@svgrid/mcp 3.0.4 → 3.0.6
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 +88 -51
- package/dist/preview.d.ts +1 -1
- package/dist/preview.js +1 -1
- package/dist/search.d.ts +11 -0
- package/dist/search.js +34 -1
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/data.js
CHANGED
|
@@ -3031,6 +3031,30 @@ export const examples = [
|
|
|
3031
3031
|
"blurb": "50. E-commerce seller panel - catalog + inventory + orders + pricing The view a marketplace seller (Amazon / Shopify / eBay style) lives in: four tabs over the same underlying product list.",
|
|
3032
3032
|
"source": "<!-- Documented in: docs/help/recipes.md -->\n<script lang=\"ts\">\n /**\n * 50. E-commerce seller panel - catalog + inventory + orders + pricing\n * ---------------------------------------------------------------------\n * The view a marketplace seller (Amazon / Shopify / eBay style)\n * lives in: four tabs over the same underlying product list.\n *\n * 1. **Catalog.** Product thumbnail (programmatic SVG icon),\n * ASIN, title, category, price + sale price, stock, status.\n * Price + sale + stock + status are inline-editable.\n *\n * 2. **Inventory.** Stock level vs reorder threshold visualised\n * as a bar; low-stock and out-of-stock rows highlighted in\n * colour. One-click \"reorder\" simulates a PO submission.\n *\n * 3. **Orders.** A small pipeline of orders for these SKUs with\n * status pills (pending / packing / shipped / delivered /\n * returned), customer, line value, ship-by date.\n *\n * 4. **Pricing rules.** Catalog-wide rules with type +\n * condition + action + last-triggered timestamp; toggle\n * active / inactive inline.\n *\n * KPI strip across the top rolls live numbers regardless of which\n * tab is active.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n renderSnippet,\n type ColumnDef,\n } from '@svgrid/grid'\n\n // ---- Domain ---------------------------------------------------------\n\n type ProductCategory = 'electronics' | 'home' | 'fashion' | 'books' | 'sports' | 'beauty' | 'kitchen' | 'garden'\n type ProductStatus = 'live' | 'paused' | 'review' | 'archived'\n\n type Product = {\n id: string // ASIN-style\n title: string\n category: ProductCategory\n price: number\n salePrice: number | null\n cost: number\n stock: number\n reorderAt: number\n sold30d: number\n rating: number\n reviews: number\n status: ProductStatus\n /** epoch ms */\n listedAt: number\n }\n\n type OrderStatus = 'pending' | 'packing' | 'shipped' | 'delivered' | 'returned' | 'cancelled'\n\n type Order = {\n id: string\n productId: string\n customer: string\n qty: number\n /** epoch ms */\n placedAt: number\n /** epoch ms */\n shipBy: number\n status: OrderStatus\n revenue: number\n region: 'NA' | 'EMEA' | 'APAC'\n }\n\n type PriceRuleType = 'discount' | 'bundle' | 'volume' | 'flash' | 'competitor'\n type PriceRule = {\n id: string\n name: string\n type: PriceRuleType\n condition: string\n action: string\n /** epoch ms */\n lastTriggered: number | null\n triggersWeek: number\n active: boolean\n }\n\n // ---- Seed -----------------------------------------------------------\n\n const CATEGORIES: readonly ProductCategory[] = ['electronics', 'home', 'fashion', 'books', 'sports', 'beauty', 'kitchen', 'garden']\n const STATUSES: readonly ProductStatus[] = ['live', 'paused', 'review', 'archived']\n const ORDER_STATUSES: readonly OrderStatus[] = ['pending', 'packing', 'shipped', 'delivered', 'returned', 'cancelled']\n const STATUS_OPTIONS = STATUSES.map((s) => ({ value: s, label: s }))\n\n const PRODUCT_TITLES: Record<ProductCategory, string[]> = {\n electronics: ['Bluetooth Earbuds Pro', 'Smart LED Bulb 4-pack', 'USB-C Hub 7-in-1', '4K Action Camera', 'Wireless Charging Pad', 'Mechanical Keyboard 75%', 'Noise-cancelling Headphones', 'GPS Smartwatch'],\n home: ['Memory Foam Pillow', 'Linen Curtains 84\"', 'Floor Lamp Brass', 'Throw Blanket 50x60', 'Bath Towel Set 6pc', 'Storage Bins Set of 3', 'Wall Clock Modern'],\n fashion: ['Merino Wool Sweater', 'Canvas Sneakers', 'Leather Belt Black', 'Cotton T-shirt 3-pack', 'Down Puffer Jacket', 'Denim Jeans Slim', 'Wool Beanie'],\n books: ['Atomic Habits', 'Sapiens', 'The Pragmatic Programmer', 'Project Hail Mary', 'Designing Data-intensive Apps', 'Clean Code'],\n sports: ['Yoga Mat 6mm', 'Adjustable Dumbbells', 'Resistance Band Set', 'Cycling Shorts', 'Running Belt'],\n beauty: ['Vitamin C Serum', 'Sheet Mask Variety Pack', 'Hair Dryer Ionic', 'Beard Trimmer Pro', 'Lip Balm Trio'],\n kitchen: ['Cast Iron Skillet 12\"', 'Chef Knife 8\"', 'French Press 1L', 'Cutting Board Maple', 'Air Fryer 5-quart'],\n garden: ['Pruning Shears', 'Hose Reel Cart', 'Raised Garden Bed', 'Solar Path Lights 8-pack', 'Plant Mister Brass'],\n }\n\n const CUSTOMERS = [\n 'Alex P.', 'Jamie C.', 'Casey S.', 'Drew O.', 'Robin D.', 'Morgan T.', 'Riley K.', 'Quinn M.',\n 'Avery R.', 'Reese B.', 'Sam L.', 'Logan N.', 'Cameron H.', 'Skyler J.', 'Jordan A.',\n ]\n\n let prng = 0xA3A2A1A0 >>> 0\n function rnd(): number {\n prng = (prng * 1664525 + 1013904223) >>> 0\n return prng / 0xFFFFFFFF\n }\n function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rnd() * arr.length)]! }\n\n function asin(i: number): string {\n const block = (n: number, len: number) => n.toString(36).toUpperCase().padStart(len, '0')\n return `B0${block(0x6F000 + i, 4)}${block(Math.floor(rnd() * 4096), 3)}`\n }\n\n function seedProducts(): Product[] {\n const out: Product[] = []\n for (const cat of CATEGORIES) {\n const titles = PRODUCT_TITLES[cat]\n for (const t of titles) {\n const price = Math.round((9.99 + rnd() * 290) * 100) / 100\n const cost = Math.round(price * (0.35 + rnd() * 0.25) * 100) / 100\n const hasSale = rnd() < 0.28\n const salePrice = hasSale ? Math.round(price * (0.75 + rnd() * 0.15) * 100) / 100 : null\n const statusRoll = rnd()\n const status: ProductStatus = statusRoll < 0.7 ? 'live' : statusRoll < 0.85 ? 'paused' : statusRoll < 0.95 ? 'review' : 'archived'\n const stockBase = status === 'archived' ? 0 : Math.floor(rnd() * 320)\n const reorderAt = 20 + Math.floor(rnd() * 60)\n out.push({\n id: asin(out.length),\n title: t,\n category: cat,\n price,\n salePrice,\n cost,\n stock: stockBase,\n reorderAt,\n sold30d: status === 'archived' ? 0 : Math.floor(rnd() * 850),\n rating: 3.6 + rnd() * 1.4,\n reviews: Math.floor(rnd() * 5200),\n status,\n listedAt: Date.now() - Math.floor(rnd() * 720) * 86_400_000,\n })\n }\n }\n return out\n }\n\n function seedOrders(products: Product[]): Order[] {\n const out: Order[] = []\n const today = Date.now()\n for (let i = 0; i < 36; i += 1) {\n const p = products[Math.floor(rnd() * products.length)]!\n const qty = 1 + Math.floor(rnd() * 4)\n const status = pick(ORDER_STATUSES)\n const placedAt = today - Math.floor(rnd() * 12) * 86_400_000 - Math.floor(rnd() * 86_400_000)\n const shipBy = placedAt + (1 + Math.floor(rnd() * 4)) * 86_400_000\n const unit = p.salePrice ?? p.price\n out.push({\n id: `ORD-${(1000 + i).toString()}`,\n productId: p.id,\n customer: pick(CUSTOMERS),\n qty,\n placedAt,\n shipBy,\n status,\n revenue: Math.round(unit * qty * 100) / 100,\n region: pick(['NA', 'EMEA', 'APAC'] as const),\n })\n }\n return out.sort((a, b) => b.placedAt - a.placedAt)\n }\n\n function seedRules(): PriceRule[] {\n const today = Date.now()\n return [\n { id: 'PR-001', name: 'Free shipping over $25', type: 'discount', condition: 'subtotal >= $25', action: 'shipping := $0', lastTriggered: today - 3 * 3_600_000, triggersWeek: 412, active: true },\n { id: 'PR-002', name: 'Buy 3 books, 15% off', type: 'bundle', condition: 'category=books & qty>=3', action: 'discount := 15%', lastTriggered: today - 26 * 3_600_000, triggersWeek: 87, active: true },\n { id: 'PR-003', name: 'Volume tier - kitchen', type: 'volume', condition: 'category=kitchen & qty>=5', action: 'unit -= 8%', lastTriggered: today - 50 * 3_600_000, triggersWeek: 22, active: true },\n { id: 'PR-004', name: 'Friday 6-9pm flash sale', type: 'flash', condition: 'day=Fri & 18:00-21:00', action: 'site_wide := -12%', lastTriggered: today - 6 * 86_400_000, triggersWeek: 1, active: true },\n { id: 'PR-005', name: 'Repricer (competitor undercut)', type: 'competitor', condition: 'lowest_offer < my_price', action: 'price := lowest_offer - $0.50', lastTriggered: today - 17 * 60_000, triggersWeek: 1248, active: true },\n { id: 'PR-006', name: 'New customer welcome', type: 'discount', condition: 'customer.orders == 0', action: 'discount := 10% (max $20)', lastTriggered: today - 2 * 86_400_000, triggersWeek: 64, active: false },\n { id: 'PR-007', name: 'Clearance archived stock', type: 'discount', condition: 'status=archived & stock>0', action: 'price -= 35%', lastTriggered: null, triggersWeek: 0, active: false },\n { id: 'PR-008', name: 'Bundle: pillow + blanket', type: 'bundle', condition: '\"Memory Foam Pillow\"+\"Throw Blanket\"', action: 'discount := $8 off', lastTriggered: today - 11 * 3_600_000, triggersWeek: 19, active: true },\n ]\n }\n\n // ---- Reactive state -------------------------------------------------\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n let products = $state<Product[]>(seedProducts())\n // svelte-ignore state_referenced_locally\n let orders = $state<Order[]>(seedOrders(products))\n let rules = $state<PriceRule[]>(seedRules())\n\n type Tab = 'catalog' | 'inventory' | 'orders' | 'pricing'\n let tab = $state<Tab>('catalog')\n\n let search = $state('')\n let categoryFilter = $state<ProductCategory | 'all'>('all')\n\n const filteredProducts = $derived.by(() => {\n const q = search.trim().toLowerCase()\n return products.filter((p) => {\n if (categoryFilter !== 'all' && p.category !== categoryFilter) return false\n if (!q) return true\n return p.title.toLowerCase().includes(q) || p.id.toLowerCase().includes(q) || p.category.includes(q)\n })\n })\n\n const productById = $derived(new Map(products.map((p) => [p.id, p])))\n\n const kpis = $derived.by(() => {\n const live = products.filter((p) => p.status === 'live').length\n const lowStock = products.filter((p) => p.status === 'live' && p.stock > 0 && p.stock <= p.reorderAt).length\n const outOfStock = products.filter((p) => p.status === 'live' && p.stock === 0).length\n const today = new Date()\n today.setHours(0, 0, 0, 0)\n const todayMs = today.getTime()\n const todayOrders = orders.filter((o) => o.placedAt >= todayMs && o.status !== 'cancelled')\n const todayRevenue = todayOrders.reduce((s, o) => s + o.revenue, 0)\n const returns = orders.filter((o) => o.status === 'returned').length\n return { live, lowStock, outOfStock, todayOrders: todayOrders.length, todayRevenue, returns }\n })\n\n // Catalog mutations\n function updateProduct(id: string, patch: Partial<Product>): void {\n products = products.map((p) => (p.id === id ? { ...p, ...patch } : p))\n }\n function bulkReorder(ids: string[]): void {\n products = products.map((p) => (ids.includes(p.id) ? { ...p, stock: p.stock + 100 } : p))\n }\n function reorder(id: string, qty: number): void { updateProduct(id, { stock: (productById.get(id)?.stock ?? 0) + qty }) }\n\n function advanceOrder(id: string): void {\n orders = orders.map((o) => {\n if (o.id !== id) return o\n const flow: OrderStatus[] = ['pending', 'packing', 'shipped', 'delivered']\n const ix = flow.indexOf(o.status)\n if (ix < 0 || ix === flow.length - 1) return o\n return { ...o, status: flow[ix + 1]! }\n })\n }\n\n function toggleRule(id: string): void {\n rules = rules.map((r) => (r.id === id ? { ...r, active: !r.active } : r))\n }\n\n // Live \"demo orders pulse\" - bumps the most recent order's status\n // every ~6 s so the Orders tab feels alive.\n $effect(() => {\n const id = setInterval(() => {\n orders = orders.map((o, i) => {\n if (i !== 0) return o\n const flow: OrderStatus[] = ['pending', 'packing', 'shipped', 'delivered']\n const ix = flow.indexOf(o.status)\n if (ix < 0 || ix === flow.length - 1) return o\n return { ...o, status: flow[ix + 1]! }\n })\n }, 6_000)\n return () => clearInterval(id)\n })\n\n // ---- Formatters -----------------------------------------------------\n\n function fmtMoney(n: number): string {\n return n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 2 })\n }\n function fmtMoneyK(n: number): string {\n if (Math.abs(n) >= 1000) return `$${(n / 1000).toFixed(1)}k`\n return `$${n.toFixed(0)}`\n }\n function fmtRelative(ts: number | null): string {\n if (!ts) return 'never'\n const diff = Date.now() - ts\n const mins = Math.floor(diff / 60_000)\n if (mins < 1) return 'just now'\n if (mins < 60) return `${mins}m ago`\n const hours = Math.floor(mins / 60)\n if (hours < 24) return `${hours}h ago`\n return `${Math.floor(hours / 24)}d ago`\n }\n function fmtShipBy(ts: number): string {\n const diff = ts - Date.now()\n if (diff < 0) return 'overdue'\n const days = Math.floor(diff / 86_400_000)\n if (days === 0) return 'today'\n if (days === 1) return 'tomorrow'\n return `in ${days}d`\n }\n\n // ---- Thumbnail icons (per category, programmatic SVG) --------------\n\n type Glyph = { bg: string; fg: string; path: string }\n const CATEGORY_ART: Record<ProductCategory, Glyph> = {\n electronics: { bg: '#0c4a6e', fg: '#7dd3fc', path: 'M6 8 h12 v8 h-12 z M9 16 v3 M15 16 v3 M9 8 v-2 h6 v2' },\n home: { bg: '#7c2d12', fg: '#fdba74', path: 'M4 13 l8-7 l8 7 v8 h-5 v-5 h-6 v5 h-5 z' },\n fashion: { bg: '#581c87', fg: '#d8b4fe', path: 'M6 4 l3 2 l3-1 l3 1 l3-2 l3 4 l-3 2 v9 h-12 v-9 l-3-2 z' },\n books: { bg: '#7c2d12', fg: '#fde68a', path: 'M5 4 h6 v16 h-6 z M13 4 h6 v16 h-6 z M5 9 h6 M13 9 h6' },\n sports: { bg: '#14532d', fg: '#86efac', path: 'M12 4 a8 8 0 1 0 0 16 a8 8 0 1 0 0-16 M4 12 h16 M12 4 a8 8 0 0 1 0 16 M12 4 a8 8 0 0 0 0 16' },\n beauty: { bg: '#831843', fg: '#fbcfe8', path: 'M10 4 h4 v3 h-4 z M9 7 h6 v4 h-6 z M8 11 h8 v9 h-8 z' },\n kitchen: { bg: '#374151', fg: '#fde047', path: 'M5 10 a7 5 0 0 1 14 0 v1 h-14 z M4 11 h16 v2 h-16 z M9 13 v6 M15 13 v6' },\n garden: { bg: '#064e3b', fg: '#86efac', path: 'M12 21 v-7 M12 14 a4 4 0 0 1-4-4 a4 4 0 0 1 4-4 a4 4 0 0 1 4 4 a4 4 0 0 1-4 4 M8 14 a4 4 0 0 0-4 0 M16 14 a4 4 0 0 1 4 0' },\n }\n</script>\n\n<!-- ───────────────────── CELL SNIPPETS - CATALOG ───────────────────── -->\n\n{#snippet ProductCell(props: { row: Product })}\n {@const art = CATEGORY_ART[props.row.category]}\n <span class=\"sp-prod\">\n <span class=\"sp-thumb\" style={`background:${art.bg}`} aria-hidden=\"true\">\n <svg viewBox=\"0 0 24 24\" width=\"32\" height=\"32\" fill=\"none\" stroke={art.fg} stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d={art.path}></path>\n </svg>\n </span>\n <span class=\"sp-prod-text\">\n <span class=\"sp-prod-title\">{props.row.title}</span>\n <span class=\"sp-prod-meta tabular-nums\">\n {props.row.id}\n <span class=\"sp-prod-rating\">★ {props.row.rating.toFixed(1)}</span>\n <span class=\"sp-muted\">({props.row.reviews.toLocaleString('en-US')})</span>\n </span>\n </span>\n </span>\n{/snippet}\n\n{#snippet CategoryCell(props: { row: Product })}\n <span class={`sp-cat sp-cat-${props.row.category}`}>{props.row.category}</span>\n{/snippet}\n\n{#snippet PriceCell(props: { row: Product })}\n <span class=\"sp-price\">\n {#if props.row.salePrice}\n <span class=\"sp-price-sale tabular-nums\">{fmtMoney(props.row.salePrice)}</span>\n <span class=\"sp-price-old tabular-nums\">{fmtMoney(props.row.price)}</span>\n {:else}\n <span class=\"sp-price-base tabular-nums\">{fmtMoney(props.row.price)}</span>\n {/if}\n <span class=\"sp-margin tabular-nums sp-muted\">m. {fmtMoney((props.row.salePrice ?? props.row.price) - props.row.cost)}</span>\n </span>\n{/snippet}\n\n{#snippet StockCell(props: { row: Product })}\n {@const out = props.row.stock === 0}\n {@const low = !out && props.row.stock <= props.row.reorderAt}\n <span class={`sp-stock ${out ? 'sp-stock-out' : low ? 'sp-stock-low' : 'sp-stock-ok'}`}>\n <span class=\"tabular-nums\">{props.row.stock}</span>\n <span class=\"sp-stock-bar\">\n <span class=\"sp-stock-fill\" style={`width: ${Math.min(100, (props.row.stock / Math.max(props.row.reorderAt * 3, 1)) * 100)}%`}></span>\n <span class=\"sp-stock-mark\" style={`left: ${Math.min(100, (props.row.reorderAt / Math.max(props.row.reorderAt * 3, 1)) * 100)}%`}></span>\n </span>\n <span class=\"sp-stock-foot sp-muted tabular-nums\">reorder @ {props.row.reorderAt}</span>\n </span>\n{/snippet}\n\n{#snippet ProductStatusCell(props: { row: Product })}\n <span class={`sp-status sp-status-${props.row.status}`}>{props.row.status}</span>\n{/snippet}\n\n{#snippet SoldCell(props: { row: Product })}\n <span class=\"sp-sold\">\n <span class=\"tabular-nums\">{props.row.sold30d}</span>\n <span class=\"sp-muted\">sold / 30d</span>\n </span>\n{/snippet}\n\n<!-- ───────────────────── CELL SNIPPETS - INVENTORY ───────────────────── -->\n\n{#snippet InventoryCell(props: { row: Product })}\n {@const ratio = props.row.reorderAt > 0 ? props.row.stock / (props.row.reorderAt * 3) : 1}\n {@const out = props.row.stock === 0}\n {@const low = !out && props.row.stock <= props.row.reorderAt}\n <span class=\"sp-inv\">\n <span class=\"sp-inv-bar\">\n <span class={`sp-inv-fill ${out ? 'sp-inv-out' : low ? 'sp-inv-low' : 'sp-inv-ok'}`} style={`width: ${Math.min(100, ratio * 100)}%`}></span>\n <span class=\"sp-inv-threshold\" style={`left: ${Math.min(100, (props.row.reorderAt / Math.max(props.row.reorderAt * 3, 1)) * 100)}%`}></span>\n </span>\n <span class=\"sp-inv-text tabular-nums\">{props.row.stock} <span class=\"sp-muted\">/ reorder @ {props.row.reorderAt}</span></span>\n </span>\n{/snippet}\n\n{#snippet InvActionCell(props: { row: Product })}\n <button type=\"button\" class=\"sp-btn sp-btn-sm\" onclick={() => reorder(props.row.id, 100)}>Reorder 100</button>\n{/snippet}\n\n{#snippet DaysCoverCell(props: { row: Product })}\n {@const daily = Math.max(1, props.row.sold30d / 30)}\n {@const days = props.row.stock / daily}\n {@const tight = days < 7 && props.row.status === 'live'}\n <span class={`sp-days ${tight ? 'sp-days-tight' : ''}`}>\n <span class=\"tabular-nums\">{Math.round(days)}</span>\n <span class=\"sp-muted\">days cover</span>\n </span>\n{/snippet}\n\n<!-- ───────────────────── CELL SNIPPETS - ORDERS ───────────────────── -->\n\n{#snippet OrderProductCell(props: { row: Order })}\n {@const p = productById.get(props.row.productId)}\n {#if p}\n {@const art = CATEGORY_ART[p.category]}\n <span class=\"sp-prod\">\n <span class=\"sp-thumb sp-thumb-sm\" style={`background:${art.bg}`}>\n <svg viewBox=\"0 0 24 24\" width=\"20\" height=\"20\" fill=\"none\" stroke={art.fg} stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d={art.path}></path>\n </svg>\n </span>\n <span class=\"sp-prod-text\">\n <span class=\"sp-prod-title sp-prod-title-sm\">{p.title}</span>\n <span class=\"sp-prod-meta sp-muted tabular-nums\">{p.id} - qty {props.row.qty}</span>\n </span>\n </span>\n {:else}\n <span class=\"sp-muted\">{props.row.productId}</span>\n {/if}\n{/snippet}\n\n{#snippet OrderStatusCell(props: { row: Order })}\n <span class={`sp-ord-status sp-ord-${props.row.status}`}>\n <span class=\"sp-ord-dot\"></span>{props.row.status}\n </span>\n{/snippet}\n\n{#snippet ShipByCell(props: { row: Order })}\n {@const late = props.row.shipBy < Date.now() && (props.row.status === 'pending' || props.row.status === 'packing')}\n <span class={`sp-shipby ${late ? 'sp-shipby-late' : ''}`}>\n <span>{new Date(props.row.shipBy).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>\n <span class=\"sp-muted\">{fmtShipBy(props.row.shipBy)}</span>\n </span>\n{/snippet}\n\n{#snippet OrderActionCell(props: { row: Order })}\n <button\n type=\"button\"\n class=\"sp-btn sp-btn-sm\"\n disabled={props.row.status === 'delivered' || props.row.status === 'cancelled' || props.row.status === 'returned'}\n onclick={() => advanceOrder(props.row.id)}\n >Advance</button>\n{/snippet}\n\n<!-- ───────────────────── CELL SNIPPETS - PRICING ───────────────────── -->\n\n{#snippet RuleNameCell(props: { row: PriceRule })}\n <span class=\"sp-rule-name\">\n <span class=\"sp-rule-title\">{props.row.name}</span>\n <span class=\"sp-muted tabular-nums\">{props.row.id}</span>\n </span>\n{/snippet}\n\n{#snippet RuleTypeCell(props: { row: PriceRule })}\n <span class={`sp-rule-type sp-rule-type-${props.row.type}`}>{props.row.type}</span>\n{/snippet}\n\n{#snippet RuleActiveCell(props: { row: PriceRule })}\n <button\n type=\"button\"\n class={`sp-toggle ${props.row.active ? 'sp-toggle-on' : ''}`}\n aria-pressed={props.row.active}\n onclick={() => toggleRule(props.row.id)}\n >\n <span class=\"sp-toggle-knob\"></span>\n </button>\n{/snippet}\n\n{#snippet RuleTriggersCell(props: { row: PriceRule })}\n <span class=\"sp-rule-stats\">\n <span class=\"tabular-nums\">{props.row.triggersWeek}</span>\n <span class=\"sp-muted\">this week</span>\n <span class=\"sp-muted\">last: {fmtRelative(props.row.lastTriggered)}</span>\n </span>\n{/snippet}\n\n<!-- ───────────────────── LAYOUT ───────────────────── -->\n\n<section class=\"sp-shell flex flex-col flex-1 min-h-0 gap-3\">\n <!-- KPI strip -->\n <div class=\"sp-kpi-strip\">\n <div class=\"sp-kpi\">\n <div class=\"sp-kpi-label\">Live listings</div>\n <div class=\"sp-kpi-value tabular-nums\">{kpis.live}</div>\n <div class=\"sp-kpi-foot\">of {products.length} total</div>\n </div>\n <div class=\"sp-kpi\">\n <div class=\"sp-kpi-label\">Low stock</div>\n <div class={`sp-kpi-value tabular-nums ${kpis.lowStock > 0 ? 'sp-warn' : ''}`}>{kpis.lowStock}</div>\n <div class=\"sp-kpi-foot\">{kpis.outOfStock} out of stock</div>\n </div>\n <div class=\"sp-kpi\">\n <div class=\"sp-kpi-label\">Today's orders</div>\n <div class=\"sp-kpi-value tabular-nums\">{kpis.todayOrders}</div>\n <div class=\"sp-kpi-foot\">{kpis.returns} returns this period</div>\n </div>\n <div class=\"sp-kpi\">\n <div class=\"sp-kpi-label\">Today's revenue</div>\n <div class=\"sp-kpi-value tabular-nums sp-accent\">{fmtMoneyK(kpis.todayRevenue)}</div>\n <div class=\"sp-kpi-foot\">incl. all live orders</div>\n </div>\n <div class=\"sp-kpi\">\n <div class=\"sp-kpi-label\">Active rules</div>\n <div class=\"sp-kpi-value tabular-nums\">{rules.filter((r) => r.active).length}</div>\n <div class=\"sp-kpi-foot\">of {rules.length} pricing rules</div>\n </div>\n </div>\n\n <!-- Tab strip -->\n <div class=\"sp-tabs\">\n <button type=\"button\" class={`sp-tab ${tab === 'catalog' ? 'sp-tab-active' : ''}`} onclick={() => (tab = 'catalog')}>\n Catalog <span class=\"sp-tab-n tabular-nums\">{products.length}</span>\n </button>\n <button type=\"button\" class={`sp-tab ${tab === 'inventory' ? 'sp-tab-active' : ''}`} onclick={() => (tab = 'inventory')}>\n Inventory\n {#if kpis.lowStock + kpis.outOfStock > 0}<span class=\"sp-tab-n sp-tab-n-warn tabular-nums\">{kpis.lowStock + kpis.outOfStock}</span>{/if}\n </button>\n <button type=\"button\" class={`sp-tab ${tab === 'orders' ? 'sp-tab-active' : ''}`} onclick={() => (tab = 'orders')}>\n Orders <span class=\"sp-tab-n tabular-nums\">{orders.length}</span>\n </button>\n <button type=\"button\" class={`sp-tab ${tab === 'pricing' ? 'sp-tab-active' : ''}`} onclick={() => (tab = 'pricing')}>\n Pricing rules <span class=\"sp-tab-n tabular-nums\">{rules.length}</span>\n </button>\n <span class=\"sp-tabs-spacer\"></span>\n {#if tab === 'catalog' || tab === 'inventory'}\n <input\n type=\"search\"\n class=\"sp-search\"\n placeholder=\"Search title, ASIN, category...\"\n bind:value={search}\n />\n <select class=\"sp-select\" bind:value={categoryFilter} aria-label=\"Filter by category\">\n <option value=\"all\">All categories</option>\n {#each CATEGORIES as c (c)}<option value={c}>{c}</option>{/each}\n </select>\n {/if}\n </div>\n\n <!-- Grid area -->\n <div class=\"flex-1 min-h-0\">\n {#if tab === 'catalog'}\n <SvGrid responsive={true}\n columnResize\n data={filteredProducts}\n columns={[\n { field: 'title', header: 'Product', width: 320, editable: false,\n cell: (ctx) => renderSnippet(ProductCell, { row: ctx.row.original }) },\n { field: 'category', header: 'Category', width: 120, editable: false,\n cell: (ctx) => renderSnippet(CategoryCell, { row: ctx.row.original }) },\n { field: 'price', header: 'Price', editorType: 'number', width: 150,\n cell: (ctx) => renderSnippet(PriceCell, { row: ctx.row.original }) },\n { field: 'salePrice', header: 'Sale', editorType: 'number', width: 100 },\n { field: 'stock', header: 'Stock', editorType: 'number', width: 170,\n cell: (ctx) => renderSnippet(StockCell, { row: ctx.row.original }) },\n { field: 'sold30d', header: 'Sold', editorType: 'number', width: 110, editable: false,\n cell: (ctx) => renderSnippet(SoldCell, { row: ctx.row.original }) },\n { field: 'status', header: 'Status', editorType: 'list', width: 110,\n editorOptions: STATUS_OPTIONS,\n cell: (ctx) => renderSnippet(ProductStatusCell, { row: ctx.row.original }) },\n ] satisfies ColumnDef<typeof features, Product>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"row\"\n showRowSelection={true}\n enableInlineEditing={true}\n enableCellSelection={false}\n rowHeight={56}\n containerHeight=\"100%\"\n fitColumns={true}\n />\n {:else if tab === 'inventory'}\n <SvGrid responsive={true}\n columnResize\n data={filteredProducts}\n columns={[\n { field: 'title', header: 'Product', width: 300, editable: false,\n cell: (ctx) => renderSnippet(ProductCell, { row: ctx.row.original }) },\n { field: 'stock', header: 'Stock level', editorType: 'number', width: 280, editable: false,\n cell: (ctx) => renderSnippet(InventoryCell, { row: ctx.row.original }) },\n { field: 'reorderAt', header: 'Reorder at', editorType: 'number', width: 110 },\n { field: 'sold30d', header: 'Days cover', editorType: 'number', width: 120, editable: false,\n cell: (ctx) => renderSnippet(DaysCoverCell, { row: ctx.row.original }) },\n { field: 'status', header: 'Status', width: 110, editable: false,\n cell: (ctx) => renderSnippet(ProductStatusCell, { row: ctx.row.original }) },\n { field: 'id', header: '', width: 140, editable: false,\n cell: (ctx) => renderSnippet(InvActionCell, { row: ctx.row.original }) },\n ] satisfies ColumnDef<typeof features, Product>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"row\"\n showRowSelection={false}\n enableInlineEditing={true}\n enableCellSelection={false}\n rowHeight={56}\n containerHeight=\"100%\"\n fitColumns={true}\n />\n {:else if tab === 'orders'}\n <SvGrid responsive={true}\n columnResize\n data={orders}\n columns={[\n { field: 'id', header: 'Order', width: 120, editable: false },\n { field: 'productId', header: 'Item', width: 320, editable: false,\n cell: (ctx) => renderSnippet(OrderProductCell, { row: ctx.row.original }) },\n { field: 'customer', header: 'Customer', width: 130, editable: false },\n { field: 'region', header: 'Region', width: 80, editable: false },\n { field: 'revenue', header: 'Total', editorType: 'number', width: 110, editable: false },\n { field: 'placedAt', header: 'Placed', width: 140, editable: false },\n { field: 'shipBy', header: 'Ship by', width: 140, editable: false,\n cell: (ctx) => renderSnippet(ShipByCell, { row: ctx.row.original }) },\n { field: 'status', header: 'Status', width: 130, editable: false,\n cell: (ctx) => renderSnippet(OrderStatusCell, { row: ctx.row.original }) },\n { field: 'qty', header: '', width: 110, editable: false,\n cell: (ctx) => renderSnippet(OrderActionCell, { row: ctx.row.original }) },\n ] satisfies ColumnDef<typeof features, Order>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"row\"\n showRowSelection={false}\n enableInlineEditing={false}\n enableCellSelection={false}\n rowHeight={56}\n containerHeight=\"100%\"\n fitColumns={true}\n />\n {:else if tab === 'pricing'}\n <SvGrid responsive={true}\n columnResize\n data={rules}\n columns={[\n { field: 'name', header: 'Rule', width: 250, editable: false,\n cell: (ctx) => renderSnippet(RuleNameCell, { row: ctx.row.original }) },\n { field: 'type', header: 'Type', width: 110, editable: false,\n cell: (ctx) => renderSnippet(RuleTypeCell, { row: ctx.row.original }) },\n { field: 'condition', header: 'Condition', width: 280, editable: false },\n { field: 'action', header: 'Action', width: 250, editable: false },\n { field: 'triggersWeek', header: 'Triggers', editorType: 'number', width: 180, editable: false,\n cell: (ctx) => renderSnippet(RuleTriggersCell, { row: ctx.row.original }) },\n // `field: 'id'` (string) instead of the real `active` boolean -\n // SvGrid auto-renders boolean fields as its own read-only\n // checkbox and ignores the cell: snippet, so we route through\n // the rule id and pull the live value off ctx.row.original.\n { field: 'id', header: 'On', width: 70, editable: false,\n cell: (ctx) => renderSnippet(RuleActiveCell, { row: ctx.row.original }) },\n ] satisfies ColumnDef<typeof features, PriceRule>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"row\"\n showRowSelection={false}\n enableInlineEditing={false}\n enableCellSelection={false}\n rowHeight={56}\n containerHeight=\"100%\"\n fitColumns={true}\n />\n {/if}\n </div>\n</section>\n\n<style>\n /* KPI strip */\n .sp-kpi-strip {\n display: grid;\n grid-template-columns: repeat(5, minmax(0, 1fr));\n gap: 10px;\n flex-shrink: 0;\n }\n .sp-kpi {\n border: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #ffffff);\n border-radius: 10px;\n padding: 12px 14px;\n }\n .sp-kpi-label {\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n color: var(--sg-muted, #64748b);\n margin-bottom: 6px;\n }\n .sp-kpi-value { font-size: 22px; font-weight: 700; line-height: 1.1; }\n .sp-kpi-foot { margin-top: 6px; font-size: 11px; color: var(--sg-muted, #64748b); }\n .sp-warn { color: #d97706; }\n :global([data-theme='dark']) .sp-warn { color: #fbbf24; }\n .sp-accent { color: #f97316; }\n\n /* Tab strip */\n .sp-tabs {\n display: flex;\n align-items: center;\n gap: 6px;\n flex-wrap: wrap;\n flex-shrink: 0;\n }\n .sp-tabs-spacer { flex: 1 1 0; }\n .sp-tab {\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-bg, #ffffff);\n color: var(--sg-fg, #1e293b);\n border-radius: 999px;\n padding: 5px 14px;\n font-size: 12.5px;\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n }\n .sp-tab:hover { background: var(--sg-header-bg, #f1f5f9); }\n .sp-tab-active {\n background: var(--sg-accent, #1e293b);\n color: var(--sg-on-accent, #fff);\n border-color: transparent;\n }\n .sp-tab-n {\n font-size: 11px;\n background: rgba(15, 23, 42, 0.08);\n padding: 1px 7px;\n border-radius: 999px;\n }\n .sp-tab-active .sp-tab-n { background: rgba(255, 255, 255, 0.18); }\n .sp-tab-n-warn { background: #fef3c7; color: #92400e; }\n :global([data-theme='dark']) .sp-tab-n-warn { background: rgba(245,158,11,.2); color: #fbbf24; }\n .sp-search {\n flex: 0 1 240px;\n min-width: 180px;\n border: 1px solid var(--sg-input-border, #cbd5e1);\n background: var(--sg-input-bg, #ffffff);\n color: var(--sg-fg, #1e293b);\n border-radius: 6px;\n padding: 5px 10px;\n font-size: 12.5px;\n }\n .sp-select {\n border: 1px solid var(--sg-input-border, #cbd5e1);\n background: var(--sg-input-bg, #ffffff);\n color: var(--sg-fg, #1e293b);\n border-radius: 6px;\n padding: 5px 10px;\n font-size: 12.5px;\n text-transform: capitalize;\n }\n\n /* Product cell shared between catalog + orders */\n :global(.sp-prod) { display: inline-flex; align-items: center; gap: 10px; min-width: 0; }\n :global(.sp-thumb) {\n width: 44px; height: 44px;\n border-radius: 8px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n }\n :global(.sp-thumb-sm) { width: 32px; height: 32px; }\n :global(.sp-prod-text) { display: inline-flex; flex-direction: column; line-height: 1.25; min-width: 0; }\n :global(.sp-prod-title) {\n font-weight: 600;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n :global(.sp-prod-title-sm) { font-size: 12.5px; }\n :global(.sp-prod-meta) {\n font-size: 11px;\n color: var(--sg-muted, #64748b);\n display: inline-flex;\n gap: 8px;\n align-items: center;\n }\n :global(.sp-prod-rating) {\n color: #f97316;\n font-weight: 600;\n }\n :global(.sp-muted) { color: var(--sg-muted, #64748b); }\n\n /* Category pill */\n :global(.sp-cat) {\n display: inline-block;\n padding: 1px 9px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n }\n :global(.sp-cat-electronics) { background: #dbeafe; color: #1d4ed8; }\n :global(.sp-cat-home) { background: #fef3c7; color: #92400e; }\n :global(.sp-cat-fashion) { background: #f3e8ff; color: #6b21a8; }\n :global(.sp-cat-books) { background: #fed7aa; color: #9a3412; }\n :global(.sp-cat-sports) { background: #dcfce7; color: #166534; }\n :global(.sp-cat-beauty) { background: #fce7f3; color: #9d174d; }\n :global(.sp-cat-kitchen) { background: #fee2e2; color: #991b1b; }\n :global(.sp-cat-garden) { background: #d1fae5; color: #065f46; }\n\n /* Price cell */\n :global(.sp-price) { display: inline-flex; flex-direction: column; line-height: 1.2; }\n :global(.sp-price-sale) { font-weight: 700; color: #b91c1c; }\n :global([data-theme='dark']) :global(.sp-price-sale) { color: #f87171; }\n :global(.sp-price-old) { font-size: 11px; text-decoration: line-through; color: var(--sg-muted, #64748b); }\n :global(.sp-price-base) { font-weight: 600; }\n :global(.sp-margin) { font-size: 10.5px; }\n\n /* Stock cell */\n :global(.sp-stock) {\n display: inline-grid;\n grid-template-columns: 36px 1fr;\n grid-template-rows: auto auto;\n column-gap: 6px;\n row-gap: 2px;\n width: 100%;\n align-items: center;\n }\n :global(.sp-stock > span:first-child) { font-weight: 700; }\n :global(.sp-stock-bar) {\n position: relative;\n height: 5px;\n background: var(--sg-input-bg, #e2e8f0);\n border-radius: 999px;\n overflow: visible;\n }\n :global(.sp-stock-fill) {\n position: absolute;\n inset: 0 auto 0 0;\n height: 100%;\n background: #16a34a;\n border-radius: 999px;\n }\n :global(.sp-stock-mark) {\n position: absolute;\n top: -2px; bottom: -2px;\n border-left: 2px dashed var(--sg-fg, #1e293b);\n }\n :global(.sp-stock-low .sp-stock-fill) { background: #f59e0b; }\n :global(.sp-stock-out .sp-stock-fill) { background: #dc2626; }\n :global(.sp-stock-foot) {\n grid-column: 1 / -1;\n font-size: 10.5px;\n }\n\n /* Status pill */\n :global(.sp-status) {\n display: inline-block;\n padding: 1px 9px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n }\n :global(.sp-status-live) { background: #dcfce7; color: #166534; }\n :global(.sp-status-paused) { background: #fef3c7; color: #92400e; }\n :global(.sp-status-review) { background: #dbeafe; color: #1d4ed8; }\n :global(.sp-status-archived) { background: #e2e8f0; color: #475569; }\n :global([data-theme='dark']) :global(.sp-status-live) { background: rgba(34,197,94,.2); color: #4ade80; }\n :global([data-theme='dark']) :global(.sp-status-paused) { background: rgba(245,158,11,.2); color: #fbbf24; }\n :global([data-theme='dark']) :global(.sp-status-review) { background: rgba(59,130,246,.2); color: #93c5fd; }\n :global([data-theme='dark']) :global(.sp-status-archived) { background: rgba(148,163,184,.2); color: #cbd5e1; }\n\n :global(.sp-sold) { display: inline-flex; flex-direction: column; line-height: 1.2; font-size: 11.5px; }\n :global(.sp-sold > span:first-child) { font-weight: 700; }\n\n /* Inventory cell */\n :global(.sp-inv) { display: inline-flex; flex-direction: column; gap: 4px; width: 100%; }\n :global(.sp-inv-bar) {\n position: relative;\n height: 8px;\n background: var(--sg-input-bg, #e2e8f0);\n border-radius: 999px;\n overflow: visible;\n }\n :global(.sp-inv-fill) { position: absolute; inset: 0 auto 0 0; height: 100%; border-radius: 999px; }\n :global(.sp-inv-ok) { background: linear-gradient(90deg, #16a34a, #22c55e); }\n :global(.sp-inv-low) { background: linear-gradient(90deg, #d97706, #f59e0b); }\n :global(.sp-inv-out) { background: linear-gradient(90deg, #b91c1c, #dc2626); }\n :global(.sp-inv-threshold) {\n position: absolute;\n top: -3px; bottom: -3px;\n border-left: 2px dashed var(--sg-fg, #1e293b);\n }\n :global(.sp-inv-text) { font-size: 11.5px; }\n\n :global(.sp-days) { display: inline-flex; flex-direction: column; line-height: 1.2; font-size: 11.5px; }\n :global(.sp-days > span:first-child) { font-weight: 700; }\n :global(.sp-days-tight) { color: #d97706; }\n :global([data-theme='dark']) :global(.sp-days-tight) { color: #fbbf24; }\n\n /* Orders */\n :global(.sp-ord-status) {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n font-size: 11.5px;\n text-transform: capitalize;\n padding: 2px 9px;\n border-radius: 999px;\n font-weight: 600;\n }\n :global(.sp-ord-dot) { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }\n :global(.sp-ord-pending) { background: #e2e8f0; color: #475569; }\n :global(.sp-ord-packing) { background: #fef3c7; color: #92400e; }\n :global(.sp-ord-shipped) { background: #dbeafe; color: #1d4ed8; }\n :global(.sp-ord-delivered) { background: #dcfce7; color: #166534; }\n :global(.sp-ord-returned) { background: #fee2e2; color: #b91c1c; }\n :global(.sp-ord-cancelled) { background: #f3f4f6; color: #6b7280; }\n :global([data-theme='dark']) :global(.sp-ord-pending) { background: rgba(148,163,184,.2); color: #cbd5e1; }\n :global([data-theme='dark']) :global(.sp-ord-packing) { background: rgba(245,158,11,.2); color: #fbbf24; }\n :global([data-theme='dark']) :global(.sp-ord-shipped) { background: rgba(59,130,246,.2); color: #93c5fd; }\n :global([data-theme='dark']) :global(.sp-ord-delivered) { background: rgba(34,197,94,.2); color: #4ade80; }\n :global([data-theme='dark']) :global(.sp-ord-returned) { background: rgba(239,68,68,.2); color: #f87171; }\n\n :global(.sp-shipby) { display: inline-flex; flex-direction: column; line-height: 1.2; font-size: 12px; }\n :global(.sp-shipby-late) { color: #b91c1c; font-weight: 700; }\n :global([data-theme='dark']) :global(.sp-shipby-late) { color: #f87171; }\n\n /* Pricing rules */\n :global(.sp-rule-name) { display: inline-flex; flex-direction: column; line-height: 1.2; }\n :global(.sp-rule-title) { font-weight: 600; }\n :global(.sp-rule-type) {\n display: inline-block;\n padding: 1px 9px;\n border-radius: 4px;\n font-size: 11px;\n font-weight: 700;\n text-transform: uppercase;\n }\n :global(.sp-rule-type-discount) { background: #dbeafe; color: #1d4ed8; }\n :global(.sp-rule-type-bundle) { background: #e0e7ff; color: #4338ca; }\n :global(.sp-rule-type-volume) { background: #dcfce7; color: #166534; }\n :global(.sp-rule-type-flash) { background: #fee2e2; color: #b91c1c; }\n :global(.sp-rule-type-competitor) { background: #fef3c7; color: #92400e; }\n :global(.sp-rule-stats) {\n display: inline-flex;\n flex-direction: column;\n line-height: 1.2;\n font-size: 11.5px;\n }\n :global(.sp-rule-stats > span:first-child) { font-weight: 700; font-size: 13px; }\n\n :global(.sp-toggle) {\n width: 36px;\n height: 20px;\n border-radius: 999px;\n background: var(--sg-border, #cbd5e1);\n border: 0;\n position: relative;\n cursor: pointer;\n transition: background 150ms ease;\n }\n :global(.sp-toggle-knob) {\n position: absolute;\n top: 2px;\n left: 2px;\n width: 16px;\n height: 16px;\n border-radius: 50%;\n background: var(--sg-bg, #fff);\n transition: left 150ms ease;\n box-shadow: 0 1px 2px rgba(15, 23, 42, 0.25);\n }\n :global(.sp-toggle-on) { background: #16a34a; }\n :global(.sp-toggle-on .sp-toggle-knob) { left: 18px; }\n\n /* Buttons */\n :global(.sp-btn) {\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-bg, #ffffff);\n color: var(--sg-fg, #1e293b);\n border-radius: 6px;\n padding: 5px 12px;\n font-size: 12px;\n cursor: pointer;\n }\n :global(.sp-btn:hover:not(:disabled)) { background: var(--sg-header-bg, #f1f5f9); }\n :global(.sp-btn:disabled) { opacity: 0.45; cursor: not-allowed; }\n :global(.sp-btn-sm) { padding: 3px 10px; font-size: 11px; }\n</style>\n"
|
|
3033
3033
|
},
|
|
3034
|
+
{
|
|
3035
|
+
"id": "500-worker-data-source",
|
|
3036
|
+
"path": "examples/src/demos/500-worker-data-source.svelte",
|
|
3037
|
+
"title": "Worker Data Source",
|
|
3038
|
+
"category": "Server-Side Row Model",
|
|
3039
|
+
"blurb": "500. Worker data source: sort and group off the main thread The same orders table in two grids. \"Free grid\" hands the rows to `<SvGrid data>` and lets the grid sort, filter and group them itself,",
|
|
3040
|
+
"source": "<!-- Documented in: docs/help/server/worker-data-source.md -->\n<script lang=\"ts\">\n /**\n * 500. Worker data source: sort and group off the main thread\n * -------------------------------------------------------------\n * The same orders table in two grids. \"Free grid\" hands the rows to\n * `<SvGrid data>` and lets the grid sort, filter and group them itself,\n * on the page, as the free package does. \"Enterprise: Web Worker\" builds\n * the rows inside a worker, where a columnar engine answers each request\n * (`serveWorkerDataSource`), and the server row model asks it for one block\n * of 100 rows at a time (`createWorkerDataSource`). Sort a column, group,\n * or search, and watch the meters: the slowest request, and the longest\n * gap between frames. The moving dot is driven from JavaScript, so it\n * stops whenever the page does.\n *\n * The worker data source and the row model are Enterprise; the free grid\n * on the other side is the full free package, nothing turned off.\n */\n import {\n SvGrid,\n renderComponent,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n type GridColumns,\n type ServerDataSource,\n type SvGridApi,\n } from '@svgrid/grid'\n import {\n setLicenseKey,\n installEnterprise,\n createServerRowModel,\n createWorkerDataSource,\n serverGroupText,\n SvGroupCell,\n SvRowGroupPanel,\n type ServerRowModel,\n type ServerRowModelState,\n type ServerRowModelGridRow,\n } from '@svgrid/enterprise'\n import { makeOrders, type Order } from '../shared/orders-dataset'\n\n setLicenseKey('SVENTERPRISE-DEV-LOCAL')\n\n type Mode = 'worker' | 'free'\n const canUseWorker = typeof Worker !== 'undefined'\n const SIZES = [100_000, 250_000, 500_000, 1_000_000]\n\n const INITIAL_MODE: Mode = canUseWorker ? 'worker' : 'free'\n const INITIAL_SIZE = 250_000\n\n let mode = $state<Mode>(INITIAL_MODE)\n let size = $state(INITIAL_SIZE)\n let groupBy = $state<string[]>([])\n let view = $state.raw<ServerRowModelState<Order>>()\n\n // ---- Meters --------------------------------------------------------------\n // Both read over a rolling window, so a slow sort stays on screen long\n // enough to read after it finishes.\n const WINDOW_MS = 4000\n type Sample = { at: number; ms: number }\n let requestSamples: Sample[] = []\n let frameSamples: Sample[] = []\n let slowestRequest = $state(0)\n let longestFrame = $state(0)\n let loadMs = $state<number | null>(null)\n let tick = $state(0)\n\n function windowMax(samples: Sample[], now: number): number {\n let max = 0\n for (const s of samples) if (now - s.at <= WINDOW_MS && s.ms > max) max = s.ms\n return max\n }\n\n // Wrap the source to time every block request, round trip included.\n function timed(source: ServerDataSource<Order>): ServerDataSource<Order> {\n return {\n ...source,\n async getRows(req) {\n const t0 = performance.now()\n try {\n return await source.getRows(req)\n } finally {\n requestSamples.push({ at: performance.now(), ms: performance.now() - t0 })\n }\n },\n }\n }\n\n $effect(() => {\n let prev = performance.now()\n let raf = 0\n const loop = (now: number) => {\n // A hidden tab stops painting; that pause is not the grid's.\n if (document.visibilityState === 'visible') frameSamples.push({ at: now, ms: now - prev })\n prev = now\n requestSamples = requestSamples.filter((s) => now - s.at <= WINDOW_MS)\n frameSamples = frameSamples.filter((s) => now - s.at <= WINDOW_MS)\n slowestRequest = windowMax(requestSamples, now)\n longestFrame = windowMax(frameSamples, now)\n tick = (now % 2000) / 2000\n raf = requestAnimationFrame(loop)\n }\n raf = requestAnimationFrame(loop)\n return () => cancelAnimationFrame(raf)\n })\n\n // ---- Enterprise: the worker and the row model -----------------------------\n function makeModel(rows: number, by: string[]): ServerRowModel<Order> {\n const t0 = performance.now()\n loadMs = null\n const worker = new Worker(new URL('../shared/orders.worker.ts', import.meta.url), { type: 'module' })\n // The rows are built in the worker; only their count crosses.\n worker.postMessage({ type: 'orders:init', rows })\n const source = createWorkerDataSource<Order>(worker)\n source.ready.then(() => (loadMs = performance.now() - t0), () => {})\n const model = createServerRowModel<Order>(timed(source), {\n groupBy: by,\n aggregations: [\n { col: 'amount', fn: 'sum' },\n { col: 'qty', fn: 'sum' },\n ],\n childCount: (r) => (r as { childCount?: number }).childCount,\n grandTotalRow: 'pinnedBottom',\n getRowId: (r) => String(r.id),\n blockSize: 100,\n // The worker usually answers within a frame or two: keep the rows on\n // screen until it does, rather than flashing skeletons in between.\n keepRowsWhileLoading: 500,\n onChange: (s) => (view = s),\n })\n model.refresh()\n return model\n }\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n // ---- Free: the rows on the page, the grid's own row pipeline -------------\n let freeRows = $state.raw<Order[]>([])\n let freeApi: SvGridApi<typeof freeFeatures, Order> | null = null\n const freeFeatures = tableFeatures({ rowSortingFeature, columnFilteringFeature, columnGroupingFeature, rowExpandingFeature })\n\n function makeFree(rows: number): void {\n const t0 = performance.now()\n freeRows = makeOrders(rows)\n loadMs = performance.now() - t0\n }\n\n let ctl = $state.raw<ServerRowModel<Order> | null>(INITIAL_MODE === 'worker' ? makeModel(INITIAL_SIZE, []) : null)\n if (INITIAL_MODE === 'free') makeFree(INITIAL_SIZE)\n $effect(() => () => ctl?.dispose())\n\n function rebuild(nextMode: Mode, nextSize: number) {\n ctl?.dispose() // terminates the worker, if there is one\n ctl = null\n view = undefined\n freeRows = []\n freeApi = null\n mode = nextMode\n size = nextSize\n if (nextMode === 'worker') ctl = makeModel(nextSize, groupBy)\n else makeFree(nextSize)\n }\n\n function setGroupBy(next: string[]) {\n groupBy = next\n if (mode === 'worker') ctl?.setGroupBy(next)\n else freeApi?.setGroupBy(next)\n }\n\n const groupCols = [\n { id: 'region', label: 'Region' },\n { id: 'country', label: 'Country' },\n { id: 'category', label: 'Category' },\n { id: 'status', label: 'Status' },\n ]\n\n // ---- Columns -------------------------------------------------------------\n type Row = ServerRowModelGridRow<Order>\n const usd = { type: 'number' as const, options: { style: 'currency' as const, currency: 'USD', maximumFractionDigits: 0 } }\n // The free grid totals its own groups; the worker's groups arrive totalled.\n const dataColumns = (totals: boolean) => [\n { field: 'customer', header: 'Customer', width: 110, editable: false },\n // The free grid groups by column id, so every groupable field needs a column.\n { field: 'region', header: 'Region', width: 80, editable: false },\n { field: 'country', header: 'Country', width: 70, editable: false },\n { field: 'product', header: 'Product', width: 105, editable: false },\n { field: 'category', header: 'Category', width: 85, editable: false },\n { field: 'rep', header: 'Rep', width: 75, editable: false },\n { field: 'status', header: 'Status', width: 80, editable: false },\n { field: 'placed', header: 'Placed', width: 90, editable: false },\n // Wide enough for the grand total: a million orders hold over 12 million units.\n { field: 'qty', header: 'Qty', width: 90, align: 'right', format: { type: 'number' }, editable: false, ...(totals ? { aggregate: 'sum' } : {}) },\n { field: 'amount', header: 'Amount', width: 115, align: 'right', format: usd, editable: false, ...(totals ? { aggregate: 'sum' } : {}) },\n ]\n const columns: GridColumns<Row> = [\n {\n id: 'group',\n header: 'Group / Order',\n width: 140,\n sortable: false,\n filterable: false,\n fieldFn: (row) => serverGroupText(row, 'id'),\n cell: (ctx) =>\n renderComponent(SvGroupCell, {\n row: ctx.row.original,\n onToggle: () => ctl?.group.onToggle(ctx.row.original),\n leafField: 'id',\n }),\n },\n ...(dataColumns(false) as GridColumns<Row>),\n ]\n const freeColumns: GridColumns<Order> = [\n { field: 'id', header: 'Order', width: 140, editable: false },\n ...(dataColumns(true) as GridColumns<Order>),\n ]\n\n const level = (ms: number) => (ms < 50 ? 'ok' : ms < 200 ? 'warn' : 'bad')\n const fmtMs = (ms: number) => `${Math.round(ms).toLocaleString()} ms`\n</script>\n\n<section class=\"wrap demo-kit\">\n <header class=\"chrome\">\n <div class=\"seg\" role=\"group\" aria-label=\"Which grid runs the queries\">\n <button type=\"button\" class:is-on={mode === 'worker'} aria-pressed={mode === 'worker'} disabled={!canUseWorker} onclick={() => mode !== 'worker' && rebuild('worker', size)}>Enterprise: Web Worker</button>\n <button type=\"button\" class:is-on={mode === 'free'} aria-pressed={mode === 'free'} onclick={() => mode !== 'free' && rebuild('free', size)}>Free grid</button>\n </div>\n <label class=\"sel\">\n Rows\n <select value={size} onchange={(e) => rebuild(mode, Number(e.currentTarget.value))}>\n {#each SIZES as n (n)}<option value={n}>{n.toLocaleString()}</option>{/each}\n </select>\n </label>\n <span class=\"ticker\" aria-hidden=\"true\" title=\"Moves on every frame the page paints\"><span class=\"dot\" style:transform={`translateX(${tick * 100}%)`}></span></span>\n <span class=\"note\">\n Sort a column, group by dragging a chip, or search. The <strong>Free grid</strong> sorts and groups\n every row on the page, and the page waits for it. <strong>Enterprise</strong> does the same work in\n a worker and brings back only the visible block of 100 rows.\n {#if !canUseWorker}This browser has no Web Workers, so only the free grid runs.{/if}\n </span>\n </header>\n <SvRowGroupPanel columns={groupCols} {groupBy} onChange={setGroupBy} />\n {#if mode === 'worker' && ctl}\n {#key ctl}\n <div class=\"gridpane\">\n <SvGrid\n responsive={true}\n columnResize\n fitColumns\n rowModel={ctl}\n stickyGroupRows\n {columns}\n {features}\n sortable\n filterable\n filterMode=\"menu\"\n showGlobalFilter\n selectionMode=\"none\"\n rowHeight={30}\n containerHeight=\"100%\"\n onApiReady={(next) => installEnterprise(next)}\n />\n </div>\n {/key}\n {:else if mode === 'free'}\n {#key freeRows}\n <div class=\"gridpane\">\n <SvGrid\n responsive={true}\n columnResize\n fitColumns\n data={freeRows}\n columns={freeColumns}\n features={freeFeatures}\n sortable\n filterable\n filterMode=\"menu\"\n showGlobalFilter\n showGroupingControls={false}\n selectionMode=\"none\"\n rowHeight={30}\n containerHeight=\"100%\"\n onApiReady={(next) => {\n freeApi = next\n next.setGroupBy(groupBy)\n }}\n />\n </div>\n {/key}\n {/if}\n <footer class=\"foot\">\n <span class=\"stat\"><span class=\"stat-label\">Running on</span><strong>{mode === 'worker' ? 'Web Worker' : 'The page'}</strong></span>\n <span class=\"stat\"><span class=\"stat-label\">Rows</span><strong>{size.toLocaleString()}</strong></span>\n <span class=\"stat\"><span class=\"stat-label\">Data ready</span><strong>{loadMs == null ? 'building...' : fmtMs(loadMs)}</strong></span>\n <span class=\"stat meter\" data-level={mode === 'worker' ? level(slowestRequest) : undefined} data-stat=\"request\">\n <span class=\"stat-label\">Slowest request (4 s)</span><strong>{mode === 'worker' ? fmtMs(slowestRequest) : 'none, all on the page'}</strong>\n </span>\n <span class=\"stat meter\" data-level={level(longestFrame)} data-stat=\"frame\"><span class=\"stat-label\">Longest frame (4 s)</span><strong>{fmtMs(longestFrame)}</strong></span>\n {#if view?.error}<span class=\"stat err\">{String((view.error as Error).message ?? view.error)}</span>{/if}\n </footer>\n</section>\n\n<style>\n /* Only what is particular to this demo; the chrome is the shared demo-kit. */\n .sel { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--sg-muted, #64748b); }\n .sel select {\n font: inherit; color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff);\n border: 1px solid var(--sg-border, #e2e8f0); border-radius: 6px; padding: 3px 6px;\n }\n .ticker {\n position: relative; display: inline-block; width: 72px; height: 8px; flex: none;\n border-radius: 999px; background: color-mix(in oklab, var(--sg-border, #e2e8f0) 70%, transparent);\n }\n .ticker .dot {\n position: absolute; inset: 0 auto 0 0; width: calc(100% - 8px);\n }\n .ticker .dot::after {\n content: ''; position: absolute; left: 0; top: 0; width: 8px; height: 8px; border-radius: 50%;\n background: var(--sg-fg, #0f172a);\n }\n /* Semantic colours: fine under a frame, a visible hitch, a frozen page. */\n .meter[data-level='ok'] strong { color: var(--sg-success, #16a34a); }\n .meter[data-level='warn'] strong { color: var(--sg-warning, #f59e0b); }\n .meter[data-level='bad'] strong { color: var(--sg-danger, #dc2626); }\n</style>\n"
|
|
3041
|
+
},
|
|
3042
|
+
{
|
|
3043
|
+
"id": "501-mail-app",
|
|
3044
|
+
"path": "examples/src/demos/501-mail-app.svelte",
|
|
3045
|
+
"title": "Mail App",
|
|
3046
|
+
"category": "App Templates",
|
|
3047
|
+
"blurb": "501. Mail client (app template) An Outlook-shaped mail client in one file. - SvNavPane: favourites, folders and labels over Mail / Calendar modules.",
|
|
3048
|
+
"source": "<script lang=\"ts\">\n /**\n * 501. Mail client (app template)\n * -------------------------------\n * An Outlook-shaped mail client in one file.\n *\n * - SvNavPane: favourites, folders and labels over Mail / Calendar modules.\n * - The message list is a SvGrid: sender avatars, conversation counts, sort\n * by any header, quick filters, tick rows for bulk actions, hover actions\n * per row, arrow keys carry the reading pane.\n * - The reading pane shows the whole conversation, older messages folded,\n * quoted text hidden until asked for, sanitized HTML bodies.\n * - The composer (SvRichText) has recipient chips with contact suggestions,\n * Cc, attachments, a signature and a five second Undo send.\n * - Mail keeps arriving while the page is open.\n * - Gmail-style shortcuts: c r a f e # u s j k / and ? for the list.\n *\n * Meeting invites carry Accept / Tentative / Decline; an accepted invite lands\n * on the Calendar module, which is the same SvGrid in scheduler view. Export\n * writes the open folder or the ticked rows to Excel, CSV or PDF.\n *\n * Nav pane, grid, rich text, modal: @svgrid/grid.\n * Scheduler renderer and export: @svgrid/enterprise.\n */\n import { tick, untrack } from 'svelte'\n import {\n SvGrid,\n SvNavPane,\n SvRichText,\n SvModal,\n SvTextInput,\n SvButton,\n SvMenu,\n SvAvatar,\n SvKbd,\n SvToaster,\n toast,\n renderSnippet,\n sanitizeHtml,\n htmlToText,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n type ColumnDef,\n type NavSection,\n type NavModule,\n type MenuItem,\n type SvGridApi,\n type RichTextTool,\n type SchedulerEventMoveEvent,\n type SchedulerEventResizeEvent,\n type SchedulerEventCommitEvent,\n } from '@svgrid/grid'\n import {\n enableSchedulerView,\n installEnterprise,\n setLicenseKey,\n type EnterpriseGridApi,\n type ExportColumn,\n type ExportFormat,\n } from '@svgrid/enterprise'\n\n setLicenseKey('SVENTERPRISE-DEV-LOCAL')\n enableSchedulerView()\n\n // ---- Domain ---------------------------------------------------------------\n type FolderId = 'inbox' | 'drafts' | 'sent' | 'archive' | 'junk' | 'trash'\n type LabelId = 'clients' | 'billing' | 'team' | 'travel'\n type Invite = { title: string; start: string; end: string; where: string; response?: 'accepted' | 'tentative' | 'declined' }\n type Attachment = { name: string; size: string }\n type Msg = {\n id: number\n folder: FolderId\n from: string\n email: string\n to: string\n cc?: string\n subject: string\n body: string\n received: string\n unread: boolean\n flagged: boolean\n attachments: Attachment[]\n labels: LabelId[]\n invite?: Invite\n /** Messages that share a thread show as one conversation in the reader. */\n thread?: string\n }\n type Ev = { id: number; title: string; start: string; end: string; allDay?: boolean; cal: 'work' | 'personal'; color: string; fromMsg?: number }\n\n const ME = { name: 'Robin Hale', email: 'robin@northwind.io', title: 'Head of Customer Engineering, Northwind' }\n\n const FOLDERS: Record<FolderId, string> = {\n inbox: 'Inbox', drafts: 'Drafts', sent: 'Sent', archive: 'Archive', junk: 'Junk', trash: 'Trash',\n }\n // Category colours carry meaning (which label), so they stay literal.\n const LABELS: Record<LabelId, { name: string; color: string }> = {\n clients: { name: 'Clients', color: '#2563eb' },\n billing: { name: 'Billing', color: '#d97706' },\n team: { name: 'Team', color: '#16a34a' },\n travel: { name: 'Travel', color: '#9333ea' },\n }\n const CAL_COLOR = { work: '#2563eb', personal: '#16a34a', tentative: '#94a3b8' }\n\n // ---- Dates relative to now, so the seed always reads as \"this week\" -------\n const pad = (n: number) => String(n).padStart(2, '0')\n const iso = (d: Date) =>\n `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`\n const ago = (minutes: number) => iso(new Date(Date.now() - minutes * 60_000))\n function day(offset: number, h: number, m = 0): string {\n const d = new Date()\n d.setDate(d.getDate() + offset)\n d.setHours(h, m, 0, 0)\n return iso(d)\n }\n function shortTime(s: string): string {\n const d = new Date(s)\n const now = new Date()\n if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n if ((now.getTime() - d.getTime()) / 86_400_000 < 6) return d.toLocaleDateString([], { weekday: 'short' })\n return d.toLocaleDateString([], { month: 'short', day: 'numeric' })\n }\n const longTime = (s: string) =>\n new Date(s).toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })\n const timeRange = (a: string, b: string) =>\n `${longTime(a)} - ${new Date(b).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`\n const fileSize = (bytes: number) =>\n bytes < 1024 ? `${bytes} B` : bytes < 1024 ** 2 ? `${Math.round(bytes / 1024)} KB` : `${(bytes / 1024 ** 2).toFixed(1)} MB`\n\n // ---- Seed bodies ----------------------------------------------------------\n // Bodies are ordinary HTML that goes through sanitizeHtml before it renders.\n // The sanitizer keeps class attributes, so the invoice and the build report\n // are styled divs rather than tables (tables are not on its list).\n const p = (...lines: string[]) => lines.map((l) => `<p>${l}</p>`).join('')\n const sig = (name: string, title: string) => `<div class=\"sig\"><p>${name}<br><span>${title}</span></p></div>`\n const quote = (who: string, when: string, html: string) => `<blockquote><p>On ${when}, ${who} wrote:</p>${html}</blockquote>`\n\n const RENEWAL_1 = p('Hi Robin,', 'Our renewal is up at the end of the quarter. Before procurement starts, could you send a quote for 48 analytics seats and the onboarding package?') + sig('Maya Chen', 'Procurement Lead, Contoso')\n const RENEWAL_2 = p('Hi Maya,', 'Quote coming this week. One change worth knowing about: your team ran onboarding in August, so I will drop that line rather than bill it twice.') + sig(ME.name, ME.title) + quote('Maya Chen', 'Mon', RENEWAL_1)\n const RENEWAL_3 = p('Hi Robin,', 'Attached is the revised quote. We moved the analytics seats to annual billing and dropped the onboarding line, as you suggested.', 'Procurement wants a signed copy by <strong>Friday the 17th</strong> to keep the current rate. Can you confirm the seat count (48) before then?') + sig('Maya Chen', 'Procurement Lead, Contoso') + quote(ME.name, 'Tue', RENEWAL_2)\n\n const INVOICE = p('Hello,', 'Your invoice is ready. The PDF is attached; a summary follows.') +\n `<div class=\"receipt\"><div class=\"receipt-head\"><strong>Fabrikam, Inc.</strong><span>Invoice INV-20931</span></div>` +\n `<div class=\"receipt-row\"><span>Data platform, 12 months</span><span>USD 9,600.00</span></div>` +\n `<div class=\"receipt-row\"><span>Premium support</span><span>USD 2,400.00</span></div>` +\n `<div class=\"receipt-row\"><span>Usage overage, September</span><span>USD 480.00</span></div>` +\n `<div class=\"receipt-row receipt-total\"><span>Total due on the 15th</span><span>USD 12,480.00</span></div></div>` +\n p('Questions about this invoice go to <a href=\"mailto:billing@fabrikam.com\">billing@fabrikam.com</a>.') + sig('Fabrikam Accounts Receivable', 'Fabrikam, Inc.')\n\n const BUILD = `<div class=\"build\"><div class=\"build-head\"><span class=\"build-ok\">Passed</span><strong>main</strong><code>a41c9e2</code></div>` +\n `<div class=\"build-row\"><span>Unit tests</span><span>1,912 passed, 3 skipped</span></div>` +\n `<div class=\"build-row\"><span>End-to-end</span><span>214 passed</span></div>` +\n `<div class=\"build-row\"><span>Duration</span><span>6m 41s</span></div></div>` +\n p('The flaky date-range spec that failed #4809 and #4810 passed on retry 1 of 2.', '<a href=\"https://example.com/builds/4812\">View build #4812</a>')\n\n const NEWSLETTER = `<h2>October meetup: three talks</h2>` +\n p('Runes in a 400-component codebase, testing snippets without a browser, and a live build of an offline-first app.', '<strong>Thursday, doors at 18:30.</strong> Pizza from 19:00, talks at 19:30.', '<a href=\"https://example.com/rsvp\">RSVP</a> - 40 seats left.') +\n `<div class=\"sig\"><p><span>You get this because you joined the Svelte Society list. Unsubscribe any time.</span></p></div>`\n\n // ---- Seed messages ----------------------------------------------------------\n let seq = 100\n const mk = (m: Omit<Msg, 'id' | 'unread' | 'flagged' | 'attachments' | 'labels' | 'to'> & Partial<Msg>): Msg => ({\n id: ++seq, unread: false, flagged: false, attachments: [], labels: [], to: ME.email, ...m,\n })\n\n let msgs = $state<Msg[]>([\n mk({ folder: 'inbox', thread: 'renewal', from: 'Maya Chen', email: 'maya.chen@contoso.com', subject: 'Re: Q4 renewal - revised quote', received: ago(14), unread: true, flagged: true, labels: ['clients'],\n attachments: [{ name: 'Contoso-Q4-quote.pdf', size: '182 KB' }], body: RENEWAL_3 }),\n mk({ folder: 'inbox', thread: 'design-review', from: 'Daniel Okoro', email: 'daniel@northwind.io', subject: 'Design review: billing settings', received: ago(41), unread: true, labels: ['team'],\n invite: { title: 'Design review: billing settings', start: day(1, 14), end: day(1, 15), where: 'Room 4B, or the video link in the invite' },\n body: p('Robin,', 'Walking through the new billing settings flow before it goes to the Contoso pilot. Mocks are in the team folder, page 3 onward.', 'Bring opinions on the proration copy - it is the part I am least sure about.') + sig('Daniel Okoro', 'Product Designer, Northwind') }),\n mk({ folder: 'inbox', from: 'Build bot', email: 'builds@northwind.io', subject: 'main is green again (build #4812)', received: ago(72), body: BUILD }),\n mk({ folder: 'inbox', thread: 'onboarding', from: 'Priya Nair', email: 'priya@northwind.io', subject: 'Re: Onboarding checklist for Leo', received: ago(128), unread: true, labels: ['team'],\n body: p('Done with accounts and hardware. Two things left on your side:', '<ul><li>Add Leo to the on-call rotation from the 28th</li><li>Pair him with Owen for the billing service walkthrough</li></ul>') + sig('Priya Nair', 'Engineering Manager, Northwind') }),\n mk({ folder: 'inbox', from: 'Fabrikam Billing', email: 'billing@fabrikam.com', subject: 'Invoice INV-20931 is due on the 15th', received: day(-1, 16, 20), labels: ['billing'],\n attachments: [{ name: 'INV-20931.pdf', size: '64 KB' }], body: INVOICE }),\n mk({ folder: 'inbox', from: 'Sam Ortiz', email: 'sam@northwind.io', subject: 'Offsite: flights booked', received: day(-1, 11, 5), labels: ['travel'],\n attachments: [{ name: 'itinerary-lisbon.pdf', size: '220 KB' }],\n body: p('Flights are booked for everyone. Itinerary attached.', 'Out Tuesday 07:40, back Friday 18:15. If anyone needs a different return, tell me by Wednesday - after that the fare class changes.') + sig('Sam Ortiz', 'Operations, Northwind') }),\n mk({ folder: 'inbox', thread: 'qbr', from: 'Elena Rossi', email: 'elena.rossi@fabrikam.com', subject: 'Fabrikam quarterly business review', received: day(-1, 9, 30), unread: true, labels: ['clients'],\n invite: { title: 'Fabrikam QBR', start: day(3, 10), end: day(3, 11, 30), where: 'Fabrikam HQ, 3rd floor' },\n body: p('Hi Robin,', 'Sending the invite for our quarterly review. On our side: usage trends, the two open support escalations, and the 2027 roadmap preview you promised.') + sig('Elena Rossi', 'VP Operations, Fabrikam') }),\n mk({ folder: 'inbox', from: 'Owen Bradley', email: 'owen@northwind.io', subject: 'Incident review notes - API latency', received: day(-2, 9, 40), flagged: true, labels: ['team'],\n body: p('Notes from Tuesday\\'s review:', '<ol><li>p95 latency hit 1.8 s for 22 minutes after the cache node restart</li><li>The alert fired 9 minutes late because the rule used a 15-minute window</li><li>Action: drop the window to 5 minutes and page on two consecutive breaches</li></ol>') + sig('Owen Bradley', 'Site Reliability, Northwind') }),\n mk({ folder: 'inbox', from: 'Northwind Security', email: 'security@northwind.io', subject: 'New sign-in from Lisbon, Portugal', received: day(-2, 22, 13),\n body: p('Your account signed in from a new location: <strong>Lisbon, Portugal</strong> (Chrome on macOS).', 'If this was you, there is nothing to do. If not, reset your password and revoke other sessions from Settings.') }),\n mk({ folder: 'inbox', from: 'Grace Kim', email: 'grace@northwind.io', subject: 'Can we move our 1:1?', received: day(-3, 15, 2), labels: ['team'],\n body: p('Thursday is stacked for me. Friday 10:00 or Monday after standup?', 'G') }),\n mk({ folder: 'inbox', from: 'Maya Chen', email: 'maya.chen@contoso.com', subject: 'Contoso: data export question', received: day(-4, 13, 47), labels: ['clients'],\n body: p('Quick one: can the export keep our column order, or does it always follow the default view? Finance builds a pivot on top of it and the order matters to them.') + sig('Maya Chen', 'Procurement Lead, Contoso') }),\n mk({ folder: 'inbox', from: 'Payroll', email: 'payroll@northwind.io', subject: 'Your September payslip', received: day(-5, 8, 0),\n attachments: [{ name: 'payslip-2026-09.pdf', size: '41 KB' }], body: p('Your payslip for September is attached. The password is your employee number.') }),\n mk({ folder: 'inbox', from: 'Liam Walsh', email: 'liam@wingtiptoys.com', subject: 'Intro: Wingtip Toys x Northwind', received: day(-6, 10, 12), labels: ['clients'],\n body: p('Robin, Grace suggested I reach out. We run 40 stores and our inventory screens are a mess of spreadsheets.', 'Would 30 minutes next week work to see whether your grid fits?') + sig('Liam Walsh', 'Head of Ops, Wingtip Toys') }),\n mk({ folder: 'inbox', from: 'Svelte Society', email: 'hello@sveltesociety.dev', subject: 'October meetup: talks announced', received: day(-8, 18, 30), body: NEWSLETTER }),\n mk({ folder: 'inbox', from: 'Tomas Novak', email: 'tomas@fabrikam.com', subject: 'Contract redlines', received: day(-10, 12, 0), labels: ['clients'],\n attachments: [{ name: 'MSA-v3-redline.docx', size: '96 KB' }, { name: 'DPA-v2.pdf', size: '133 KB' }],\n body: p('Legal\\'s redlines are attached. The only open point is the liability cap in 9.2; everything else is wording.') + sig('Tomas Novak', 'Counsel, Fabrikam') }),\n mk({ folder: 'inbox', from: 'Hotel Baixa', email: 'reservations@hotelbaixa.pt', subject: 'Reservation confirmed - Lisbon, 3 nights', received: day(-12, 7, 15), labels: ['travel'],\n body: p('Your reservation is confirmed: 3 nights, superior double, breakfast included.', 'Check-in from 15:00. Confirmation number <strong>HB-77120</strong>.') }),\n\n mk({ folder: 'drafts', from: ME.name, email: ME.email, to: 'liam@wingtiptoys.com', subject: 'Re: Intro: Wingtip Toys x Northwind', received: ago(9),\n body: p('Hi Liam,', 'Thanks for reaching out. Tuesday or Wednesday afternoon both work; I can show you the inventory screens other retailers built.', '') }),\n\n mk({ folder: 'sent', thread: 'renewal', from: ME.name, email: ME.email, to: 'maya.chen@contoso.com', subject: 'Re: Q4 renewal', received: day(-1, 10, 4), body: RENEWAL_2 }),\n mk({ folder: 'archive', thread: 'renewal', from: 'Maya Chen', email: 'maya.chen@contoso.com', subject: 'Q4 renewal', received: day(-2, 16, 30), labels: ['clients'], body: RENEWAL_1 }),\n mk({ folder: 'sent', thread: 'onboarding', from: ME.name, email: ME.email, to: 'priya@northwind.io', subject: 'Onboarding checklist for Leo', received: ago(220),\n body: p('Priya, here is the checklist for Leo\\'s first week. Can you take accounts and hardware?') + sig(ME.name, ME.title) }),\n mk({ folder: 'sent', thread: 'qbr', from: ME.name, email: ME.email, to: 'elena.rossi@fabrikam.com', subject: 'Agenda for the QBR', received: day(-1, 8, 50),\n body: p('Elena, proposed agenda: usage, escalations, roadmap. 90 minutes should do it.') + sig(ME.name, ME.title) }),\n mk({ folder: 'sent', from: ME.name, email: ME.email, to: 'sam@northwind.io', subject: 'Offsite dates', received: day(-3, 17, 20),\n body: p('Tuesday to Friday works for everyone on my side.') + sig(ME.name, ME.title) }),\n\n mk({ folder: 'archive', from: 'Owen Bradley', email: 'owen@northwind.io', subject: 'Postmortem template', received: day(-20, 11, 0), labels: ['team'],\n body: p('Template for postmortems going forward: timeline, impact, detection, response, follow-ups. Keep it blameless.') }),\n mk({ folder: 'archive', from: 'Northwind IT', email: 'it@northwind.io', subject: 'Welcome to Northwind', received: day(-40, 9, 0), body: p('Welcome aboard. Your laptop ships Monday.') }),\n\n mk({ folder: 'junk', from: 'Prize Desk', email: 'winner@prize-desk.biz', subject: 'You have been selected for a free cruise', received: day(-2, 3, 12),\n body: p('Click to claim your prize within 24 hours!') }),\n\n mk({ folder: 'trash', from: 'Grace Kim', email: 'grace@northwind.io', subject: 'Lunch order', received: day(-6, 11, 30), body: p('Thai or the salad place?') }),\n ])\n\n // Mail that arrives while the page is open: after 25 s and after 80 s.\n const INCOMING: Array<{ after: number; make: () => Msg }> = [\n { after: 25_000, make: () => mk({ folder: 'inbox', thread: 'design-review', from: 'Daniel Okoro', email: 'daniel@northwind.io', subject: 'Re: Design review: billing settings', received: iso(new Date()), unread: true, labels: ['team'],\n body: p('Adding Grace to tomorrow - she owns the proration copy now, so she should hear the feedback first hand.', 'Nothing else changes.') + sig('Daniel Okoro', 'Product Designer, Northwind') }) },\n { after: 80_000, make: () => mk({ folder: 'inbox', thread: 'renewal', from: 'Maya Chen', email: 'maya.chen@contoso.com', subject: 'Re: Q4 renewal - revised quote', received: iso(new Date()), unread: true, labels: ['clients'],\n body: p('One more thing: legal asked whether the DPA from last year still applies, or if we sign the new version with this renewal.') + sig('Maya Chen', 'Procurement Lead, Contoso') }) },\n ]\n\n // Monday of this week, for the calendar seed.\n const monday = (() => {\n const d = new Date()\n d.setDate(d.getDate() - ((d.getDay() + 6) % 7))\n return d\n })()\n function wk(dayIdx: number, h: number, m = 0): string {\n const d = new Date(monday)\n d.setDate(monday.getDate() + dayIdx)\n d.setHours(h, m, 0, 0)\n return iso(d)\n }\n let evSeq = 500\n let events = $state<Ev[]>([\n { id: ++evSeq, title: 'Standup', start: wk(0, 9, 30), end: wk(0, 9, 45), cal: 'work', color: CAL_COLOR.work },\n { id: ++evSeq, title: 'Standup', start: wk(2, 9, 30), end: wk(2, 9, 45), cal: 'work', color: CAL_COLOR.work },\n { id: ++evSeq, title: 'Standup', start: wk(4, 9, 30), end: wk(4, 9, 45), cal: 'work', color: CAL_COLOR.work },\n { id: ++evSeq, title: 'Contoso renewal call', start: wk(1, 11), end: wk(1, 11, 45), cal: 'work', color: CAL_COLOR.work },\n { id: ++evSeq, title: 'Roadmap writing (focus)', start: wk(2, 13), end: wk(2, 15, 30), cal: 'work', color: CAL_COLOR.work },\n { id: ++evSeq, title: '1:1 Grace', start: wk(3, 16), end: wk(3, 16, 30), cal: 'work', color: CAL_COLOR.work },\n { id: ++evSeq, title: 'Gym', start: wk(1, 18), end: wk(1, 19), cal: 'personal', color: CAL_COLOR.personal },\n { id: ++evSeq, title: 'Dentist', start: wk(4, 8), end: wk(4, 8, 45), cal: 'personal', color: CAL_COLOR.personal },\n ])\n\n // ---- Views ----------------------------------------------------------------\n type Quick = 'all' | 'unread' | 'flagged' | 'files'\n let area = $state<'mail' | 'calendar'>('mail')\n let view = $state<string>('inbox')\n let quick = $state<Quick>('all')\n let query = $state('')\n let openId = $state<number | null>(null)\n let selected = $state<number[]>([])\n let compact = $state(false)\n let api = $state<EnterpriseGridApi<typeof features, Msg> | null>(null)\n\n function inView(m: Msg, v: string): boolean {\n if (v === 'flagged') return m.flagged && m.folder !== 'trash' && m.folder !== 'junk'\n if (v === 'unread') return m.unread && m.folder !== 'trash' && m.folder !== 'junk'\n if (v.startsWith('label:')) return m.labels.includes(v.slice(6) as LabelId) && m.folder !== 'trash'\n return m.folder === v\n }\n function viewName(v: string): string {\n if (v === 'flagged') return 'Flagged'\n if (v === 'unread') return 'Unread'\n if (v.startsWith('label:')) return LABELS[v.slice(6) as LabelId].name\n return FOLDERS[v as FolderId]\n }\n const passesQuick = (m: Msg) =>\n quick === 'all' || (quick === 'unread' ? m.unread : quick === 'flagged' ? m.flagged : m.attachments.length > 0)\n\n const q = $derived(query.trim().toLowerCase())\n // The open message stays in the list even after it stops matching (opening a\n // mail in Unread marks it read; it should not vanish from under the cursor).\n const matchesQuery = (m: Msg) =>\n q === '' || m.subject.toLowerCase().includes(q) || m.from.toLowerCase().includes(q) || m.to.toLowerCase().includes(q) || preview(m).toLowerCase().includes(q)\n // A folder view drops the open message once it moves out of the folder; a\n // filtered view (Unread, Flagged, a label, a quick filter, a search) keeps it.\n const viewRows = $derived(\n msgs.filter((m) =>\n m.id === openId\n ? (view in FOLDERS ? m.folder === view : m.folder !== 'trash')\n : inView(m, view) && passesQuick(m) && matchesQuery(m),\n ),\n )\n const QUICK_OPTIONS: Array<[Quick, string]> = [['all', 'All'], ['unread', 'Unread'], ['flagged', 'Flagged'], ['files', 'Files']]\n const listSummary = $derived.by(() => {\n const unread = viewRows.filter((m) => m.unread).length\n const parts = [viewRows.length === 1 ? '1 message' : `${viewRows.length} messages`]\n if (unread) parts.push(`${unread} unread`)\n if (selected.length) parts.push(`${selected.length} ticked`)\n return parts.join(', ')\n })\n const openMsg = $derived(openId == null ? null : (msgs.find((m) => m.id === openId) ?? null))\n const isOutbound = (f: FolderId) => f === 'sent' || f === 'drafts'\n\n // Conversations: every message of a thread outside Trash and Drafts, oldest first.\n const threadSizes = $derived.by(() => {\n const n = new Map<string, number>()\n for (const m of msgs) if (m.thread && m.folder !== 'trash' && m.folder !== 'drafts') n.set(m.thread, (n.get(m.thread) ?? 0) + 1)\n return n\n })\n const conversation = $derived.by(() => {\n const m = openMsg\n if (!m) return []\n if (!m.thread) return [m]\n return msgs.filter((x) => x.thread === m.thread && x.folder !== 'trash' && x.folder !== 'drafts').sort((a, b) => a.received.localeCompare(b.received))\n })\n let expanded = $state<number[]>([])\n let quotedOpen = $state<number[]>([])\n const toggleIn = (list: number[], id: number) => (list.includes(id) ? list.filter((x) => x !== id) : [...list, id])\n\n const unreadIn = (v: string) => msgs.filter((m) => inView(m, v) && m.unread).length || undefined\n const sections = $derived<NavSection[]>([\n { id: 'fav', label: 'Favorites', items: [\n { id: 'unread', label: 'Unread', icon: iUnread, badge: unreadIn('unread') },\n { id: 'flagged', label: 'Flagged', icon: iFlag, badge: msgs.filter((m) => inView(m, 'flagged')).length || undefined },\n ] },\n { id: 'folders', label: 'Folders', items: [\n { id: 'inbox', label: 'Inbox', icon: iInbox, badge: unreadIn('inbox') },\n { id: 'drafts', label: 'Drafts', icon: iDrafts, badge: msgs.filter((m) => m.folder === 'drafts').length || undefined },\n { id: 'sent', label: 'Sent', icon: iSent },\n { id: 'archive', label: 'Archive', icon: iArchive },\n { id: 'junk', label: 'Junk', icon: iJunk, badge: unreadIn('junk') },\n { id: 'trash', label: 'Trash', icon: iTrash },\n ] },\n { id: 'labels', label: 'Labels', items: [\n { id: 'label:clients', label: 'Clients', icon: lClients },\n { id: 'label:billing', label: 'Billing', icon: lBilling },\n { id: 'label:team', label: 'Team', icon: lTeam },\n { id: 'label:travel', label: 'Travel', icon: lTravel },\n ] },\n ])\n const calSections: NavSection[] = [\n { id: 'cals', label: 'Calendars', items: [\n { id: 'cal:all', label: 'All calendars', icon: iCal },\n { id: 'cal:work', label: 'Work', icon: cWork },\n { id: 'cal:personal', label: 'Personal', icon: cPersonal },\n ] },\n ]\n const modules = $derived<NavModule[]>([\n { id: 'mail', label: 'Mail', icon: iUnread, badge: unreadIn('inbox') },\n { id: 'calendar', label: 'Calendar', icon: iCal },\n ])\n let calView = $state('cal:all')\n let calDate = $state(new Date())\n const calRows = $derived(calView === 'cal:all' ? events : events.filter((e) => `cal:${e.cal}` === calView))\n\n function go(v: string) {\n view = v\n openId = null\n clearSelection()\n }\n function clearSelection() {\n api?.clearRowSelection()\n selected = []\n }\n\n // ---- Narrow screens: nav becomes a rail, the reader replaces the list ------\n let narrow = $state(typeof window !== 'undefined' && window.matchMedia('(max-width: 860px)').matches)\n $effect(() => {\n const mq = window.matchMedia('(max-width: 860px)')\n const on = () => (narrow = mq.matches)\n mq.addEventListener('change', on)\n return () => mq.removeEventListener('change', on)\n })\n\n // ---- Message actions ------------------------------------------------------\n const previews = new Map<string, string>()\n function preview(m: Msg): string {\n let t = previews.get(m.body)\n if (t == null) {\n // The preview is the new text only: quoted history and signatures are noise.\n // A space between adjacent tags keeps \"Passed\" and \"main\" apart when the\n // markup is flattened.\n const fresh = m.body.replace(/<blockquote[\\s\\S]*?<\\/blockquote>/g, '').replace(/<div class=\"sig\">[\\s\\S]*?<\\/div>/g, '').replace(/></g, '> <')\n t = htmlToText(fresh).replace(/\\s+/g, ' ').trim().slice(0, 140)\n previews.set(m.body, t)\n }\n return t\n }\n const who = (m: Msg) => (isOutbound(m.folder) ? `To: ${nameOf(m.to.split(',')[0]!.trim())}` : m.from)\n\n function patch(ids: readonly number[], change: Partial<Msg> | ((m: Msg) => Partial<Msg>)) {\n const set = new Set(ids)\n msgs = msgs.map((m) => (set.has(m.id) ? { ...m, ...(typeof change === 'function' ? change(m) : change) } : m))\n }\n\n function open(m: Msg) {\n if (m.folder === 'drafts') {\n compose({ id: m.id, to: splitAddresses(m.to), cc: splitAddresses(m.cc ?? ''), subject: m.subject, html: m.body, attachments: m.attachments, thread: m.thread })\n return\n }\n openId = m.id\n expanded = [m.id]\n quotedOpen = []\n readerEl?.scrollTo({ top: 0 })\n const unreadInThread = m.thread ? msgs.filter((x) => x.thread === m.thread && x.unread).map((x) => x.id) : m.unread ? [m.id] : []\n if (unreadInThread.length) patch(unreadInThread, { unread: false })\n }\n\n // Arrow keys carry the reading pane along with the grid's active row, the\n // way a desktop mail client does. Only while the list holds focus: the grid\n // seeds an active cell at mount, and that must not open (and mark read) a mail.\n let listEl = $state<HTMLElement | null>(null)\n let readerEl = $state<HTMLElement | null>(null)\n function onActiveCellChange(cell: { rowIndex: number }) {\n if (!listEl?.contains(document.activeElement)) return\n const m = api?.getDisplayedRows()[cell.rowIndex]\n if (m && m.folder !== 'drafts' && m.id !== openId) open(m)\n }\n /** j / k: the next or previous row in the order the grid shows them. */\n function step(by: 1 | -1) {\n const rows = api?.getDisplayedRows() ?? viewRows\n const i = rows.findIndex((m) => m.id === openId)\n const next = rows[Math.min(rows.length - 1, Math.max(0, i < 0 ? 0 : i + by))]\n if (next && next.id !== openId) {\n open(next)\n const at = rows.indexOf(next)\n api?.scrollToRow?.(at)\n }\n }\n\n const EMPTY: Record<string, string> = {\n inbox: 'Inbox zero. Nothing left to read.',\n unread: 'All caught up.',\n flagged: 'No flagged mail. Flag a message to keep it here.',\n drafts: 'No drafts.',\n sent: 'Nothing sent yet.',\n archive: 'Nothing archived.',\n junk: 'No junk. Mail you move to Junk lands here.',\n trash: 'Trash is empty.',\n }\n const QUICK_EMPTY: Record<Quick, string> = { all: '', unread: 'No unread mail here.', flagged: 'Nothing flagged here.', files: 'No mail with attachments here.' }\n const emptyMessage = $derived(\n q ? `No mail in ${viewName(view)} matches \"${query.trim()}\".`\n : quick !== 'all' ? QUICK_EMPTY[quick]\n : (EMPTY[view] ?? `No mail labelled ${viewName(view)}.`),\n )\n\n /** What a toolbar action applies to: the ticked rows, else the open message. */\n const targets = $derived(selected.length ? selected : openId != null ? [openId] : [])\n const targetMsgs = $derived(msgs.filter((m) => targets.includes(m.id)))\n const plural = (n: number) => (n === 1 ? '1 message' : `${n} messages`)\n\n function moveTo(folder: FolderId, ids: readonly number[] = [...targets]) {\n if (!ids.length) return\n const was = new Map(msgs.filter((m) => ids.includes(m.id)).map((m) => [m.id, m.folder]))\n patch(ids, { folder })\n if (openId != null && ids.includes(openId)) openId = null\n clearSelection()\n toast(`${plural(ids.length)} moved to ${FOLDERS[folder]}`, {\n action: { label: 'Undo', onClick: () => patch(ids, (m) => ({ folder: was.get(m.id) ?? m.folder })) },\n })\n }\n function remove(ids: readonly number[] = [...targets]) {\n if (!ids.length) return\n const inTrash = msgs.filter((m) => ids.includes(m.id) && m.folder === 'trash').length === ids.length\n if (!inTrash) return moveTo('trash', ids)\n // Deleting from Trash is for good; the toast keeps the rows (and their\n // places in the list) so Undo can put them back.\n const before = msgs\n msgs = msgs.filter((m) => !ids.includes(m.id))\n if (openId != null && ids.includes(openId)) openId = null\n clearSelection()\n toast(`${plural(ids.length)} deleted for good`, {\n action: {\n label: 'Undo',\n onClick: () => {\n // Keep anything that arrived since, then the old list with current copies.\n const known = new Set(before.map((m) => m.id))\n msgs = [...msgs.filter((x) => !known.has(x.id)), ...before.map((m) => msgs.find((x) => x.id === m.id) ?? m)]\n },\n },\n })\n }\n function toggleRead(ids: readonly number[] = targets) {\n const anyUnread = msgs.some((m) => ids.includes(m.id) && m.unread)\n patch(ids, { unread: !anyUnread })\n }\n function toggleFlag(ids: readonly number[] = targets) {\n const anyUnflagged = msgs.some((m) => ids.includes(m.id) && !m.flagged)\n patch(ids, { flagged: anyUnflagged })\n }\n\n const moveItems = $derived<MenuItem[]>(\n (['inbox', 'archive', 'junk', 'trash'] as FolderId[])\n .filter((f) => f !== view)\n .map((f) => ({ label: FOLDERS[f], disabled: targets.length === 0, onSelect: () => moveTo(f) })),\n )\n\n // ---- Export (Enterprise) --------------------------------------------------\n const EXPORT_COLUMNS: ExportColumn<Msg>[] = [\n { field: 'from', header: 'From' },\n { field: 'email', header: 'Address' },\n { field: 'to', header: 'To' },\n { field: 'subject', header: 'Subject' },\n { field: 'received', header: 'Received', format: { type: 'datetime', pattern: 'short' } },\n ]\n async function exportMail(format: ExportFormat, rows: 'displayed' | 'selected') {\n if (!api) return\n const stamp = iso(new Date()).slice(0, 10)\n try {\n await api.exportData({ format, filename: `${viewName(view).toLowerCase()}-${stamp}`, columns: EXPORT_COLUMNS, rows })\n toast.success(`Exported ${rows === 'selected' ? plural(selected.length) : viewName(view)} to ${format.toUpperCase()}`)\n } catch (err) {\n toast.error(`Export failed: ${err instanceof Error ? err.message : String(err)}`)\n }\n }\n const exportItems = $derived<MenuItem[]>([\n { label: `${viewName(view)} to Excel`, onSelect: () => exportMail('xlsx', 'displayed') },\n { label: `${viewName(view)} to CSV`, onSelect: () => exportMail('csv', 'displayed') },\n { label: `${viewName(view)} to PDF`, onSelect: () => exportMail('pdf', 'displayed') },\n { separator: true },\n { label: selected.length ? `Ticked (${selected.length}) to Excel` : 'Ticked rows to Excel', disabled: selected.length === 0, onSelect: () => exportMail('xlsx', 'selected') },\n ])\n\n // ---- Keyboard shortcuts -----------------------------------------------------\n // They listen on the app root, not the window, so they never fight the page\n // around the app; the root takes focus when you click anywhere inside it.\n let rootEl = $state<HTMLElement | null>(null)\n let searchEl = $state<HTMLElement | null>(null)\n let helpOpen = $state(false)\n const SHORTCUTS: Array<[string, string]> = [\n ['c', 'New message'], ['r', 'Reply'], ['a', 'Reply all'], ['f', 'Forward'],\n ['e', 'Archive'], ['#', 'Delete'], ['u', 'Mark read or unread'], ['s', 'Flag'],\n ['j', 'Next message'], ['k', 'Previous message'], ['/', 'Search'], ['?', 'This list'],\n ]\n function onRootKey(e: KeyboardEvent) {\n const t = e.target as HTMLElement | null\n if (composeOpen || helpOpen || previewing || e.ctrlKey || e.metaKey || e.altKey) return\n if (t?.closest('input, textarea, select, [contenteditable=\"true\"]')) return\n if (area !== 'mail') return\n const m = openMsg\n const run: Record<string, () => void> = {\n c: () => compose(),\n r: () => m && reply(m),\n a: () => m && reply(m, true),\n f: () => m && forward(m),\n e: () => moveTo('archive'),\n '#': () => remove(),\n Delete: () => remove(),\n u: () => toggleRead(),\n s: () => toggleFlag(),\n j: () => step(1),\n k: () => step(-1),\n '/': () => searchEl?.querySelector('input')?.focus(),\n '?': () => (helpOpen = true),\n }\n const fn = run[e.key]\n if (fn) {\n e.preventDefault()\n fn()\n }\n }\n\n // ---- Contacts + recipient chips --------------------------------------------\n type Contact = { name: string; email: string }\n const contacts = $derived.by(() => {\n const seen = new Map<string, Contact>()\n for (const m of msgs) if (m.email !== ME.email && !seen.has(m.email)) seen.set(m.email, { name: m.from, email: m.email })\n return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name))\n })\n const nameOf = (email: string) => contacts.find((c) => c.email === email)?.name ?? email\n const isEmail = (s: string) => /^[^\\s@,]+@[^\\s@,]+\\.[^\\s@,]+$/.test(s)\n const splitAddresses = (s: string) => s.split(',').map((x) => x.trim()).filter(Boolean)\n\n type Field = 'to' | 'cc'\n let typing = $state<Record<Field, string>>({ to: '', cc: '' })\n let suggestFor = $state<Field | null>(null)\n let suggestAt = $state(0)\n const suggestions = $derived.by(() => {\n if (!suggestFor) return []\n const t = typing[suggestFor].trim().toLowerCase()\n if (!t) return []\n const taken = new Set([...draft.to, ...draft.cc])\n return contacts.filter((c) => !taken.has(c.email) && (c.name.toLowerCase().includes(t) || c.email.includes(t))).slice(0, 6)\n })\n function addRecipient(field: Field, email: string) {\n if (!email || draft[field].includes(email)) return\n draft[field] = [...draft[field], email]\n typing[field] = ''\n suggestAt = 0\n toError = ''\n }\n function commitTyped(field: Field) {\n const raw = typing[field].trim().replace(/[,;]$/, '')\n if (!raw) return\n const hit = suggestions[suggestAt]\n if (hit && suggestFor === field) addRecipient(field, hit.email)\n else if (isEmail(raw)) addRecipient(field, raw)\n }\n function onRecipientKey(field: Field, e: KeyboardEvent) {\n if (e.key === 'ArrowDown' && suggestions.length) { e.preventDefault(); suggestAt = (suggestAt + 1) % suggestions.length }\n else if (e.key === 'ArrowUp' && suggestions.length) { e.preventDefault(); suggestAt = (suggestAt - 1 + suggestions.length) % suggestions.length }\n else if ((e.key === 'Enter' || e.key === ',' || e.key === ';' || e.key === 'Tab') && typing[field].trim()) { e.preventDefault(); commitTyped(field) }\n else if (e.key === 'Backspace' && !typing[field] && draft[field].length) draft[field] = draft[field].slice(0, -1)\n else if (e.key === 'Escape' && suggestFor) { e.stopPropagation(); suggestFor = null }\n }\n\n // ---- Compose --------------------------------------------------------------\n type Draft = { id?: number; to: string[]; cc: string[]; subject: string; html: string; attachments: Attachment[]; thread?: string }\n const blank = (): Draft => ({ to: [], cc: [], subject: '', html: `<p><br></p><p><br></p>${sig(ME.name, ME.title)}`, attachments: [] })\n let composeOpen = $state(false)\n let showCc = $state(false)\n let draft = $state<Draft>(blank())\n let toError = $state('')\n let fileEl = $state<HTMLInputElement | null>(null)\n const TOOLS: RichTextTool[] = ['bold', 'italic', 'underline', 'strike', '|', 'ul', 'ol', 'quote', '|', 'link', 'clear', '|', 'undo', 'redo']\n\n function compose(d: Partial<Draft> = {}) {\n draft = { ...blank(), ...d }\n showCc = draft.cc.length > 0\n typing = { to: '', cc: '' }\n toError = ''\n composeOpen = true\n }\n const reSubject = (s: string) => (/^re:/i.test(s) ? s : `Re: ${s.replace(/^(re|fwd):\\s*/i, '')}`)\n function reply(m: Msg, all = false) {\n const from = isOutbound(m.folder) ? splitAddresses(m.to) : [m.email]\n const cc = all ? [...splitAddresses(m.to), ...splitAddresses(m.cc ?? '')].filter((a) => a !== ME.email && !from.includes(a)) : []\n compose({\n to: from, cc, subject: reSubject(m.subject), thread: m.thread ?? `t${m.id}`,\n html: `<p><br></p><p><br></p>${sig(ME.name, ME.title)}${quote(`${m.from} <${m.email}>`, longTime(m.received), m.body)}`,\n })\n if (!m.thread) patch([m.id], { thread: `t${m.id}` })\n }\n function forward(m: Msg) {\n compose({\n subject: /^fwd:/i.test(m.subject) ? m.subject : `Fwd: ${m.subject.replace(/^re:\\s*/i, '')}`,\n attachments: [...m.attachments],\n html: `<p><br></p><p><br></p>${sig(ME.name, ME.title)}<p>---------- Forwarded message ----------<br>From: ${m.from} <${m.email}><br>Date: ${longTime(m.received)}<br>Subject: ${m.subject}</p>${m.body}`,\n })\n }\n function onFiles(e: Event) {\n const input = e.currentTarget as HTMLInputElement\n const files = [...(input.files ?? [])]\n draft.attachments = [...draft.attachments, ...files.map((f) => ({ name: f.name, size: fileSize(f.size) }))]\n input.value = ''\n }\n\n // Undo send: the message waits five seconds in the toast before it goes.\n const pending = new Set<ReturnType<typeof setTimeout>>()\n $effect(() => () => { for (const t of pending) clearTimeout(t) })\n function send() {\n commitTyped('to')\n commitTyped('cc')\n if (!draft.to.length) {\n toError = 'Add at least one recipient'\n return\n }\n const d: Draft = $state.snapshot(draft)\n composeOpen = false\n if (d.id != null) msgs = msgs.filter((m) => m.id !== d.id) // a sent draft leaves Drafts\n const timer = setTimeout(() => {\n pending.delete(timer)\n const sent: Msg = {\n id: ++seq, folder: 'sent', from: ME.name, email: ME.email, to: d.to.join(', '), cc: d.cc.join(', ') || undefined,\n subject: d.subject.trim() || '(no subject)', body: d.html || '<p></p>', received: iso(new Date()),\n unread: false, flagged: false, attachments: d.attachments, labels: [], thread: d.thread,\n }\n msgs = [sent, ...msgs]\n toast.success(`Sent to ${d.to.map(nameOf).join(', ')}`)\n }, 5000)\n pending.add(timer)\n toast('Sending...', {\n duration: 5000,\n action: { label: 'Undo', onClick: () => { clearTimeout(timer); pending.delete(timer); compose({ ...d, id: undefined }) } },\n })\n }\n function onComposeKey(e: KeyboardEvent) {\n if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {\n e.preventDefault()\n send()\n }\n }\n function saveDraft() {\n commitTyped('to')\n commitTyped('cc')\n const fields = {\n to: draft.to.join(', '), cc: draft.cc.join(', ') || undefined, subject: draft.subject.trim() || '(no subject)',\n body: draft.html, received: iso(new Date()), attachments: draft.attachments, thread: draft.thread,\n }\n if (draft.id != null && msgs.some((m) => m.id === draft.id)) patch([draft.id], fields)\n else msgs = [{ id: ++seq, folder: 'drafts', from: ME.name, email: ME.email, unread: false, flagged: false, labels: [], ...fields }, ...msgs]\n composeOpen = false\n toast('Draft saved')\n }\n\n // ---- Incoming mail ------------------------------------------------------------\n $effect(() => {\n const timers = INCOMING.map(({ after, make }) => setTimeout(() => untrack(() => arrive(make())), after))\n return () => timers.forEach(clearTimeout)\n })\n function arrive(m: Msg) {\n msgs = [m, ...msgs]\n toast(m.subject, {\n title: `New mail from ${m.from}`,\n duration: 7000,\n action: { label: 'Open', onClick: () => { area = 'mail'; if (view !== 'inbox') go('inbox'); open(m) } },\n })\n }\n\n // ---- Attachment preview --------------------------------------------------------\n let previewing = $state<{ att: Attachment; msg: Msg } | null>(null)\n const extOf = (name: string) => name.split('.').pop()?.toLowerCase() ?? ''\n\n // Links inside a mail open in a new tab instead of replacing the app.\n function onBodyClick(e: MouseEvent) {\n const a = (e.target as HTMLElement | null)?.closest('a')\n const href = a?.getAttribute('href')\n if (!a || !href) return\n e.preventDefault()\n if (href.startsWith('mailto:')) compose({ to: [href.slice(7)] })\n else window.open(href, '_blank', 'noopener,noreferrer')\n }\n\n // ---- Invites -> Calendar --------------------------------------------------\n function respond(m: Msg, response: NonNullable<Invite['response']>) {\n const inv = m.invite\n if (!inv) return\n patch([m.id], { invite: { ...inv, response } })\n const rest = events.filter((e) => e.fromMsg !== m.id)\n if (response === 'declined') {\n events = rest\n toast(`Declined. ${m.from.split(' ')[0]} will see your answer.`)\n return\n }\n events = [...rest, { id: ++evSeq, title: inv.title, start: inv.start, end: inv.end, cal: 'work', color: response === 'tentative' ? CAL_COLOR.tentative : CAL_COLOR.work, fromMsg: m.id }]\n toast.success(response === 'accepted' ? `Added to your calendar: ${inv.title}` : `Marked tentative: ${inv.title}`)\n }\n function showInCalendar(inv: Invite) {\n calDate = new Date(inv.start)\n calView = 'cal:all'\n area = 'calendar'\n }\n\n function onEventMove(e: SchedulerEventMoveEvent<Ev>) { e.row.start = iso(e.start); e.row.end = iso(e.end) }\n function onEventResize(e: SchedulerEventResizeEvent<Ev>) { e.row.start = iso(e.start); e.row.end = iso(e.end) }\n function onEventCommit(e: SchedulerEventCommitEvent<Ev>) { Object.assign(e.row, e.values) }\n function onEventAdd(start: Date, end: Date) {\n const cal = calView === 'cal:personal' ? 'personal' : 'work'\n events = [...events, { id: ++evSeq, title: 'New event', start: iso(start), end: iso(end), cal, color: CAL_COLOR[cal] }]\n }\n function onEventDelete(row: Ev) { events = events.filter((e) => e.id !== row.id) }\n\n // ---- Account menu -------------------------------------------------------------\n const accountItems = $derived<MenuItem[]>([\n { label: `${ME.name} ${ME.email}`, disabled: true },\n { separator: true },\n { label: compact ? 'Comfortable list' : 'Compact list', onSelect: () => (compact = !compact) },\n { label: 'Keyboard shortcuts', shortcut: '?', onSelect: () => (helpOpen = true) },\n ])\n\n // ---- Grids ----------------------------------------------------------------\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature, rowSelectionFeature })\n // On a phone the Received column goes and the time moves into the message cell.\n const columns = $derived<ColumnDef<typeof features, Msg>[]>([\n { id: 'flagged', field: 'flagged', header: '', width: 40, cell: (ctx) => renderSnippet(FlagCell, { row: ctx.row.original }) },\n { id: 'from', field: 'from', header: 'From', width: narrow ? 170 : 260, cell: (ctx) => renderSnippet(MessageCell, { row: ctx.row.original }) },\n ...(narrow ? [] : [{ id: 'received', field: 'received', header: 'Received', width: 104, cell: (ctx) => renderSnippet(TimeCell, { row: ctx.row.original }) } satisfies ColumnDef<typeof features, Msg>]),\n ])\n const calColumns: ColumnDef<any, Ev>[] = [\n { field: 'title', header: 'Title' },\n { field: 'start', header: 'Start' },\n { field: 'end', header: 'End' },\n ]\n function onApiReady(a: SvGridApi<typeof features, Msg>) {\n api = installEnterprise(a)\n }\n // Focus the composer where the typing starts: the To field for new mail,\n // the body for a reply that already has its recipients.\n $effect(() => {\n if (!composeOpen) return\n const toFirst = untrack(() => draft.to.length === 0)\n void tick().then(() => {\n const el = toFirst\n ? document.querySelector<HTMLElement>('.mx-form .mx-rcpt input')\n : document.querySelector<HTMLElement>('.mx-form [contenteditable=\"true\"]')\n el?.focus()\n })\n })\n</script>\n\n{#snippet ico(d: string, size = 16)}\n <svg viewBox=\"0 0 24 24\" width={size} height={size} fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path {d} /></svg>\n{/snippet}\n{#snippet iInbox()}{@render ico('M22 12h-6l-2 3h-4l-2-3H2 M5.5 5.1 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.5-6.9A2 2 0 0 0 16.8 4H7.2a2 2 0 0 0-1.7 1.1z')}{/snippet}\n{#snippet iDrafts()}{@render ico('M12 20h9 M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z')}{/snippet}\n{#snippet iSent()}{@render ico('M22 2 11 13 M22 2l-7 20-4-9-9-4z')}{/snippet}\n{#snippet iArchive()}{@render ico('M3 4h18v4H3z M5 8v12h14V8 M10 12h4')}{/snippet}\n{#snippet iJunk()}{@render ico('M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z M4.9 4.9l14.2 14.2')}{/snippet}\n{#snippet iTrash()}{@render ico('M3 6h18 M8 6V4h8v2 M19 6l-1 14H6L5 6')}{/snippet}\n{#snippet iFlag()}{@render ico('M4 22V4 M4 4h12l-2 4 2 4H4')}{/snippet}\n{#snippet iUnread()}{@render ico('M4 6h16v12H4z M4 7l8 6 8-6')}{/snippet}\n{#snippet iCal()}{@render ico('M7 2v3 M17 2v3 M4 5h16v16H4z M4 10h16')}{/snippet}\n{#snippet dot(color: string)}<span class=\"mx-ldot\" style:background={color}></span>{/snippet}\n{#snippet lClients()}{@render dot(LABELS.clients.color)}{/snippet}\n{#snippet lBilling()}{@render dot(LABELS.billing.color)}{/snippet}\n{#snippet lTeam()}{@render dot(LABELS.team.color)}{/snippet}\n{#snippet lTravel()}{@render dot(LABELS.travel.color)}{/snippet}\n{#snippet cWork()}{@render dot(CAL_COLOR.work)}{/snippet}\n{#snippet cPersonal()}{@render dot(CAL_COLOR.personal)}{/snippet}\n\n{#snippet FlagCell(props: { row: Msg })}\n <button\n type=\"button\"\n class=\"mx-flag\"\n class:is-on={props.row.flagged}\n aria-label={props.row.flagged ? 'Remove flag' : 'Flag'}\n aria-pressed={props.row.flagged}\n onclick={(e) => { e.stopPropagation(); toggleFlag([props.row.id]) }}\n >{@render ico('M4 22V4 M4 4h12l-2 4 2 4H4', 14)}</button>\n{/snippet}\n{#snippet MessageCell(props: { row: Msg })}\n {@const m = props.row}\n {@const count = m.thread ? (threadSizes.get(m.thread) ?? 1) : 1}\n <div class=\"mx-mc\" class:is-unread={m.unread} class:is-compact={compact}>\n {#if !compact && !narrow}<span class=\"mx-mc-av\"><SvAvatar name={isOutbound(m.folder) ? nameOf(m.to.split(',')[0]!.trim()) : m.from} size={30} /></span>{/if}\n <div class=\"mx-mc-txt\">\n <div class=\"mx-mc-l1\">\n <span class=\"mx-mc-from\">{who(m)}</span>\n {#if count > 1}<span class=\"mx-mc-count\" title=\"{count} messages in this conversation\">{count}</span>{/if}\n {#if m.attachments.length}<span class=\"mx-mc-clip\" title=\"{m.attachments.length} attachment(s)\">{@render ico('M21 11l-8.5 8.5a5 5 0 0 1-7-7L14 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L15 7', 13)}</span>{/if}\n {#if narrow}<span class=\"mx-time mx-mc-when\" class:is-unread={m.unread}>{shortTime(m.received)}</span>{/if}\n </div>\n <div class=\"mx-mc-sub\">{#if m.invite}<span class=\"mx-mc-tag\">Invite</span>{/if}{m.subject}</div>\n {#if !compact}<div class=\"mx-mc-prev\">{#each m.labels as l (l)}{@render dot(LABELS[l].color)}{/each}<span>{preview(m)}</span></div>{/if}\n </div>\n {#if !narrow && m.folder !== 'drafts'}\n <!-- Hover actions: one click on the row's own message, nothing ticked needed. -->\n <span class=\"mx-hover\">\n <button type=\"button\" title=\"Archive (e)\" aria-label=\"Archive\" onclick={(e) => { e.stopPropagation(); moveTo('archive', [m.id]) }}>{@render ico('M3 4h18v4H3z M5 8v12h14V8 M10 12h4', 14)}</button>\n <button type=\"button\" title=\"Delete (#)\" aria-label=\"Delete\" onclick={(e) => { e.stopPropagation(); remove([m.id]) }}>{@render ico('M3 6h18 M8 6V4h8v2 M19 6l-1 14H6L5 6', 14)}</button>\n <button type=\"button\" title={m.unread ? 'Mark read (u)' : 'Mark unread (u)'} aria-label={m.unread ? 'Mark read' : 'Mark unread'} onclick={(e) => { e.stopPropagation(); toggleRead([m.id]) }}>{@render ico(m.unread ? 'M4 6h16v12H4z M4 7l8 6 8-6 M9 13l2 2 4-4' : 'M4 6h16v12H4z M4 7l8 6 8-6', 14)}</button>\n </span>\n {/if}\n </div>\n{/snippet}\n{#snippet TimeCell(props: { row: Msg })}\n <span class=\"mx-time\" class:is-unread={props.row.unread}>{shortTime(props.row.received)}</span>\n{/snippet}\n\n{#snippet recipients(field: Field, label: string)}\n <div class=\"mx-field\">\n <span>{label}</span>\n <div class=\"mx-rcpt\" class:is-invalid={field === 'to' && !!toError}>\n {#each draft[field] as email (email)}\n <span class=\"mx-chip\" title={email}>\n <SvAvatar name={nameOf(email)} size={18} />\n <span>{nameOf(email)}</span>\n <button type=\"button\" aria-label=\"Remove {email}\" onclick={() => (draft[field] = draft[field].filter((x) => x !== email))}>{@render ico('M6 6l12 12 M18 6 6 18', 12)}</button>\n </span>\n {/each}\n <input\n type=\"text\"\n aria-label={label}\n autocomplete=\"off\"\n placeholder={draft[field].length ? '' : 'Name or address'}\n bind:value={typing[field]}\n onfocus={() => { suggestFor = field; suggestAt = 0 }}\n oninput={() => { suggestFor = field; suggestAt = 0 }}\n onkeydown={(e) => onRecipientKey(field, e)}\n onblur={() => setTimeout(() => { commitTyped(field); if (suggestFor === field) suggestFor = null }, 120)}\n />\n {#if suggestFor === field && suggestions.length}\n <ul class=\"mx-suggest\" role=\"listbox\" aria-label=\"Contacts\">\n {#each suggestions as c, i (c.email)}\n <li role=\"option\" aria-selected={i === suggestAt}>\n <button type=\"button\" class:is-on={i === suggestAt} onmousedown={(e) => { e.preventDefault(); addRecipient(field, c.email) }}>\n <SvAvatar name={c.name} size={22} />\n <span class=\"mx-suggest-txt\"><strong>{c.name}</strong><span>{c.email}</span></span>\n </button>\n </li>\n {/each}\n </ul>\n {/if}\n </div>\n {#if field === 'to' && !showCc}\n <button type=\"button\" class=\"mx-link\" onclick={() => (showCc = true)}>Cc</button>\n {/if}\n </div>\n {#if field === 'to' && toError}<div class=\"mx-error\" role=\"alert\">{toError}</div>{/if}\n{/snippet}\n\n<!-- svelte-ignore a11y_no_noninteractive_tabindex -->\n<!-- svelte-ignore a11y_no_static_element_interactions -->\n<div class=\"mx\" class:rail={narrow} class:reading={narrow && openMsg != null && area === 'mail'} tabindex=\"-1\" bind:this={rootEl} onkeydown={onRootKey}>\n <!-- Navigation pane -->\n <aside class=\"mx-side\">\n <div class=\"mx-brand\">\n <span class=\"mx-logo\">{@render ico('M4 6h16v12H4z M4 7l8 6 8-6', 18)}</span>\n {#if !narrow}<span>Postbox</span>{/if}\n </div>\n {#if area === 'mail'}\n <div class=\"mx-compose\">\n <SvButton variant=\"primary\" size=\"sm\" block={!narrow} ariaLabel=\"New message\" onclick={() => compose()}>\n {#if narrow}{@render ico('M12 5v14 M5 12h14')}{:else}New message{/if}\n </SvButton>\n </div>\n <SvNavPane sections={sections} value={view} onSelect={go} collapsed={narrow} ariaLabel=\"Mail folders\"\n {modules} moduleValue={area} onModuleSelect={(id) => (area = id === 'calendar' ? 'calendar' : 'mail')} />\n {:else}\n <SvNavPane sections={calSections} bind:value={calView} collapsed={narrow} ariaLabel=\"Calendars\"\n {modules} moduleValue={area} onModuleSelect={(id) => (area = id === 'calendar' ? 'calendar' : 'mail')} />\n {/if}\n </aside>\n\n {#if area === 'mail'}\n <div class=\"mx-main\">\n <!-- Command bar -->\n <div class=\"mx-bar\" role=\"toolbar\" aria-label=\"Message actions\">\n <div class=\"mx-search\" bind:this={searchEl}>\n <SvTextInput bind:value={query} placeholder=\"Search mail ( / )\" ariaLabel=\"Search mail\" clearable block size=\"sm\" />\n </div>\n <div class=\"mx-cmds\">\n <button type=\"button\" class=\"mx-cmd\" disabled={!openMsg} title=\"Reply (r)\" onclick={() => openMsg && reply(openMsg)}>{@render ico('M9 14 4 9l5-5 M4 9h10a6 6 0 0 1 6 6v5', 15)}<span>Reply</span></button>\n <button type=\"button\" class=\"mx-cmd\" disabled={!openMsg} title=\"Reply all (a)\" onclick={() => openMsg && reply(openMsg, true)}>{@render ico('M7 14 2 9l5-5 M12 14 7 9l5-5 M7 9h7a6 6 0 0 1 6 6v5', 15)}<span>Reply all</span></button>\n <button type=\"button\" class=\"mx-cmd\" disabled={!openMsg} title=\"Forward (f)\" onclick={() => openMsg && forward(openMsg)}>{@render ico('M15 14l5-5-5-5 M20 9H10a6 6 0 0 0-6 6v5', 15)}<span>Forward</span></button>\n <span class=\"mx-sep\" aria-hidden=\"true\"></span>\n <button type=\"button\" class=\"mx-cmd\" disabled={!targets.length || view === 'archive'} title=\"Archive (e)\" onclick={() => moveTo('archive')}>{@render ico('M3 4h18v4H3z M5 8v12h14V8 M10 12h4', 15)}<span>Archive</span></button>\n <button type=\"button\" class=\"mx-cmd\" disabled={!targets.length} title=\"Delete (#)\" onclick={() => remove()}>{@render ico('M3 6h18 M8 6V4h8v2 M19 6l-1 14H6L5 6', 15)}<span>Delete</span></button>\n <button type=\"button\" class=\"mx-cmd\" disabled={!targets.length} title=\"Mark read or unread (u)\" onclick={() => toggleRead()}>{@render ico('M4 6h16v12H4z M4 7l8 6 8-6', 15)}<span>{targetMsgs.some((m) => m.unread) ? 'Mark read' : 'Mark unread'}</span></button>\n <button type=\"button\" class=\"mx-cmd\" disabled={!targets.length} title=\"Flag (s)\" onclick={() => toggleFlag()}>{@render ico('M4 22V4 M4 4h12l-2 4 2 4H4', 15)}<span>Flag</span></button>\n <SvMenu items={moveItems}>\n {#snippet anchor()}<button type=\"button\" class=\"mx-cmd\" disabled={!targets.length}>{@render ico('M3 7h7l2 2h9v10H3z', 15)}<span>Move</span></button>{/snippet}\n </SvMenu>\n <SvMenu items={exportItems}>\n {#snippet anchor()}<button type=\"button\" class=\"mx-cmd\">{@render ico('M12 3v12 M8 11l4 4 4-4 M4 17v3h16v-3', 15)}<span>Export</span></button>{/snippet}\n </SvMenu>\n </div>\n <div class=\"mx-account\">\n <SvMenu items={accountItems}>\n {#snippet anchor()}<button type=\"button\" class=\"mx-acct\" aria-label=\"Account\"><SvAvatar name={ME.name} size=\"sm\" status=\"online\" /></button>{/snippet}\n </SvMenu>\n </div>\n </div>\n\n <div class=\"mx-body\">\n <!-- Message list -->\n <section class=\"mx-list\" aria-label=\"{viewName(view)} messages\" bind:this={listEl}>\n <header class=\"mx-list-head\">\n <div class=\"mx-list-title\">\n <h2>{q ? `Results in ${viewName(view)}` : viewName(view)}</h2>\n <span class=\"mx-muted\">{listSummary}</span>\n </div>\n <div class=\"mx-quick\" role=\"group\" aria-label=\"Filter\">\n {#each QUICK_OPTIONS as [k, label] (k)}\n <button type=\"button\" class:is-on={quick === k} aria-pressed={quick === k} onclick={() => { quick = k; clearSelection() }}>{label}</button>\n {/each}\n </div>\n </header>\n <div class=\"mx-list-grid\">\n <SvGrid\n data={viewRows}\n {columns}\n {features}\n getRowId={(m) => String(m.id)}\n sortable\n selectionMode=\"row\"\n showRowSelection\n enableCellSelection={false}\n enableRowSummaries={false}\n rowHeight={compact ? 52 : 74}\n fitColumns\n containerHeight=\"100%\"\n initialSorting={[{ id: 'received', desc: true }]}\n {emptyMessage}\n {onActiveCellChange}\n rowClass={({ row }) => ({ 'mx-row-open': row.id === openId, 'mx-row-unread': row.unread })}\n onRowClick={(e) => open(e.row)}\n onRowSelectionChange={(_sel, rows) => (selected = rows.map((r) => r.id))}\n {onApiReady}\n />\n </div>\n </section>\n\n <!-- Reading pane: the whole conversation -->\n <section class=\"mx-reader\" aria-label=\"Reading pane\" bind:this={readerEl}>\n {#if openMsg}\n {@const head = openMsg}\n <article class=\"mx-rd\">\n {#if narrow}\n <button type=\"button\" class=\"mx-back\" onclick={() => (openId = null)}>{@render ico('M15 18l-6-6 6-6', 15)} {viewName(view)}</button>\n {/if}\n <header class=\"mx-rd-head\">\n <h2>{head.subject.replace(/^(re|fwd):\\s*/i, '')}</h2>\n <div class=\"mx-rd-tags\">\n {#each head.labels as l (l)}<span class=\"mx-tag\">{@render dot(LABELS[l].color)}{LABELS[l].name}</span>{/each}\n {#if conversation.length > 1}<span class=\"mx-tag\">{conversation.length} messages</span>{/if}\n {#if head.folder !== 'inbox'}<span class=\"mx-tag\">{FOLDERS[head.folder]}</span>{/if}\n </div>\n </header>\n\n {#each conversation as m (m.id)}\n {@const isOpen = expanded.includes(m.id) || conversation.length === 1}\n <section class=\"mx-msg\" class:is-open={isOpen}>\n <button type=\"button\" class=\"mx-msg-head\" aria-expanded={isOpen} disabled={conversation.length === 1} onclick={() => (expanded = toggleIn(expanded, m.id))}>\n <SvAvatar name={m.from} size={34} />\n <span class=\"mx-msg-who\">\n <span><strong>{m.email === ME.email ? `${ME.name} (you)` : m.from}</strong>{#if isOpen}{' '}<span class=\"mx-muted\"><{m.email}></span>{/if}</span>\n {#if isOpen}\n <span class=\"mx-muted\">To: {m.to === ME.email ? 'you' : splitAddresses(m.to).map(nameOf).join(', ')}{#if m.cc} · Cc: {splitAddresses(m.cc).map(nameOf).join(', ')}{/if}</span>\n {:else}\n <span class=\"mx-muted mx-msg-snip\">{preview(m)}</span>\n {/if}\n </span>\n <time class=\"mx-muted mx-msg-when\" datetime={m.received}>{isOpen ? longTime(m.received) : shortTime(m.received)}</time>\n </button>\n\n {#if isOpen}\n {#if m.invite}\n {@const inv = m.invite}\n <div class=\"mx-inv\">\n <div class=\"mx-inv-ic\">{@render ico('M7 2v3 M17 2v3 M4 5h16v16H4z M4 10h16', 20)}</div>\n <div class=\"mx-inv-txt\">\n <strong>{inv.title}</strong>\n <span>{timeRange(inv.start, inv.end)}</span>\n <span class=\"mx-muted\">{inv.where}</span>\n </div>\n <div class=\"mx-inv-act\">\n {#if inv.response}\n <span class=\"mx-inv-state\" data-r={inv.response}>{inv.response === 'accepted' ? 'Accepted' : inv.response === 'tentative' ? 'Tentative' : 'Declined'}</span>\n {#if inv.response !== 'declined'}<SvButton variant=\"outline\" size=\"sm\" onclick={() => showInCalendar(inv)}>View in calendar</SvButton>{/if}\n <SvButton variant=\"ghost\" size=\"sm\" onclick={() => patch([m.id], { invite: { ...inv, response: undefined } })}>Change</SvButton>\n {:else}\n <SvButton variant=\"primary\" size=\"sm\" onclick={() => respond(m, 'accepted')}>Accept</SvButton>\n <SvButton variant=\"outline\" size=\"sm\" onclick={() => respond(m, 'tentative')}>Tentative</SvButton>\n <SvButton variant=\"ghost\" size=\"sm\" onclick={() => respond(m, 'declined')}>Decline</SvButton>\n {/if}\n </div>\n </div>\n {/if}\n\n <!-- The body is sanitized: scripts, handlers and javascript: URLs are stripped. -->\n <!-- svelte-ignore a11y_click_events_have_key_events -->\n <!-- svelte-ignore a11y_no_static_element_interactions -->\n <div class=\"mx-rd-body\" class:show-quoted={quotedOpen.includes(m.id)} onclick={onBodyClick}>{@html sanitizeHtml(m.body)}</div>\n {#if m.body.includes('<blockquote')}\n <button type=\"button\" class=\"mx-quoted\" onclick={() => (quotedOpen = toggleIn(quotedOpen, m.id))} aria-expanded={quotedOpen.includes(m.id)}>\n {quotedOpen.includes(m.id) ? 'Hide quoted text' : '... Show quoted text'}\n </button>\n {/if}\n\n {#if m.attachments.length}\n <div class=\"mx-att\">\n {#each m.attachments as a (a.name)}\n <button type=\"button\" class=\"mx-att-item\" onclick={() => (previewing = { att: a, msg: m })}>\n <span class=\"mx-att-ext\" data-ext={extOf(a.name)}>{extOf(a.name)}</span>\n <span class=\"mx-att-txt\"><span>{a.name}</span><span class=\"mx-muted\">{a.size}</span></span>\n </button>\n {/each}\n </div>\n {/if}\n {/if}\n </section>\n {/each}\n\n {#if conversation.length}\n {@const last = conversation[conversation.length - 1]!}\n <div class=\"mx-rd-reply\">\n <SvButton variant=\"outline\" size=\"sm\" onclick={() => reply(last)}>Reply</SvButton>\n <SvButton variant=\"outline\" size=\"sm\" onclick={() => reply(last, true)}>Reply all</SvButton>\n <SvButton variant=\"outline\" size=\"sm\" onclick={() => forward(last)}>Forward</SvButton>\n </div>\n {/if}\n </article>\n {:else}\n <div class=\"mx-empty\">\n {@render ico('M4 6h16v12H4z M4 7l8 6 8-6', 36)}\n <strong>Select a message to read</strong>\n <span class=\"mx-muted\">j and k move through the list. Press <SvKbd keys={['?']} size=\"sm\" /> for every shortcut.</span>\n </div>\n {/if}\n </section>\n </div>\n </div>\n {:else}\n <div class=\"mx-main\">\n <div class=\"mx-cal-head\">\n <h2>Calendar</h2>\n <span class=\"mx-muted\">{calRows.length} events. Accepted invites from Mail land here; drag to move, drag an empty slot to add.</span>\n </div>\n <div class=\"mx-cal\">\n <SvGrid\n data={calRows}\n columns={calColumns}\n getRowId={(e) => String(e.id)}\n containerHeight=\"100%\"\n scheduler={{\n startField: 'start', endField: 'end', allDayField: 'allDay', titleField: 'title', colorField: 'color',\n views: ['month', 'week', 'day', 'agenda'], initialView: 'week', weekStartsOn: 1,\n dayStartHour: 7, dayEndHour: 20, slotMinutes: 30,\n date: calDate, onNavigate: (d) => (calDate = d),\n editable: true, tooltip: true,\n onEventMove, onEventResize, onEventCommit, onEventAdd, onEventDelete,\n }}\n />\n </div>\n </div>\n {/if}\n</div>\n\n<SvModal bind:open={composeOpen} title={draft.subject.trim() || 'New message'} width={720}>\n <!-- svelte-ignore a11y_no_static_element_interactions -->\n <div class=\"mx-form\" onkeydown={onComposeKey}>\n {@render recipients('to', 'To')}\n {#if showCc}{@render recipients('cc', 'Cc')}{/if}\n <label class=\"mx-field\">\n <span>Subject</span>\n <SvTextInput bind:value={draft.subject} placeholder=\"Subject\" ariaLabel=\"Subject\" block />\n </label>\n <SvRichText bind:value={draft.html} tools={TOOLS} minHeight=\"220px\" placeholder=\"Write your message...\" ariaLabel=\"Message body\" />\n {#if draft.attachments.length}\n <div class=\"mx-att mx-att-draft\">\n {#each draft.attachments as a, i (a.name + i)}\n <span class=\"mx-att-item\">\n <span class=\"mx-att-ext\" data-ext={extOf(a.name)}>{extOf(a.name)}</span>\n <span class=\"mx-att-txt\"><span>{a.name}</span><span class=\"mx-muted\">{a.size}</span></span>\n <button type=\"button\" class=\"mx-att-x\" aria-label=\"Remove {a.name}\" onclick={() => (draft.attachments = draft.attachments.filter((_, j) => j !== i))}>{@render ico('M6 6l12 12 M18 6 6 18', 12)}</button>\n </span>\n {/each}\n </div>\n {/if}\n <input type=\"file\" multiple hidden bind:this={fileEl} onchange={onFiles} />\n </div>\n {#snippet footer()}\n <div class=\"mx-foot\">\n <button type=\"button\" class=\"mx-cmd\" onclick={() => fileEl?.click()}>{@render ico('M21 11l-8.5 8.5a5 5 0 0 1-7-7L14 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L15 7', 15)}<span>Attach</span></button>\n <span class=\"mx-muted mx-foot-hint\">Ctrl+Enter to send</span>\n <SvButton variant=\"ghost\" size=\"sm\" onclick={() => (composeOpen = false)}>Discard</SvButton>\n <SvButton variant=\"outline\" size=\"sm\" onclick={saveDraft}>Save draft</SvButton>\n <SvButton variant=\"primary\" size=\"sm\" onclick={send}>Send</SvButton>\n </div>\n {/snippet}\n</SvModal>\n\n<SvModal open={previewing != null} onClose={() => (previewing = null)} title={previewing?.att.name ?? ''} width={560}>\n {#if previewing}\n {@const pv = previewing}\n <div class=\"mx-pv\">\n <div class=\"mx-pv-page\" data-ext={extOf(pv.att.name)}>\n <span class=\"mx-att-ext\" data-ext={extOf(pv.att.name)}>{extOf(pv.att.name)}</span>\n <strong>{pv.att.name.replace(/\\.[^.]+$/, '').replace(/[-_]/g, ' ')}</strong>\n <span class=\"mx-pv-line\" style:width=\"86%\"></span>\n <span class=\"mx-pv-line\" style:width=\"72%\"></span>\n <span class=\"mx-pv-line\" style:width=\"90%\"></span>\n <span class=\"mx-pv-line\" style:width=\"64%\"></span>\n <span class=\"mx-pv-line\" style:width=\"80%\"></span>\n </div>\n <div class=\"mx-pv-meta\">\n <span>{pv.att.size} · from {pv.msg.from}, {longTime(pv.msg.received)}</span>\n <span class=\"mx-muted\">A sample attachment: in a real app this button streams the file from your mail API.</span>\n </div>\n </div>\n {/if}\n {#snippet footer()}\n <div class=\"mx-foot\">\n <SvButton variant=\"ghost\" size=\"sm\" onclick={() => { if (previewing) forward(previewing.msg); previewing = null }}>Forward</SvButton>\n <SvButton variant=\"primary\" size=\"sm\" disabled>Download</SvButton>\n </div>\n {/snippet}\n</SvModal>\n\n<SvModal bind:open={helpOpen} title=\"Keyboard shortcuts\" width={420}>\n <ul class=\"mx-keys\">\n {#each SHORTCUTS as [k, label] (k)}\n <li><SvKbd keys={[k]} size=\"sm\" /><span>{label}</span></li>\n {/each}\n </ul>\n</SvModal>\n\n<SvToaster position=\"bottom-right\" />\n\n<style>\n .mx {\n display: grid;\n grid-template-columns: 224px minmax(0, 1fr);\n grid-template-rows: minmax(0, 1fr);\n flex: 1 1 auto;\n min-height: 640px;\n height: 100%;\n border: 1px solid var(--sg-border, #e5e7eb);\n border-radius: 12px;\n overflow: hidden;\n background: var(--sg-bg, #fff);\n color: var(--sg-fg, #0f172a);\n font-size: 13px;\n outline: none;\n }\n .mx.rail { grid-template-columns: 60px minmax(0, 1fr); }\n .mx-muted { color: var(--sg-muted, #64748b); }\n\n /* ---- Navigation pane */\n .mx-side { display: flex; flex-direction: column; min-height: 0; min-width: 0; border-right: 1px solid var(--sg-border, #e5e7eb); background: var(--sg-header-bg, #f8fafc); }\n .mx-side :global(.sv-nav) { width: auto; flex: 1 1 0; min-height: 0; border: 0; border-radius: 0; background: transparent; }\n .mx-brand { display: flex; align-items: center; gap: 9px; height: 50px; padding: 0 16px; font-weight: 650; font-size: 15px; flex: none; }\n .mx.rail .mx-brand { padding: 0; justify-content: center; }\n .mx-logo { display: inline-grid; place-items: center; width: 28px; height: 28px; border-radius: 7px; background: var(--sg-fg, #0f172a); color: var(--sg-bg, #fff); }\n .mx-compose { padding: 0 12px 8px; flex: none; }\n .mx.rail .mx-compose { padding: 0 10px 8px; display: flex; justify-content: center; }\n .mx :global(.mx-ldot) { display: inline-block; width: 8px; height: 8px; border-radius: 999px; flex: none; }\n\n /* ---- Main column */\n .mx-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }\n .mx-bar { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--sg-border, #e5e7eb); flex: none; flex-wrap: wrap; }\n .mx-search { width: 240px; flex: 0 1 240px; }\n .mx-cmds { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; min-width: 0; }\n .mx-account { margin-left: auto; }\n .mx-acct { display: inline-flex; padding: 0; border: 0; background: none; cursor: pointer; border-radius: 999px; }\n .mx-acct:focus-visible { outline: 2px solid var(--sg-accent, #2563eb); outline-offset: 2px; }\n .mx-cmd {\n display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 9px;\n border: 0; border-radius: 6px; background: transparent; color: var(--sg-fg, #0f172a);\n font: inherit; font-size: 12.5px; cursor: pointer; white-space: nowrap;\n }\n .mx-cmd:hover:not(:disabled) { background: var(--sg-row-hover-bg, #f1f5f9); }\n .mx-cmd:disabled { opacity: 0.4; cursor: default; }\n .mx-cmd:focus-visible { outline: 2px solid var(--sg-accent, #2563eb); outline-offset: 1px; }\n .mx-sep { width: 1px; height: 18px; margin: 0 4px; background: var(--sg-border, #e5e7eb); }\n\n .mx-body { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(340px, 440px) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }\n .mx-list { display: flex; flex-direction: column; min-height: 0; min-width: 0; border-right: 1px solid var(--sg-border, #e5e7eb); }\n .mx-list-head { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px 10px; flex: none; }\n .mx-list-title { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; min-width: 0; }\n .mx-list-head h2 { margin: 0; font-size: 16px; font-weight: 650; white-space: nowrap; }\n .mx-list-title span { font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n .mx-quick { display: flex; gap: 4px; }\n .mx-quick button { font: inherit; font-size: 12px; padding: 3px 10px; border-radius: 999px; border: 1px solid var(--sg-border, #e5e7eb); background: transparent; color: var(--sg-muted, #64748b); cursor: pointer; }\n .mx-quick button:hover { color: var(--sg-fg, #0f172a); }\n .mx-quick button.is-on { background: var(--sg-fg, #0f172a); border-color: var(--sg-fg, #0f172a); color: var(--sg-bg, #fff); }\n .mx-list-grid { flex: 1 1 0; min-height: 0; }\n\n /* Message cells */\n .mx :global(.mx-mc) { position: relative; display: flex; align-items: center; gap: 10px; min-width: 0; width: 100%; line-height: 1.3; }\n .mx :global(.mx-mc-av) { flex: none; display: inline-flex; }\n .mx :global(.mx-mc-txt) { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }\n .mx :global(.mx-mc-l1) { display: flex; align-items: center; gap: 6px; min-width: 0; }\n .mx :global(.mx-mc-from) { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .mx :global(.mx-mc.is-unread .mx-mc-from),\n .mx :global(.mx-mc.is-unread .mx-mc-sub) { font-weight: 700; }\n .mx :global(.mx-mc-count) { flex: none; font-size: 10.5px; font-weight: 600; min-width: 18px; padding: 0 5px; line-height: 16px; text-align: center; border-radius: 999px; background: var(--sg-row-hover-bg, #f1f5f9); color: var(--sg-muted, #64748b); }\n .mx :global(.mx-mc-clip) { display: inline-flex; color: var(--sg-muted, #64748b); flex: none; }\n .mx :global(.mx-mc-when) { margin-left: auto; flex: none; padding-left: 6px; }\n .mx :global(.mx-mc-sub) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; }\n .mx :global(.mx-mc-tag) { font-size: 10.5px; font-weight: 600; padding: 0 5px; margin-right: 6px; border-radius: 4px; border: 1px solid var(--sg-border, #e5e7eb); color: var(--sg-muted, #64748b); }\n .mx :global(.mx-mc-prev) { display: flex; align-items: center; gap: 5px; overflow: hidden; white-space: nowrap; font-size: 12px; color: var(--sg-muted, #64748b); }\n .mx :global(.mx-mc-prev > span:last-child) { overflow: hidden; text-overflow: ellipsis; min-width: 0; }\n /* Hover actions sit over the end of the cell on a solid background. */\n .mx :global(.mx-hover) { position: absolute; right: 0; top: 50%; transform: translateY(-50%); display: flex; gap: 2px; padding: 2px 2px 2px 14px; opacity: 0; pointer-events: none; background: linear-gradient(to right, transparent, var(--sg-bg, #fff) 14px); transition: opacity 0.12s ease; }\n .mx :global(tr:hover .mx-hover) { opacity: 1; pointer-events: auto; }\n .mx :global(.mx-hover button) { display: inline-grid; place-items: center; width: 26px; height: 26px; padding: 0; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 6px; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); cursor: pointer; }\n .mx :global(.mx-hover button:hover) { background: var(--sg-row-hover-bg, #f1f5f9); }\n .mx :global(.mx-time) { font-size: 12px; color: var(--sg-muted, #64748b); font-variant-numeric: tabular-nums; }\n .mx :global(.mx-time.is-unread) { color: var(--sg-fg, #0f172a); font-weight: 600; }\n /* A row paints no box-shadow under cells that have their own background,\n so the unread bar goes on the first cell. */\n .mx :global(.mx-row-unread > td:first-child) { box-shadow: inset 3px 0 0 var(--sg-fg, #0f172a); }\n .mx-list-grid :global(.sv-grid-row:not(.sv-grid-empty-row)) { cursor: pointer; }\n .mx-list-grid :global(.sv-grid-empty-cell) { padding: 48px 20px; text-align: center; color: var(--sg-muted, #64748b); font-size: 13px; }\n /* The cells paint their own background, so the open row is tinted per cell. */\n .mx :global(.mx-row-open > td) { background: var(--sg-selection-bg, #eff6ff); }\n /* The tint already marks the clicked row; the active-cell ring stays for\n keyboard moves onto other rows. */\n .mx :global(.mx-row-open .sv-grid-cell-active:not(.sv-grid-cell-editing)) { box-shadow: none; }\n .mx :global(.mx-flag) { display: inline-grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 5px; background: transparent; color: var(--sg-muted, #94a3b8); cursor: pointer; opacity: 0.55; }\n .mx :global(.mx-flag:hover) { opacity: 1; background: var(--sg-row-hover-bg, #f1f5f9); }\n /* Flag red is a status colour, not chrome. */\n .mx :global(.mx-flag.is-on) { color: #dc2626; opacity: 1; }\n\n /* ---- Reading pane */\n .mx-reader { min-height: 0; min-width: 0; overflow: auto; }\n .mx-rd { padding: 18px 24px 28px; display: flex; flex-direction: column; gap: 10px; max-width: 840px; }\n .mx-back { align-self: flex-start; display: inline-flex; align-items: center; gap: 4px; border: 0; background: none; color: var(--sg-fg, #0f172a); font: inherit; font-weight: 600; cursor: pointer; padding: 4px 0; }\n .mx-rd-head { padding-bottom: 4px; }\n .mx-rd-head h2 { margin: 0 0 6px; font-size: 19px; font-weight: 650; line-height: 1.3; }\n .mx-rd-tags { display: flex; flex-wrap: wrap; gap: 6px; }\n .mx-tag { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--sg-border, #e5e7eb); color: var(--sg-muted, #64748b); }\n\n .mx-msg { border: 1px solid var(--sg-border, #e5e7eb); border-radius: 10px; background: var(--sg-bg, #fff); }\n .mx-msg.is-open { padding-bottom: 14px; }\n .mx-msg-head { display: flex; align-items: center; gap: 12px; width: 100%; padding: 10px 14px; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; border-radius: 10px; }\n .mx-msg-head:disabled { cursor: default; }\n .mx-msg:not(.is-open) .mx-msg-head:hover { background: var(--sg-row-hover-bg, #f8fafc); }\n .mx-msg-who { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }\n .mx-msg-who > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .mx-msg-when { font-size: 12px; white-space: nowrap; }\n .mx-msg > :not(.mx-msg-head) { margin-left: 60px; margin-right: 14px; }\n\n .mx-rd-body { font-size: 14px; line-height: 1.6; overflow-wrap: anywhere; }\n .mx-rd-body :global(p) { margin: 0 0 10px; }\n .mx-rd-body :global(h2) { margin: 0 0 8px; font-size: 17px; }\n .mx-rd-body :global(ul), .mx-rd-body :global(ol) { margin: 0 0 10px; padding-left: 22px; }\n .mx-rd-body :global(a) { color: var(--sg-accent, #2563eb); }\n .mx-rd-body :global(code) { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; padding: 0 4px; border-radius: 4px; background: var(--sg-row-hover-bg, #f1f5f9); }\n .mx-rd-body :global(blockquote) { margin: 10px 0 0; padding: 2px 0 2px 12px; border-left: 3px solid var(--sg-border, #e5e7eb); color: var(--sg-muted, #64748b); }\n .mx-rd-body:not(.show-quoted) :global(blockquote) { display: none; }\n .mx-rd-body :global(.sig) { margin-top: 12px; padding-top: 8px; border-top: 1px solid var(--sg-border, #e5e7eb); font-size: 12.5px; }\n .mx-rd-body :global(.sig span) { color: var(--sg-muted, #64748b); }\n .mx-rd-body :global(.receipt), .mx-rd-body :global(.build) { margin: 4px 0 12px; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 8px; overflow: hidden; max-width: 460px; }\n .mx-rd-body :global(.receipt-head), .mx-rd-body :global(.build-head) { display: flex; align-items: center; gap: 8px; justify-content: space-between; padding: 10px 14px; background: var(--sg-header-bg, #f8fafc); border-bottom: 1px solid var(--sg-border, #e5e7eb); }\n .mx-rd-body :global(.build-head) { justify-content: flex-start; }\n .mx-rd-body :global(.receipt-row), .mx-rd-body :global(.build-row) { display: flex; justify-content: space-between; gap: 12px; padding: 7px 14px; font-size: 13px; font-variant-numeric: tabular-nums; }\n .mx-rd-body :global(.receipt-row + .receipt-row), .mx-rd-body :global(.build-row + .build-row) { border-top: 1px solid var(--sg-border, #e5e7eb); }\n .mx-rd-body :global(.receipt-total) { font-weight: 700; background: var(--sg-header-bg, #f8fafc); }\n /* Passed green is a status colour. */\n .mx-rd-body :global(.build-ok) { font-size: 11px; font-weight: 700; padding: 1px 8px; border-radius: 999px; color: #15803d; background: color-mix(in srgb, #16a34a 14%, transparent); }\n .mx-quoted { align-self: flex-start; font: inherit; font-size: 12px; padding: 2px 8px; border-radius: 6px; border: 1px solid var(--sg-border, #e5e7eb); background: var(--sg-header-bg, #f8fafc); color: var(--sg-muted, #64748b); cursor: pointer; margin-bottom: 8px; }\n .mx-rd-reply { display: flex; gap: 8px; padding-top: 6px; }\n\n .mx-inv { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 12px 14px; margin-bottom: 12px; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 10px; background: var(--sg-header-bg, #f8fafc); }\n .mx-inv-ic { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 9px; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e5e7eb); flex: none; }\n .mx-inv-txt { display: flex; flex-direction: column; gap: 2px; flex: 1 1 200px; min-width: 0; }\n .mx-inv-act { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }\n .mx-inv-state { font-size: 12px; font-weight: 600; padding: 2px 9px; border-radius: 999px; }\n /* Response colours are status, so they stay literal. */\n .mx-inv-state[data-r='accepted'] { color: #15803d; background: color-mix(in srgb, #16a34a 14%, transparent); }\n .mx-inv-state[data-r='tentative'] { color: #b45309; background: color-mix(in srgb, #d97706 14%, transparent); }\n .mx-inv-state[data-r='declined'] { color: #b91c1c; background: color-mix(in srgb, #dc2626 12%, transparent); }\n\n .mx-att { display: flex; flex-wrap: wrap; gap: 8px; }\n .mx-att-item { position: relative; display: inline-flex; align-items: center; gap: 9px; padding: 7px 12px 7px 7px; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 8px; background: var(--sg-bg, #fff); color: inherit; font: inherit; cursor: pointer; text-align: left; }\n button.mx-att-item:hover { background: var(--sg-row-hover-bg, #f1f5f9); }\n .mx-att-draft .mx-att-item { cursor: default; padding-right: 30px; }\n .mx-att-x { position: absolute; right: 6px; top: 6px; display: inline-grid; place-items: center; width: 18px; height: 18px; padding: 0; border: 0; border-radius: 4px; background: none; color: var(--sg-muted, #64748b); cursor: pointer; }\n .mx-att-x:hover { background: var(--sg-row-hover-bg, #f1f5f9); }\n .mx-att-ext { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 6px; font-size: 10px; font-weight: 700; text-transform: uppercase; background: var(--sg-header-bg, #f8fafc); border: 1px solid var(--sg-border, #e5e7eb); flex: none; }\n /* File-type colours identify the type, like a file manager does. */\n .mx-att-ext[data-ext='pdf'] { color: #b91c1c; }\n .mx-att-ext[data-ext='docx'], .mx-att-ext[data-ext='doc'] { color: #1d4ed8; }\n .mx-att-ext[data-ext='xlsx'], .mx-att-ext[data-ext='csv'] { color: #15803d; }\n .mx-att-txt { display: flex; flex-direction: column; font-size: 12.5px; }\n\n .mx-empty { height: 100%; min-height: 240px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--sg-muted, #94a3b8); text-align: center; padding: 24px; }\n .mx-empty strong { color: var(--sg-fg, #0f172a); font-size: 14px; }\n\n /* ---- Calendar module */\n .mx-cal-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; padding: 12px 16px; border-bottom: 1px solid var(--sg-border, #e5e7eb); flex: none; }\n .mx-cal-head h2 { margin: 0; font-size: 16px; font-weight: 650; }\n .mx-cal-head span { font-size: 12px; }\n .mx-cal { flex: 1 1 0; min-height: 0; padding: 8px; }\n\n /* ---- Composer */\n .mx-form { display: flex; flex-direction: column; gap: 10px; }\n .mx-field { display: grid; grid-template-columns: 64px minmax(0, 1fr) auto; align-items: center; gap: 8px; }\n .mx-field > span { font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }\n label.mx-field { grid-template-columns: 64px minmax(0, 1fr); }\n .mx-rcpt { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; min-height: 34px; padding: 3px 6px; border: 1px solid var(--sg-input-border, var(--sg-border, #d1d5db)); border-radius: var(--sg-radius, 6px); background: var(--sg-input-bg, var(--sg-bg, #fff)); }\n .mx-rcpt:focus-within { border-color: var(--sg-accent, #2563eb); box-shadow: 0 0 0 3px color-mix(in srgb, var(--sg-accent, #2563eb) 18%, transparent); }\n .mx-rcpt.is-invalid { border-color: #dc2626; }\n .mx-rcpt input { flex: 1 1 120px; min-width: 80px; border: 0; outline: 0; background: transparent; color: var(--sg-fg, #0f172a); font: inherit; font-size: 13px; padding: 4px 2px; }\n .mx-chip { display: inline-flex; align-items: center; gap: 5px; padding: 1px 4px 1px 2px; border-radius: 999px; background: var(--sg-row-hover-bg, #f1f5f9); font-size: 12.5px; }\n .mx-chip button { display: inline-grid; place-items: center; width: 16px; height: 16px; padding: 0; border: 0; border-radius: 999px; background: none; color: var(--sg-muted, #64748b); cursor: pointer; }\n .mx-chip button:hover { background: var(--sg-border, #e5e7eb); color: var(--sg-fg, #0f172a); }\n .mx-suggest { position: absolute; left: 0; right: 0; top: calc(100% + 4px); z-index: 5; margin: 0; padding: 4px; list-style: none; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 8px; background: var(--sg-bg, #fff); box-shadow: 0 8px 24px rgb(0 0 0 / 0.12); }\n .mx-suggest button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 6px 8px; border: 0; border-radius: 6px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }\n .mx-suggest button.is-on, .mx-suggest button:hover { background: var(--sg-row-hover-bg, #f1f5f9); }\n .mx-suggest-txt { display: flex; flex-direction: column; font-size: 12.5px; }\n .mx-suggest-txt span { color: var(--sg-muted, #64748b); font-size: 12px; }\n .mx-link { border: 0; background: none; padding: 4px 6px; font: inherit; font-size: 12.5px; color: var(--sg-muted, #64748b); cursor: pointer; border-radius: 5px; }\n .mx-link:hover { color: var(--sg-fg, #0f172a); background: var(--sg-row-hover-bg, #f1f5f9); }\n .mx-error { margin: -4px 0 0 72px; font-size: 12px; color: #dc2626; }\n .mx-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; width: 100%; }\n .mx-foot-hint { margin-right: auto; font-size: 12px; }\n\n /* ---- Attachment preview */\n .mx-pv { display: flex; flex-direction: column; gap: 12px; }\n .mx-pv-page { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; padding: 26px 30px; min-height: 260px; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 8px; background: var(--sg-header-bg, #f8fafc); }\n .mx-pv-page strong { font-size: 15px; text-transform: capitalize; margin-bottom: 6px; }\n .mx-pv-line { display: block; height: 8px; border-radius: 4px; background: var(--sg-border, #e5e7eb); }\n .mx-pv-meta { display: flex; flex-direction: column; gap: 2px; font-size: 12.5px; }\n\n .mx-keys { margin: 0; padding: 0; list-style: none; display: grid; grid-template-columns: 1fr 1fr; gap: 8px 16px; }\n .mx-keys li { display: flex; align-items: center; gap: 10px; font-size: 13px; }\n\n /* ---- Narrow: the reader replaces the list while a message is open */\n @media (max-width: 860px) {\n .mx-body { grid-template-columns: minmax(0, 1fr); }\n .mx-list { border-right: 0; }\n .mx-reader { display: none; }\n .mx.reading .mx-list { display: none; }\n .mx.reading .mx-reader { display: block; }\n .mx-search { flex: 1 1 160px; width: auto; }\n .mx-cmd span { display: none; }\n .mx-cmd { padding: 0 7px; }\n .mx-sep { display: none; }\n .mx-account { display: none; }\n .mx-rd { padding: 12px 14px 20px; }\n .mx-msg > :not(.mx-msg-head) { margin-left: 14px; }\n }\n @media (max-width: 639px) {\n .mx-foot-hint { display: none; }\n .mx-keys { grid-template-columns: 1fr; }\n }\n @media (max-width: 639px), (max-height: 500px) and (pointer: coarse) {\n .mx { flex-shrink: 0; min-height: 600px; }\n }\n</style>\n"
|
|
3049
|
+
},
|
|
3050
|
+
{
|
|
3051
|
+
"id": "502-ops-workspace",
|
|
3052
|
+
"path": "examples/src/demos/502-ops-workspace.svelte",
|
|
3053
|
+
"title": "Ops Workspace",
|
|
3054
|
+
"category": "App Templates",
|
|
3055
|
+
"blurb": "502. Ops workspace (app template) An on-call workspace on SvDockManager. Panes: a live service grid, p50 / p95 / p99 latency and error charts for the selected service, an",
|
|
3056
|
+
"source": "<script lang=\"ts\">\n /**\n * 502. Ops workspace (app template)\n * ---------------------------------\n * An on-call workspace on SvDockManager. Panes: a live service grid,\n * p50 / p95 / p99 latency and error charts for the selected service, an\n * incident board (Board or Table over the same SvGrid), deploys, on-call and\n * an alert feed. Every pane drags, tabs, floats and auto-hides, and the\n * arrangement saves to localStorage.\n *\n * The panes run one incident loop, the way a real on-call tool does:\n * 1. A deploy goes out (progress, then live) and the service degrades.\n * 2. An alert rule fires: the row tints, the feed logs it, an incident\n * opens in Triage and the team's primary is paged.\n * 3. The primary acknowledges; the incident's timeline records each step.\n * 4. Rolling back the deploy recovers the service and moves the incident\n * to Mitigated; resolve it from the incident drawer.\n * A scripted bad deploy runs the loop a few seconds after load; Inject\n * failure runs it again on the selected service.\n *\n * Dock layout, grid, charts, drawer: @svgrid/grid.\n * Alert rules, Kanban board renderer, export: @svgrid/enterprise.\n */\n import { untrack } from 'svelte'\n import {\n SvGrid,\n SvDockManager,\n SvChart,\n SvDrawer,\n SvMenu,\n SvAvatar,\n SvToaster,\n toast,\n renderSnippet,\n dockGroup,\n dockTabs,\n dockPane,\n type ColumnDef,\n type ChartSpec,\n type DockManagerState,\n type MenuItem,\n type SvGridApi,\n type BoardCardMoveEvent,\n } from '@svgrid/grid'\n import type { ConditionalFormat } from '@svgrid/grid/format'\n import {\n SvGridAlerts,\n alertStore,\n enableAlerts,\n enableBoardView,\n installEnterprise,\n setLicenseKey,\n type AlertEvent,\n type AlertRule,\n type EnterpriseGridApi,\n type ExportColumn,\n type ExportFormat,\n type ExprColumn,\n } from '@svgrid/enterprise'\n\n setLicenseKey('SVENTERPRISE-DEV-LOCAL')\n enableAlerts()\n enableBoardView()\n\n // ---- Domain ---------------------------------------------------------------\n type Status = 'healthy' | 'degraded' | 'down'\n type Service = {\n id: string\n name: string\n team: string\n tier: 'P0' | 'P1' | 'P2'\n region: string\n rps: number\n p95: number\n errorPct: number\n cpu: number\n version: string\n status: Status\n p95History: number[]\n }\n type Baseline = { p95: number; rps: number; err: number; cpu: number }\n type IncStatus = 'triage' | 'investigating' | 'mitigated' | 'resolved'\n type Severity = 'SEV1' | 'SEV2' | 'SEV3'\n type TimelineEntry = { at: number; who: string; text: string; kind: 'open' | 'status' | 'sev' | 'page' | 'ack' | 'note' | 'deploy' }\n type Incident = {\n id: string\n title: string\n service: string\n severity: Severity\n status: IncStatus\n commander: string\n opened: string\n source: 'alert' | 'manual'\n timeline: TimelineEntry[]\n }\n type Deploy = {\n id: number\n service: string\n version: string\n from: string\n author: string\n at: number\n sha: string\n message: string\n kind: 'deploy' | 'rollback'\n state: 'deploying' | 'live'\n progress: number\n rolledBack?: boolean\n /** A bad deploy turns its service sour once it is live. */\n chaos?: Chaos['kind']\n }\n type Chaos = { kind: 'latency' | 'errors'; ticks: number }\n\n const SLO_P95 = 800\n const ERROR_LIMIT = 5\n const HISTORY = 40\n const TICK_MS = 1500\n const ME = 'Robin Hale'\n\n const ROTA: Record<string, { primary: string; secondary: string; until: number }> = {\n Payments: { primary: 'Priya Nair', secondary: 'Leo Marsh', until: 18 },\n Platform: { primary: 'Owen Bradley', secondary: 'Ana Ruiz', until: 20 },\n Growth: { primary: 'Grace Kim', secondary: 'Tom Haas', until: 17 },\n Search: { primary: 'Sam Ortiz', secondary: 'Ivy Okafor', until: 19 },\n Data: { primary: 'Mia Chen', secondary: 'Lena Fischer', until: 18 },\n }\n const PEOPLE = [ME, ...Object.values(ROTA).flatMap((r) => [r.primary, r.secondary])]\n\n const SEED: Array<Omit<Service, 'rps' | 'p95' | 'errorPct' | 'cpu' | 'status' | 'p95History'> & Baseline> = [\n { id: 'checkout-api', name: 'checkout-api', team: 'Payments', tier: 'P0', region: 'us-east', version: 'v2.13.4', p95: 310, rps: 1840, err: 0.4, cpu: 52 },\n { id: 'payments-gw', name: 'payments-gw', team: 'Payments', tier: 'P0', region: 'us-east', version: 'v5.2.0', p95: 420, rps: 960, err: 0.6, cpu: 47 },\n { id: 'auth', name: 'auth', team: 'Platform', tier: 'P0', region: 'global', version: 'v8.0.3', p95: 95, rps: 5200, err: 0.1, cpu: 38 },\n { id: 'edge-proxy', name: 'edge-proxy', team: 'Platform', tier: 'P0', region: 'global', version: 'v1.31.0', p95: 42, rps: 14800, err: 0.05, cpu: 61 },\n { id: 'catalog', name: 'catalog', team: 'Growth', tier: 'P1', region: 'eu-west', version: 'v3.9.1', p95: 180, rps: 3100, err: 0.3, cpu: 44 },\n { id: 'search', name: 'search', team: 'Search', tier: 'P1', region: 'eu-west', version: 'v12.4.2', p95: 260, rps: 2650, err: 0.5, cpu: 66 },\n { id: 'recommendations', name: 'recommendations', team: 'Growth', tier: 'P2', region: 'eu-west', version: 'v0.18.7', p95: 540, rps: 720, err: 0.8, cpu: 71 },\n { id: 'notifications', name: 'notifications', team: 'Platform', tier: 'P1', region: 'us-west', version: 'v4.4.0', p95: 150, rps: 1300, err: 0.2, cpu: 29 },\n { id: 'orders', name: 'orders', team: 'Payments', tier: 'P0', region: 'us-east', version: 'v6.1.2', p95: 230, rps: 1500, err: 0.3, cpu: 41 },\n { id: 'inventory', name: 'inventory', team: 'Data', tier: 'P1', region: 'us-west', version: 'v2.2.9', p95: 205, rps: 880, err: 0.4, cpu: 35 },\n { id: 'analytics-ingest', name: 'analytics-ingest', team: 'Data', tier: 'P2', region: 'us-west', version: 'v9.0.0', p95: 610, rps: 4100, err: 1.1, cpu: 78 },\n { id: 'media', name: 'media', team: 'Growth', tier: 'P2', region: 'ap-south', version: 'v1.6.3', p95: 330, rps: 640, err: 0.6, cpu: 33 },\n ]\n const base = new Map<string, Baseline>(SEED.map((s) => [s.id, { p95: s.p95, rps: s.rps, err: s.err, cpu: s.cpu }]))\n const teamOf = (serviceId: string) => SEED.find((s) => s.id === serviceId)?.team ?? 'Platform'\n\n const statusOf = (s: { p95: number; errorPct: number }): Status =>\n s.errorPct > ERROR_LIMIT ? 'down' : s.p95 > SLO_P95 || s.errorPct > 2 ? 'degraded' : 'healthy'\n const round = (n: number, d = 0) => Math.round(n * 10 ** d) / 10 ** d\n\n // A seeded random walk, so the first frames look the same every visit.\n let rngState = 7\n const rnd = () => ((rngState = (rngState * 16807) % 2147483647) / 2147483647)\n const shaOf = () => Array.from({ length: 7 }, () => '0123456789abcdef'[Math.floor(rnd() * 16)]).join('')\n\n const firstRows: Service[] = SEED.map((s) => {\n const hist = Array.from({ length: HISTORY }, () => round(s.p95 * (0.9 + rnd() * 0.2)))\n const row = { ...s, rps: s.rps, p95: hist[HISTORY - 1]!, errorPct: s.err, cpu: s.cpu, p95History: hist }\n return { ...row, status: statusOf(row) }\n })\n let services = $state.raw<Service[]>(firstRows)\n // Percentile and error histories live beside the rows; the grid has no use for them.\n const p50Hist = new Map<string, number[]>(firstRows.map((s) => [s.id, s.p95History.map((v) => round(v * (0.48 + rnd() * 0.06)))]))\n const p99Hist = new Map<string, number[]>(firstRows.map((s) => [s.id, s.p95History.map((v) => round(v * (1.5 + rnd() * 0.3)))]))\n const errHistory = new Map<string, number[]>(SEED.map((s) => [s.id, Array.from({ length: HISTORY }, () => round(s.err * (0.8 + rnd() * 0.4), 2))]))\n let histVersion = $state(0)\n const chaos = new Map<string, Chaos>()\n\n // Wall clock for relative times and the UTC readout, so \"3m ago\" keeps moving.\n let now = $state(Date.now())\n $effect(() => {\n const t = setInterval(() => (now = Date.now()), 1000)\n return () => clearInterval(t)\n })\n const utc = $derived(new Date(now).toISOString().slice(11, 19))\n\n let paused = $state(false)\n let tickCount = 0\n const hms = (t: number) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })\n let clock = $state<string[]>(Array.from({ length: HISTORY }, (_, i) => hms(Date.now() - (HISTORY - 1 - i) * TICK_MS)))\n\n function tick() {\n if (paused) return\n tickCount++\n // One scripted bad deploy a few seconds in, so the loop runs without a click.\n if (tickCount === 2) ship('checkout-api', 'latency', 'ci-bot', 'Batch tax lookups per cart instead of per line')\n advanceDeploys()\n services = services.map((s) => {\n const b = base.get(s.id)!\n const c = chaos.get(s.id)\n const wobble = () => 1 + (rnd() - 0.5) * 0.18\n let p95 = b.p95 * wobble()\n let errorPct = b.err * (0.7 + rnd() * 0.6)\n let rps = b.rps * wobble()\n let cpu = b.cpu * (0.92 + rnd() * 0.16)\n if (c) {\n const ramp = Math.min(1, (40 - c.ticks) / 4 + 0.25)\n // Additive, so even a 40 ms service crosses the SLO when it breaks.\n if (c.kind === 'latency') { p95 += (900 + rnd() * 600) * ramp; cpu = Math.min(99, cpu * 1.45); errorPct += 1.2 * ramp }\n else { errorPct = 6 + rnd() * 6 * ramp; rps *= 0.82 }\n c.ticks--\n if (c.ticks <= 0) chaos.delete(s.id)\n }\n const next = { ...s, p95: round(p95), errorPct: round(errorPct, 2), rps: round(rps), cpu: round(cpu), p95History: [...s.p95History.slice(1), round(p95)] }\n push(p50Hist, s.id, round(p95 * (0.48 + rnd() * 0.06)))\n push(p99Hist, s.id, round(p95 * (1.5 + rnd() * 0.3)))\n push(errHistory, s.id, next.errorPct)\n return { ...next, status: statusOf(next) }\n })\n histVersion++\n clock = [...clock.slice(1), hms(Date.now())]\n }\n function push(map: Map<string, number[]>, id: string, v: number) {\n const arr = map.get(id)!\n arr.push(v)\n arr.shift()\n }\n $effect(() => {\n const t = setInterval(tick, TICK_MS)\n return () => clearInterval(t)\n })\n\n // Timers the workspace starts (page acknowledgements); cleared on unmount.\n const timers = new Set<ReturnType<typeof setTimeout>>()\n const later = (ms: number, fn: () => void) => {\n const t = setTimeout(() => { timers.delete(t); fn() }, ms)\n timers.add(t)\n }\n $effect(() => () => { for (const t of timers) clearTimeout(t) })\n\n const ago = (t: number) => {\n const s = Math.max(0, Math.round((now - t) / 1000))\n if (s < 45) return 'just now'\n const m = Math.round(s / 60)\n return m < 60 ? `${m}m ago` : `${Math.floor(m / 60)}h ${m % 60}m ago`\n }\n const age = (iso: string) => {\n const m = Math.max(0, Math.floor((now - new Date(iso).getTime()) / 60_000))\n return m < 1 ? 'now' : m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`\n }\n const clockTime = (t: number) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })\n\n // ---- Deploys ----------------------------------------------------------------\n let deploySeq = 0\n const minutesAgo = (m: number) => Date.now() - m * 60_000\n const liveDeploy = (d: Omit<Deploy, 'id' | 'kind' | 'state' | 'progress' | 'sha'>): Deploy => ({ id: ++deploySeq, kind: 'deploy', state: 'live', progress: 100, sha: shaOf(), ...d })\n let deploys = $state<Deploy[]>([\n liveDeploy({ service: 'search', version: 'v12.4.2', from: 'v12.4.1', author: 'sam', at: minutesAgo(38), message: 'Cache facet counts per shard' }),\n liveDeploy({ service: 'auth', version: 'v8.0.3', from: 'v8.0.2', author: 'owen', at: minutesAgo(95), message: 'Rotate signing keys without dropping sessions' }),\n liveDeploy({ service: 'catalog', version: 'v3.9.1', from: 'v3.9.0', author: 'grace', at: minutesAgo(160), message: 'Variant images served from the CDN' }),\n liveDeploy({ service: 'orders', version: 'v6.1.2', from: 'v6.1.1', author: 'priya', at: minutesAgo(240), message: 'Idempotency keys on order create' }),\n ])\n const bump = (v: string) => v.replace(/(\\d+)$/, (n) => String(Number(n) + 1))\n\n /** A deploy: three ticks of progress, then live. A bad one turns its service sour. */\n function ship(id: string, kind: Chaos['kind'] | undefined, author: string, message: string) {\n const s = services.find((x) => x.id === id)\n if (!s) return\n const pending = deploys.find((d) => d.service === id && d.state === 'deploying')\n if (pending) return\n deploys = [{ id: ++deploySeq, service: id, version: bump(s.version), from: s.version, author, at: Date.now(), sha: shaOf(), message, kind: 'deploy', state: 'deploying', progress: 8, chaos: kind }, ...deploys]\n }\n function advanceDeploys() {\n if (!deploys.some((d) => d.state === 'deploying')) return\n deploys = deploys.map((d) => {\n if (d.state !== 'deploying') return d\n const progress = Math.min(100, d.progress + 34)\n if (progress < 100) return { ...d, progress }\n services = services.map((x) => (x.id === d.service ? { ...x, version: d.version } : x))\n if (d.chaos) chaos.set(d.service, { kind: d.chaos, ticks: 40 })\n untrack(() => toast(`${d.service} ${d.version} is live`, { title: 'Deploy finished' }))\n return { ...d, progress: 100, state: 'live' as const, at: Date.now() }\n })\n }\n\n /** Only the newest live entry per service can be undone, and only a deploy. */\n const latestEntry = $derived(deploys.reduceRight((m, d) => m.set(d.service, d.id), new Map<string, number>()))\n const canRollback = (d: Deploy) => d.kind === 'deploy' && d.state === 'live' && latestEntry.get(d.service) === d.id\n function rollback(d: Deploy) {\n chaos.delete(d.service)\n services = services.map((x) => (x.id === d.service ? { ...x, version: d.from } : x))\n deploys = [\n { id: ++deploySeq, service: d.service, version: d.from, from: d.version, author: 'you', at: Date.now(), sha: d.sha, message: `Revert \"${d.message}\"`, kind: 'rollback', state: 'live', progress: 100 },\n ...deploys.map((x) => (x.id === d.id ? { ...x, rolledBack: true } : x)),\n ]\n const moved = incidents.filter((i) => i.service === d.service && (i.status === 'triage' || i.status === 'investigating'))\n for (const i of incidents.filter((x) => x.service === d.service && x.status !== 'resolved')) log(i.id, ME, `Rolled back ${d.service} to ${d.from}`, 'deploy')\n for (const i of moved) setStatus(i.id, 'mitigated', ME)\n toast.success(`${d.service} rolled back to ${d.from}${moved.length ? `; ${moved.map((i) => i.id).join(', ')} moved to Mitigated` : ''}`)\n }\n\n // ---- Incidents ----------------------------------------------------------------\n let incSeq = 1040\n const isoAgo = (m: number) => new Date(minutesAgo(m)).toISOString()\n const STATUS_LABEL: Record<IncStatus, string> = { triage: 'Triage', investigating: 'Investigating', mitigated: 'Mitigated', resolved: 'Resolved' }\n let incidents = $state<Incident[]>([\n { id: `INC-${++incSeq}`, title: 'search: slow facet queries in eu-west', service: 'search', severity: 'SEV3', status: 'investigating', commander: 'Sam Ortiz', opened: isoAgo(52), source: 'manual',\n timeline: [\n { at: minutesAgo(52), who: 'Ivy Okafor', text: 'Opened from a support ticket', kind: 'open' },\n { at: minutesAgo(49), who: 'Sam Ortiz', text: 'Took command', kind: 'ack' },\n { at: minutesAgo(31), who: 'Sam Ortiz', text: 'Status: Triage to Investigating', kind: 'status' },\n { at: minutesAgo(18), who: 'Sam Ortiz', text: 'Only shards 3 and 7 are slow; their cache hit rate dropped after the reindex', kind: 'note' },\n ] },\n { id: `INC-${++incSeq}`, title: 'analytics-ingest: consumer lag above 2 min', service: 'analytics-ingest', severity: 'SEV3', status: 'mitigated', commander: 'Mia Chen', opened: isoAgo(130), source: 'alert',\n timeline: [\n { at: minutesAgo(130), who: 'Alert rules', text: 'Opened: consumer lag above 2 min', kind: 'open' },\n { at: minutesAgo(128), who: 'Mia Chen', text: 'Acknowledged the page', kind: 'ack' },\n { at: minutesAgo(96), who: 'Mia Chen', text: 'Scaled consumers from 6 to 10; lag is draining', kind: 'note' },\n { at: minutesAgo(80), who: 'Mia Chen', text: 'Status: Investigating to Mitigated', kind: 'status' },\n ] },\n { id: `INC-${++incSeq}`, title: 'auth: token refresh 401s after rotation', service: 'auth', severity: 'SEV2', status: 'resolved', commander: 'Owen Bradley', opened: isoAgo(400), source: 'alert',\n timeline: [\n { at: minutesAgo(400), who: 'Alert rules', text: 'Opened: error rate over 5%', kind: 'open' },\n { at: minutesAgo(397), who: 'Owen Bradley', text: 'Acknowledged the page', kind: 'ack' },\n { at: minutesAgo(360), who: 'Owen Bradley', text: 'Status: Mitigated to Resolved', kind: 'status' },\n ] },\n ])\n const openIncidents = $derived(incidents.filter((i) => i.status !== 'resolved').length)\n const openSev1 = $derived(incidents.filter((i) => i.status !== 'resolved' && i.severity === 'SEV1').length)\n\n function log(id: string, who: string, text: string, kind: TimelineEntry['kind']) {\n incidents = incidents.map((i) => (i.id === id ? { ...i, timeline: [...i.timeline, { at: Date.now(), who, text, kind }] } : i))\n }\n function setStatus(id: string, status: IncStatus, who = ME) {\n const inc = incidents.find((i) => i.id === id)\n if (!inc || inc.status === status) return\n log(id, who, `Status: ${STATUS_LABEL[inc.status]} to ${STATUS_LABEL[status]}`, 'status')\n incidents = incidents.map((i) => (i.id === id ? { ...i, status } : i))\n }\n function setSeverity(id: string, severity: Severity, who = ME) {\n const inc = incidents.find((i) => i.id === id)\n if (!inc || inc.severity === severity) return\n log(id, who, `Severity: ${inc.severity} to ${severity}`, 'sev')\n incidents = incidents.map((i) => (i.id === id ? { ...i, severity } : i))\n }\n function setCommander(id: string, commander: string) {\n log(id, ME, `Commander: ${commander}`, 'ack')\n incidents = incidents.map((i) => (i.id === id ? { ...i, commander } : i))\n }\n /** Page a person; they acknowledge a few seconds later, as on-call people do. */\n function page(person: string, team: string, incId?: string) {\n const targets = incId ? [incId] : incidents.filter((i) => i.status !== 'resolved' && teamOf(i.service) === team).map((i) => i.id)\n for (const id of targets) log(id, 'Paging', `Paged ${person} (${team})`, 'page')\n toast(`Paged ${person}`, { title: `${team} on-call` })\n later(4000 + Math.round(rnd() * 3000), () => {\n for (const id of targets) log(id, person, 'Acknowledged the page', 'ack')\n toast.success(`${person} acknowledged`)\n })\n }\n\n function openIncidentFor(e: AlertEvent) {\n const svc = services.find((s) => s.id === e.rowId)\n if (!svc) return\n const errors = e.ruleId === RULE_ERRORS\n const severity: Severity = svc.tier === 'P0' ? (errors ? 'SEV1' : 'SEV2') : errors ? 'SEV2' : 'SEV3'\n const existing = incidents.find((i) => i.service === svc.id && i.status !== 'resolved')\n if (existing) {\n // Escalate an open incident rather than opening a second one.\n if (severity < existing.severity) {\n setSeverity(existing.id, severity, 'Alert rules')\n toast.warning(`${existing.id} escalated to ${severity}`)\n }\n return\n }\n const rota = ROTA[svc.team]\n const inc: Incident = {\n id: `INC-${++incSeq}`,\n title: errors ? `${svc.name}: error rate above ${ERROR_LIMIT}%` : `${svc.name}: p95 latency over the ${SLO_P95} ms SLO`,\n service: svc.id, severity, status: 'triage', commander: rota?.primary ?? 'Unassigned', opened: new Date().toISOString(), source: 'alert',\n timeline: [{ at: Date.now(), who: 'Alert rules', text: `Opened: ${e.ruleName}`, kind: 'open' }],\n }\n incidents = [inc, ...incidents]\n if (rota) page(rota.primary, svc.team, inc.id)\n }\n function newIncident() {\n const svc = services.find((s) => s.id === selectedId) ?? services[0]!\n const id = `INC-${++incSeq}`\n incidents = [{ id, title: `${svc.name}: reported by support`, service: svc.id, severity: 'SEV3', status: 'triage', commander: ME, opened: new Date().toISOString(), source: 'manual',\n timeline: [{ at: Date.now(), who: ME, text: 'Opened by hand', kind: 'open' }] }, ...incidents]\n drawerId = id\n }\n function onCardMove(e: BoardCardMoveEvent<Incident>) {\n setStatus(e.row.id, e.toLane as IncStatus)\n }\n\n // ---- Incident drawer --------------------------------------------------------\n let drawerId = $state<string | null>(null)\n const drawerInc = $derived(incidents.find((i) => i.id === drawerId) ?? null)\n const drawerSvc = $derived(drawerInc ? services.find((s) => s.id === drawerInc.service) : undefined)\n const drawerDeploy = $derived(drawerInc ? deploys.find((d) => d.service === drawerInc.service && d.kind === 'deploy') : undefined)\n let note = $state('')\n function addNote() {\n const text = note.trim()\n if (!text || !drawerInc) return\n log(drawerInc.id, ME, text, 'note')\n note = ''\n }\n\n // ---- Alert rules (Enterprise) ---------------------------------------------\n const RULE_P95 = 'ops-p95-slo'\n const RULE_ERRORS = 'ops-error-rate'\n const OUR_RULES = new Set([RULE_P95, RULE_ERRORS])\n const exprColumns: ExprColumn[] = [\n { id: 'name', name: 'Service', type: 'text' },\n { id: 'team', name: 'Team', type: 'text' },\n { id: 'tier', name: 'Tier', type: 'text' },\n { id: 'region', name: 'Region', type: 'text' },\n { id: 'rps', name: 'Requests / s', type: 'number' },\n { id: 'p95', name: 'p95 latency (ms)', type: 'number' },\n { id: 'errorPct', name: 'Error rate (%)', type: 'number' },\n { id: 'cpu', name: 'CPU (%)', type: 'number' },\n ]\n const seededRules: AlertRule[] = [\n {\n id: RULE_P95, name: `p95 over the ${SLO_P95} ms SLO`, enabled: true, severity: 'warning', scope: 'row',\n predicate: { kind: 'cmp', column: 'p95', op: 'greaterThan', value: SLO_P95 },\n trigger: { type: 'dataChange' },\n actions: [{ kind: 'toast', message: '{name}: p95 is {value} ms' }, { kind: 'highlight', style: { background: '#fef3c7', color: '#92400e' } }],\n createdAt: 0,\n },\n {\n id: RULE_ERRORS, name: `Error rate over ${ERROR_LIMIT}%`, enabled: true, severity: 'error', scope: 'row',\n predicate: { kind: 'cmp', column: 'errorPct', op: 'greaterThan', value: ERROR_LIMIT },\n trigger: { type: 'dataChange' },\n actions: [{ kind: 'toast', message: '{name}: error rate {value}%' }, { kind: 'highlight', style: { background: '#fee2e2', color: '#991b1b' } }],\n createdAt: 0,\n },\n ]\n let alertFormats = $state<ConditionalFormat<Service>[]>([])\n\n // The alert log is shared across the page; only events from this\n // workspace's rules count, and only the ones fired after it mounted.\n const keyOf = (e: AlertEvent) => `${e.ruleId}|${e.rowId ?? ''}|${e.firedAt}`\n const mountedAt = Date.now()\n const seen = new Set(alertStore.events.map(keyOf))\n const feed = $derived(alertStore.events.filter((e) => OUR_RULES.has(e.ruleId) && e.firedAt >= mountedAt))\n const unacked = $derived(feed.filter((e) => !e.acknowledged).length)\n /** Acknowledge this workspace's alerts only; the log is shared with the page. */\n function ackAll() {\n for (const e of feed) {\n if (!e.acknowledged) alertStore.acknowledge(alertStore.events.indexOf(e))\n }\n }\n const incidentForService = (id: string | undefined) => incidents.find((i) => i.service === id && i.status !== 'resolved')\n $effect(() => {\n const events = alertStore.events\n untrack(() => {\n for (const e of [...events].reverse()) {\n const k = keyOf(e)\n if (seen.has(k)) continue\n seen.add(k)\n if (OUR_RULES.has(e.ruleId) && e.rowId) openIncidentFor(e)\n }\n })\n })\n\n // ---- Selection + charts -------------------------------------------------------\n let selectedId = $state('checkout-api')\n const selected = $derived(services.find((s) => s.id === selectedId) ?? services[0]!)\n const latencySpec = $derived.by<ChartSpec>(() => {\n void histVersion\n return {\n type: 'line',\n categories: clock,\n xAxis: { labelRotation: 0 },\n series: [\n { label: 'p99', values: [...(p99Hist.get(selected.id) ?? [])], color: '#93c5fd', marker: 'none', smooth: 'monotone' },\n { label: 'p95', values: selected.p95History, color: '#2563eb', type: 'area', marker: 'none', smooth: 'monotone' },\n { label: 'p50', values: [...(p50Hist.get(selected.id) ?? [])], color: '#64748b', marker: 'none', smooth: 'monotone' },\n ],\n referenceLines: [{ value: SLO_P95, label: `SLO ${SLO_P95} ms`, dashed: true, color: '#dc2626' }],\n yAxisTitle: 'ms',\n }\n })\n const errorSpec = $derived.by<ChartSpec>(() => {\n void histVersion\n return {\n type: 'area',\n categories: clock,\n xAxis: { labelRotation: 0 },\n series: [{ label: 'Errors (%)', values: [...(errHistory.get(selected.id) ?? [])], color: '#d97706', marker: 'none', smooth: 'monotone' }],\n referenceLines: [{ value: ERROR_LIMIT, label: `Limit ${ERROR_LIMIT}%`, dashed: true, color: '#dc2626' }],\n yAxisTitle: '%',\n }\n })\n\n // ---- KPIs ---------------------------------------------------------------------\n const healthy = $derived(services.filter((s) => s.status === 'healthy').length)\n const fleetP95 = $derived.by(() => {\n const sorted = services.map((s) => s.p95).sort((a, b) => a - b)\n return sorted[Math.floor(sorted.length / 2)] ?? 0\n })\n const totalRps = $derived(services.reduce((n, s) => n + s.rps, 0))\n const fleetErr = $derived(round(services.reduce((n, s) => n + s.errorPct * s.rps, 0) / Math.max(1, totalRps), 2))\n\n // ---- Grids --------------------------------------------------------------------\n let servicesApi = $state<EnterpriseGridApi<any, Service> | null>(null)\n let incidentsApi = $state<EnterpriseGridApi<any, Incident> | null>(null)\n let incidentView = $state<'board' | 'table'>('board')\n\n const serviceColumns: ColumnDef<any, Service>[] = [\n { id: 'name', field: 'name', header: 'Service', width: 190, cell: (ctx) => renderSnippet(ServiceCell, { row: ctx.row.original }) },\n { id: 'status', field: 'status', header: 'Status', width: 104, cell: (ctx) => renderSnippet(StatusCell, { row: ctx.row.original }) },\n { id: 'tier', field: 'tier', header: 'Tier', width: 64 },\n { id: 'p95', field: 'p95', header: 'p95 ms', width: 88, format: { type: 'number' } },\n { id: 'trend', field: 'p95History', header: 'p95, last 60 s', width: 130, sparkline: { type: 'area', color: '#2563eb', lineWidth: 1.5 } },\n { id: 'errorPct', field: 'errorPct', header: 'Errors', width: 84, cell: (ctx) => renderSnippet(ErrorCell, { row: ctx.row.original }) },\n { id: 'rps', field: 'rps', header: 'Req/s', width: 88, format: { type: 'number' } },\n { id: 'cpu', field: 'cpu', header: 'CPU %', width: 76, format: { type: 'number' } },\n { id: 'region', field: 'region', header: 'Region', width: 92 },\n { id: 'version', field: 'version', header: 'Version', width: 96 },\n ]\n const incidentColumns: ColumnDef<any, Incident>[] = [\n { field: 'id', header: 'ID', width: 96, editable: false },\n { field: 'title', header: 'Title', width: 280, editorType: 'text' },\n { field: 'severity', header: 'Sev', width: 80, editorType: 'list', editorOptions: ['SEV1', 'SEV2', 'SEV3'] },\n { field: 'status', header: 'Status', width: 130, editorType: 'list', editorOptions: ['triage', 'investigating', 'mitigated', 'resolved'] },\n { field: 'commander', header: 'Commander', width: 140, editorType: 'text' },\n { field: 'service', header: 'Service', width: 140, editable: false },\n { field: 'opened', header: 'Opened', width: 150, editable: false, format: { type: 'datetime', pattern: 'short' } },\n ]\n const lanes = [\n { id: 'triage', title: 'Triage', color: '#dc2626' },\n { id: 'investigating', title: 'Investigating', color: '#d97706' },\n { id: 'mitigated', title: 'Mitigated', color: '#2563eb' },\n { id: 'resolved', title: 'Resolved', color: '#16a34a' },\n ]\n\n // ---- Export (Enterprise) ------------------------------------------------------\n const SERVICE_EXPORT: ExportColumn<Service>[] = [\n { field: 'name', header: 'Service' }, { field: 'team', header: 'Team' }, { field: 'tier', header: 'Tier' },\n { field: 'status', header: 'Status' }, { field: 'p95', header: 'p95 (ms)' }, { field: 'errorPct', header: 'Error rate (%)' },\n { field: 'rps', header: 'Requests / s' }, { field: 'cpu', header: 'CPU (%)' }, { field: 'region', header: 'Region' }, { field: 'version', header: 'Version' },\n ]\n const INCIDENT_EXPORT: ExportColumn<Incident>[] = [\n { field: 'id', header: 'ID' }, { field: 'title', header: 'Title' }, { field: 'severity', header: 'Severity' }, { field: 'status', header: 'Status' },\n { field: 'commander', header: 'Commander' }, { field: 'service', header: 'Service' },\n { field: 'opened', header: 'Opened', format: { type: 'datetime', pattern: 'short' } }, { field: 'source', header: 'Source' },\n ]\n const stamp = () => {\n const d = new Date()\n const pad = (n: number) => String(n).padStart(2, '0')\n return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`\n }\n async function run(label: string, job: () => Promise<unknown> | undefined) {\n try {\n await job()\n toast.success(`Exported ${label}`)\n } catch (err) {\n toast.error(`Export failed: ${err instanceof Error ? err.message : String(err)}`)\n }\n }\n const exportServices = (format: ExportFormat) =>\n run(`services to ${format.toUpperCase()}`, () => servicesApi?.exportData({ format, filename: `services-${stamp()}`, columns: SERVICE_EXPORT, rows: 'all' }))\n const exportIncidents = (format: ExportFormat) =>\n run(`incidents to ${format.toUpperCase()}`, () => incidentsApi?.exportData({ format, filename: `incidents-${stamp()}`, columns: INCIDENT_EXPORT, rows: incidents }))\n const exportItems: MenuItem[] = [\n { label: 'Services snapshot to Excel', onSelect: () => exportServices('xlsx') },\n { label: 'Services snapshot to PDF', onSelect: () => exportServices('pdf') },\n { separator: true },\n { label: 'Incident log to Excel', onSelect: () => exportIncidents('xlsx') },\n { label: 'Incident log to CSV', onSelect: () => exportIncidents('csv') },\n ]\n const injectItems = $derived<MenuItem[]>([\n { label: `Bad deploy on ${selected.name}: latency`, onSelect: () => ship(selected.id, 'latency', 'you', 'Switch the connection pool to lazy init') },\n { label: `Bad deploy on ${selected.name}: errors`, onSelect: () => ship(selected.id, 'errors', 'you', 'Tighten request validation') },\n { separator: true },\n { label: `Clean deploy on ${selected.name}`, onSelect: () => ship(selected.id, undefined, 'you', 'Bump dependencies') },\n ])\n\n // ---- Dock layout ----------------------------------------------------------------\n // The board sits under the service grid, in the wide column, so all four\n // lanes fit; the charts and logs are narrow by nature.\n const LAYOUT_KEY = 'svgrid-demo-ops-layout-v3'\n const defaultLayout = (): DockManagerState => ({\n main: dockGroup('row', [\n dockGroup('column', [\n dockTabs([dockPane('services', 'Services', { closable: false, minSize: 180 })]),\n dockTabs([dockPane('incidents', 'Incidents', { minSize: 200 })]),\n ], [0.5, 0.5]),\n dockGroup('column', [\n dockTabs([dockPane('latency', 'Latency'), dockPane('errors', 'Errors')]),\n dockTabs([dockPane('deploys', 'Deploys'), dockPane('oncall', 'On-call'), dockPane('feed', 'Alert feed')]),\n ], [0.5, 0.5]),\n ], [0.64, 0.36]),\n floating: [],\n autoHide: [],\n })\n function loadLayout(): DockManagerState {\n try {\n const s = localStorage.getItem(LAYOUT_KEY)\n if (s) return JSON.parse(s) as DockManagerState\n } catch {\n /* storage blocked or malformed: fall back to the default */\n }\n return defaultLayout()\n }\n let workspace = $state<DockManagerState>(loadLayout())\n function saveLayout() {\n try {\n localStorage.setItem(LAYOUT_KEY, JSON.stringify(workspace))\n toast.success('Layout saved; it comes back on reload')\n } catch {\n toast.error('Could not save: storage is blocked in this browser')\n }\n }\n function resetLayout() {\n workspace = defaultLayout()\n try { localStorage.removeItem(LAYOUT_KEY) } catch { /* ignore */ }\n }\n\n function onServicesReady(a: SvGridApi<any, Service>) { servicesApi = installEnterprise(a) }\n function onIncidentsReady(a: SvGridApi<any, Incident>) { incidentsApi = installEnterprise(a) }\n\n // Status and severity colours carry meaning, so they stay literal.\n const STATUS_COLOR: Record<Status, string> = { healthy: '#16a34a', degraded: '#d97706', down: '#dc2626' }\n const SEV_COLOR: Record<Severity, string> = { SEV1: '#dc2626', SEV2: '#d97706', SEV3: '#64748b' }\n const SEVERITY_COLOR: Record<string, string> = { error: '#dc2626', warning: '#d97706', info: '#2563eb', success: '#16a34a' }\n const LANE_COLOR: Record<IncStatus, string> = { triage: '#dc2626', investigating: '#d97706', mitigated: '#2563eb', resolved: '#16a34a' }\n const TL_ICON: Record<TimelineEntry['kind'], string> = {\n open: 'M12 8v4 M12 16h.01 M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z',\n status: 'M5 12h14 M13 6l6 6-6 6',\n sev: 'M12 3 2 20h20z M12 10v4 M12 17h.01',\n page: 'M6 16v-5a6 6 0 0 1 12 0v5l2 2H4z M10 21h4',\n ack: 'M5 13l4 4L19 7',\n note: 'M4 5h16v11H8l-4 4z',\n deploy: 'M9 14 4 9l5-5 M4 9h10a6 6 0 0 1 0 12h-3',\n }\n const SEVERITIES: Severity[] = ['SEV1', 'SEV2', 'SEV3']\n const STATUSES: IncStatus[] = ['triage', 'investigating', 'mitigated', 'resolved']\n const ROTA_ROWS = Object.entries(ROTA)\n</script>\n\n{#snippet ico(d: string, size = 15)}\n <svg viewBox=\"0 0 24 24\" width={size} height={size} fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path {d} /></svg>\n{/snippet}\n\n{#snippet ServiceCell(props: { row: Service })}\n <span class=\"ops-svc\">\n <span class=\"ops-dot\" style:background={STATUS_COLOR[props.row.status]}></span>\n <span class=\"ops-svc-txt\"><strong>{props.row.name}</strong><span>{props.row.team}</span></span>\n </span>\n{/snippet}\n{#snippet StatusCell(props: { row: Service })}\n <span class=\"ops-pill\" style:--c={STATUS_COLOR[props.row.status]}>{props.row.status}</span>\n{/snippet}\n{#snippet ErrorCell(props: { row: Service })}\n <span class=\"ops-num\" class:is-bad={props.row.errorPct > ERROR_LIMIT} class:is-warn={props.row.errorPct > 2 && props.row.errorPct <= ERROR_LIMIT}>{props.row.errorPct.toFixed(2)}%</span>\n{/snippet}\n{#snippet incidentCard(i: Incident)}\n <!-- svelte-ignore a11y_click_events_have_key_events -->\n <!-- svelte-ignore a11y_no_static_element_interactions -->\n <div class=\"ops-card\" onclick={() => (drawerId = i.id)} title=\"Open {i.id}\">\n <div class=\"ops-card-top\">\n <span class=\"ops-sev\" style:--c={SEV_COLOR[i.severity]}>{i.severity}</span>\n <span class=\"ops-card-id\">{i.id}</span>\n <span class=\"ops-card-age\">{age(i.opened)}</span>\n </div>\n <div class=\"ops-card-title\">{i.title}</div>\n <div class=\"ops-card-foot\">\n <SvAvatar name={i.commander} size={18} />\n <span class=\"ops-card-who\">{i.commander}</span>\n {#if i.source === 'alert'}<span class=\"ops-card-src\" title=\"Opened by an alert rule\" aria-label=\"Opened by an alert rule\">{@render ico('M6 16v-5a6 6 0 0 1 12 0v5l2 2H4z M10 21h4', 12)}</span>{/if}\n </div>\n </div>\n{/snippet}\n\n<div class=\"ops\">\n <header class=\"ops-head\">\n <div class=\"ops-title\">\n <span class=\"ops-logo\">{@render ico('M3 12h4l3-8 4 16 3-8h4', 18)}</span>\n <div>\n <h2>Ops center <span class=\"ops-live\" class:is-paused={paused}>{paused ? 'Paused' : 'Live'}</span></h2>\n <p><span class=\"ops-env\">production</span> us-east-1 · {services.length} services · <span class=\"ops-utc\">{utc} UTC</span></p>\n </div>\n </div>\n <div class=\"ops-tools\">\n <button type=\"button\" class=\"ops-btn\" onclick={() => (paused = !paused)}>{@render ico(paused ? 'M8 5v14l11-7z' : 'M7 5h3v14H7z M14 5h3v14h-3z')}{paused ? 'Resume' : 'Pause'}</button>\n <SvMenu items={injectItems}>\n {#snippet anchor()}<button type=\"button\" class=\"ops-btn\">{@render ico('M12 19V5 M5 12l7-7 7 7')}Deploy</button>{/snippet}\n </SvMenu>\n <SvMenu items={exportItems}>\n {#snippet anchor()}<button type=\"button\" class=\"ops-btn\">{@render ico('M12 3v12 M8 11l4 4 4-4 M4 17v3h16v-3')}Export</button>{/snippet}\n </SvMenu>\n <button type=\"button\" class=\"ops-btn\" onclick={saveLayout}>Save layout</button>\n <button type=\"button\" class=\"ops-btn\" onclick={resetLayout}>Reset</button>\n <SvGridAlerts data={services} columns={exprColumns} getRowId={(s) => s.id} rules={seededRules} storageKey=\"svgrid-demo:ops-alerts\" bind:formats={alertFormats} toastCooldownMs={8000} onJump={(e) => { if (e.rowId) selectedId = e.rowId }} />\n </div>\n </header>\n\n <div class=\"ops-kpis\">\n <div class=\"ops-kpi\"><span>Healthy</span><strong class:is-warn={healthy < services.length}>{healthy}<small> / {services.length}</small></strong></div>\n <div class=\"ops-kpi\"><span>Median p95</span><strong>{fleetP95}<small> ms</small></strong></div>\n <div class=\"ops-kpi\"><span>Error rate</span><strong class:is-bad={fleetErr > 2}>{fleetErr.toFixed(2)}<small>%</small></strong></div>\n <div class=\"ops-kpi\"><span>Traffic</span><strong>{Math.round(totalRps / 100) / 10}<small>k req/s</small></strong></div>\n <div class=\"ops-kpi\"><span>Open incidents</span><strong class:is-bad={openSev1 > 0}>{openIncidents}{#if openSev1}<small> ({openSev1} SEV1)</small>{/if}</strong></div>\n <div class=\"ops-kpi\"><span>Alerts fired</span><strong>{feed.length}{#if unacked}<small> ({unacked} new)</small>{/if}</strong></div>\n </div>\n\n <div class=\"ops-stage\">\n <SvDockManager bind:workspace minSize={120} keepAlive>\n {#snippet pane(p)}\n {#if p.id === 'services'}\n <div class=\"ops-pane\">\n <SvGrid\n data={services}\n columns={serviceColumns}\n getRowId={(s) => s.id}\n conditionalFormats={alertFormats}\n sortable\n selectionMode=\"none\"\n enableRowSummaries={false}\n rowHeight={44}\n containerHeight=\"100%\"\n initialColumnPinning={{ left: ['name'] }}\n columnVirtualization={false}\n rowClass={({ row }) => ({ 'ops-row-sel': row.id === selectedId })}\n onRowClick={(e) => (selectedId = e.row.id)}\n onApiReady={onServicesReady}\n />\n </div>\n {:else if p.id === 'latency' || p.id === 'errors'}\n {@const inc = incidentForService(selected.id)}\n <div class=\"ops-pane ops-chart\">\n <div class=\"ops-chart-head\">\n <strong>{selected.name}</strong>\n <span class=\"ops-pill\" style:--c={STATUS_COLOR[selected.status]}>{selected.status}</span>\n <span class=\"ops-muted\">{p.id === 'latency' ? `p95 ${selected.p95} ms` : `errors ${selected.errorPct.toFixed(2)}%`} · {selected.version} · on call {ROTA[selected.team]?.primary}</span>\n {#if inc}<button type=\"button\" class=\"ops-link\" onclick={() => (drawerId = inc.id)}>{inc.id}</button>{/if}\n </div>\n <div class=\"ops-chart-body\">\n <SvChart spec={p.id === 'latency' ? latencySpec : errorSpec} legend={p.id === 'latency'} autosize animate={false} />\n </div>\n </div>\n {:else if p.id === 'incidents'}\n <div class=\"ops-pane ops-inc\">\n <div class=\"ops-inc-bar\">\n <div class=\"ops-seg\" role=\"group\" aria-label=\"Incident view\">\n <button type=\"button\" class:is-on={incidentView === 'board'} aria-pressed={incidentView === 'board'} onclick={() => (incidentView = 'board')}>Board</button>\n <button type=\"button\" class:is-on={incidentView === 'table'} aria-pressed={incidentView === 'table'} onclick={() => (incidentView = 'table')}>Table</button>\n </div>\n <span class=\"ops-muted\">The same grid, two views. {openIncidents} open; click one for its timeline.</span>\n <button type=\"button\" class=\"ops-btn ops-btn-sm\" onclick={newIncident}>{@render ico('M12 5v14 M5 12h14', 13)}New</button>\n </div>\n <div class=\"ops-inc-grid\">\n {#if incidentView === 'board'}\n <SvGrid\n data={incidents}\n columns={incidentColumns}\n getRowId={(i) => i.id}\n containerHeight=\"100%\"\n onApiReady={onIncidentsReady}\n board={{ groupBy: 'status', lanes, card: incidentCard, onCardMove, searchable: false, laneSummary: (list) => `${list.length}` }}\n />\n {:else}\n <SvGrid\n data={incidents}\n columns={incidentColumns}\n getRowId={(i) => i.id}\n sortable\n filterable\n enableInlineEditing\n selectionMode=\"none\"\n enableRowSummaries={false}\n rowHeight={36}\n containerHeight=\"100%\"\n onApiReady={onIncidentsReady}\n onRowDoubleClick={(e) => (drawerId = e.row.id)}\n onCellValueChange={() => (incidents = [...incidents])}\n />\n {/if}\n </div>\n </div>\n {:else if p.id === 'deploys'}\n <div class=\"ops-pane ops-list\">\n {#each deploys as d (d.id)}\n <div class=\"ops-dep\">\n <span class=\"ops-dep-ic\" class:is-rb={d.kind === 'rollback'}>{@render ico(d.kind === 'rollback' ? 'M9 14 4 9l5-5 M4 9h10a6 6 0 0 1 0 12h-3' : 'M12 19V5 M5 12l7-7 7 7', 13)}</span>\n <div class=\"ops-dep-txt\">\n <span><strong>{d.service}</strong> {d.from} → {d.version}{#if d.rolledBack}<span class=\"ops-tag\">rolled back</span>{/if}</span>\n <span class=\"ops-dep-msg\">{d.message}</span>\n <span class=\"ops-muted\"><code>{d.sha}</code> · {d.kind === 'rollback' ? `rollback by ${d.author}` : `by ${d.author}`} · {d.state === 'deploying' ? 'deploying' : ago(d.at)}</span>\n {#if d.state === 'deploying'}<span class=\"ops-progress\" role=\"progressbar\" aria-valuenow={d.progress} aria-valuemin={0} aria-valuemax={100} aria-label=\"Deploy progress\"><span style:width=\"{d.progress}%\"></span></span>{/if}\n </div>\n {#if canRollback(d)}\n <button type=\"button\" class=\"ops-btn ops-btn-sm\" onclick={() => rollback(d)}>Roll back</button>\n {/if}\n </div>\n {/each}\n </div>\n {:else if p.id === 'oncall'}\n <div class=\"ops-pane ops-list\">\n {#each ROTA_ROWS as [team, r] (team)}\n {@const open = incidents.filter((i) => i.status !== 'resolved' && teamOf(i.service) === team).length}\n <div class=\"ops-dep\">\n <SvAvatar name={r.primary} size={30} status=\"online\" />\n <div class=\"ops-dep-txt\">\n <span><strong>{r.primary}</strong> <span class=\"ops-muted\">· {team}</span>{#if open}<span class=\"ops-tag ops-tag-bad\">{open} open</span>{/if}</span>\n <span class=\"ops-muted\">Primary until {r.until}:00 · secondary {r.secondary}</span>\n </div>\n <button type=\"button\" class=\"ops-btn ops-btn-sm\" onclick={() => page(r.primary, team)}>Page</button>\n </div>\n {/each}\n </div>\n {:else if p.id === 'feed'}\n <div class=\"ops-pane ops-list\">\n {#if feed.length}\n <div class=\"ops-list-head\">\n <span class=\"ops-muted\">{unacked} unacknowledged of {feed.length}</span>\n <button type=\"button\" class=\"ops-btn ops-btn-sm\" disabled={unacked === 0} onclick={ackAll}>Acknowledge all</button>\n </div>\n {/if}\n {#if feed.length === 0}\n <div class=\"ops-empty\">No alerts yet. The scripted deploy trips one a few seconds after load, or ship a bad one from Deploy.</div>\n {/if}\n {#each feed as e (keyOf(e))}\n {@const inc = incidentForService(e.rowId)}\n <div class=\"ops-alert\" class:is-ack={e.acknowledged}>\n <span class=\"ops-dot\" style:background={SEVERITY_COLOR[e.severity] ?? '#64748b'}></span>\n <div class=\"ops-dep-txt\">\n <span>{e.message}</span>\n <span class=\"ops-muted\">{e.ruleName} · {clockTime(e.firedAt)}{#if inc} · <button type=\"button\" class=\"ops-link\" onclick={() => (drawerId = inc.id)}>{inc.id}</button>{/if}</span>\n </div>\n {#if !e.acknowledged}\n <button type=\"button\" class=\"ops-btn ops-btn-sm\" onclick={() => alertStore.acknowledge(alertStore.events.indexOf(e))}>Ack</button>\n {/if}\n </div>\n {/each}\n </div>\n {/if}\n {/snippet}\n </SvDockManager>\n </div>\n</div>\n\n<SvDrawer open={drawerInc != null} onClose={() => (drawerId = null)} side=\"right\" size=\"440px\" title={drawerInc ? `${drawerInc.id} · ${drawerInc.title}` : ''}>\n {#if drawerInc}\n {@const inc = drawerInc}\n <div class=\"ops-dr\">\n <div class=\"ops-dr-row\">\n <span class=\"ops-dr-label\">Severity</span>\n <div class=\"ops-seg\" role=\"group\" aria-label=\"Severity\">\n {#each SEVERITIES as s (s)}\n <button type=\"button\" class:is-on={inc.severity === s} style:--c={SEV_COLOR[s]} aria-pressed={inc.severity === s} onclick={() => setSeverity(inc.id, s)}>{s}</button>\n {/each}\n </div>\n </div>\n <div class=\"ops-dr-row\">\n <span class=\"ops-dr-label\">Status</span>\n <div class=\"ops-seg\" role=\"group\" aria-label=\"Status\">\n {#each STATUSES as s (s)}\n <button type=\"button\" class:is-on={inc.status === s} aria-pressed={inc.status === s} onclick={() => setStatus(inc.id, s)}>{STATUS_LABEL[s]}</button>\n {/each}\n </div>\n </div>\n <div class=\"ops-dr-row\">\n <span class=\"ops-dr-label\">Commander</span>\n <select class=\"ops-select\" value={inc.commander} onchange={(e) => setCommander(inc.id, e.currentTarget.value)} aria-label=\"Commander\">\n {#each PEOPLE as name (name)}<option value={name}>{name}</option>{/each}\n </select>\n <button type=\"button\" class=\"ops-btn ops-btn-sm\" onclick={() => page(inc.commander, teamOf(inc.service), inc.id)}>Page</button>\n </div>\n\n {#if drawerSvc}\n <div class=\"ops-dr-card\">\n <div class=\"ops-dr-card-head\">\n <span class=\"ops-dot\" style:background={STATUS_COLOR[drawerSvc.status]}></span>\n <strong>{drawerSvc.name}</strong>\n <span class=\"ops-pill\" style:--c={STATUS_COLOR[drawerSvc.status]}>{drawerSvc.status}</span>\n <button type=\"button\" class=\"ops-link\" onclick={() => (selectedId = drawerSvc.id)}>Show in charts</button>\n </div>\n <div class=\"ops-dr-stats\">\n <span><small>p95</small>{drawerSvc.p95} ms</span>\n <span><small>Errors</small>{drawerSvc.errorPct.toFixed(2)}%</span>\n <span><small>Traffic</small>{drawerSvc.rps.toLocaleString()} req/s</span>\n <span><small>Version</small>{drawerSvc.version}</span>\n </div>\n {#if drawerDeploy}\n <div class=\"ops-dr-deploy\">\n <span class=\"ops-muted\">Last deploy {drawerDeploy.from} → {drawerDeploy.version} by {drawerDeploy.author}, {drawerDeploy.state === 'deploying' ? 'in progress' : ago(drawerDeploy.at)}{#if drawerDeploy.rolledBack} (rolled back){/if}</span>\n {#if canRollback(drawerDeploy)}<button type=\"button\" class=\"ops-btn ops-btn-sm\" onclick={() => drawerDeploy && rollback(drawerDeploy)}>Roll back</button>{/if}\n </div>\n {/if}\n </div>\n {/if}\n\n <h3 class=\"ops-dr-h\">Timeline</h3>\n <ol class=\"ops-tl\">\n {#each [...inc.timeline].reverse() as t, i (t.at + t.text + i)}\n <li class=\"ops-tl-item\" data-kind={t.kind}>\n <span class=\"ops-tl-ic\">{@render ico(TL_ICON[t.kind], 13)}</span>\n <div class=\"ops-tl-txt\">\n <span>{#if t.kind === 'note'}<strong>{t.who}</strong> {t.text}{:else}{t.text}{#if t.who !== 'Alert rules' && t.who !== 'Paging' && !t.text.includes(t.who)}{' '}<span class=\"ops-muted\">by {t.who}</span>{/if}{/if}</span>\n <span class=\"ops-muted\">{clockTime(t.at)} · {ago(t.at)}</span>\n </div>\n </li>\n {/each}\n </ol>\n <form class=\"ops-note\" onsubmit={(e) => { e.preventDefault(); addNote() }}>\n <textarea bind:value={note} rows=\"2\" placeholder=\"Add a note to the timeline\" aria-label=\"Note\" onkeydown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); addNote() } }}></textarea>\n <button type=\"submit\" class=\"ops-btn ops-btn-sm\" disabled={!note.trim()}>Add note</button>\n </form>\n </div>\n {/if}\n {#snippet footer()}\n {#if drawerInc}\n {@const inc = drawerInc}\n <div class=\"ops-dr-foot\">\n <span class=\"ops-muted\">Opened {age(inc.opened) === 'now' ? 'just now' : `${age(inc.opened)} ago`} {inc.source === 'alert' ? 'by an alert rule' : 'by hand'}</span>\n {#if inc.status === 'resolved'}\n <button type=\"button\" class=\"ops-btn\" onclick={() => setStatus(inc.id, 'investigating')}>Reopen</button>\n {:else}\n <button type=\"button\" class=\"ops-btn ops-btn-primary\" style:--c={LANE_COLOR.resolved} onclick={() => { setStatus(inc.id, 'resolved'); toast.success(`${inc.id} resolved`) }}>Resolve</button>\n {/if}\n </div>\n {/if}\n {/snippet}\n</SvDrawer>\n\n<SvToaster position=\"bottom-right\" />\n\n<style>\n .ops {\n display: flex;\n flex-direction: column;\n flex: 1 1 auto;\n min-height: 760px;\n height: 100%;\n border: 1px solid var(--sg-border, #e5e7eb);\n border-radius: 12px;\n overflow: hidden;\n background: var(--sg-bg, #fff);\n color: var(--sg-fg, #0f172a);\n font-size: 13px;\n }\n .ops-muted { color: var(--sg-muted, #64748b); }\n\n .ops-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 12px 16px; border-bottom: 1px solid var(--sg-border, #e5e7eb); flex: none; }\n .ops-title { display: flex; align-items: center; gap: 12px; min-width: 0; }\n .ops-logo { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 8px; background: var(--sg-fg, #0f172a); color: var(--sg-bg, #fff); flex: none; }\n .ops-title h2 { margin: 0; font-size: 16px; font-weight: 650; display: flex; align-items: center; gap: 8px; }\n .ops-title p { margin: 2px 0 0; font-size: 12px; color: var(--sg-muted, #64748b); display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }\n /* Production red-ish pill is a warning label, like every deploy tool shows. */\n .ops-env { font-size: 10.5px; font-weight: 700; padding: 0 6px; border-radius: 4px; color: #b91c1c; background: color-mix(in srgb, #dc2626 12%, transparent); }\n .ops-utc { font-variant-numeric: tabular-nums; }\n /* Live / paused are status, so green and amber stay literal. */\n .ops-live { font-size: 10.5px; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase; color: #15803d; background: color-mix(in srgb, #16a34a 14%, transparent); padding: 2px 8px; border-radius: 999px; }\n .ops-live.is-paused { color: #b45309; background: color-mix(in srgb, #d97706 14%, transparent); }\n .ops-tools { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }\n .ops-btn {\n display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 11px;\n border: 1px solid var(--sg-border, #e5e7eb); border-radius: 7px; background: var(--sg-bg, #fff);\n color: var(--sg-fg, #0f172a); font: inherit; font-size: 12.5px; font-weight: 500; cursor: pointer; white-space: nowrap;\n }\n .ops-btn:hover { background: var(--sg-row-hover-bg, #f1f5f9); }\n .ops-btn:focus-visible { outline: 2px solid var(--sg-accent, #2563eb); outline-offset: 1px; }\n .ops-btn:disabled { opacity: 0.45; cursor: default; }\n .ops-btn-sm { height: 26px; padding: 0 8px; font-size: 12px; }\n .ops-btn-primary { background: var(--c); border-color: var(--c); color: #fff; }\n .ops-btn-primary:hover { background: color-mix(in srgb, var(--c) 88%, #000); }\n .ops-link { border: 0; background: none; padding: 0; font: inherit; font-size: 12px; font-weight: 600; color: var(--sg-accent, #2563eb); cursor: pointer; }\n .ops-link:hover { text-decoration: underline; }\n\n .ops-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); border-bottom: 1px solid var(--sg-border, #e5e7eb); flex: none; }\n .ops-kpi { display: flex; flex-direction: column; gap: 2px; padding: 9px 16px; border-right: 1px solid var(--sg-border, #e5e7eb); min-width: 0; }\n .ops-kpi:last-child { border-right: 0; }\n .ops-kpi span { font-size: 11px; color: var(--sg-muted, #64748b); white-space: nowrap; }\n .ops-kpi strong { font-size: 19px; font-weight: 650; font-variant-numeric: tabular-nums; line-height: 1.15; white-space: nowrap; }\n .ops-kpi small { font-size: 12px; font-weight: 500; color: var(--sg-muted, #64748b); }\n .ops-kpi strong.is-bad, .ops :global(.ops-num.is-bad) { color: #dc2626; }\n .ops-kpi strong.is-warn, .ops :global(.ops-num.is-warn) { color: #b45309; }\n .ops-kpi strong.is-bad small, .ops-kpi strong.is-warn small { color: inherit; opacity: 0.8; }\n\n .ops-stage { flex: 1 1 0; min-height: 0; padding: 8px; display: flex; flex-direction: column; }\n .ops-stage > :global(*) { flex: 1 1 0; min-height: 0; }\n .ops-pane { height: 100%; min-height: 0; display: flex; flex-direction: column; box-sizing: border-box; }\n .ops-pane > :global(.sv-grid-root-fill), .ops-pane > :global(*:only-child) { flex: 1 1 0; min-height: 0; }\n .ops-pane :global(.sv-grid-row) { cursor: pointer; }\n\n /* Service grid cells */\n .ops :global(.ops-svc) { display: inline-flex; align-items: center; gap: 9px; min-width: 0; }\n .ops :global(.ops-dot), .ops-dr :global(.ops-dot) { width: 8px; height: 8px; border-radius: 999px; flex: none; display: inline-block; }\n .ops :global(.ops-svc-txt) { display: flex; flex-direction: column; line-height: 1.2; min-width: 0; }\n .ops :global(.ops-svc-txt strong) { font-weight: 600; overflow: hidden; text-overflow: ellipsis; }\n .ops :global(.ops-svc-txt span) { font-size: 11.5px; color: var(--sg-muted, #64748b); }\n .ops :global(.ops-pill), .ops-dr .ops-pill { display: inline-block; font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 999px; color: var(--c); background: color-mix(in srgb, var(--c) 14%, transparent); text-transform: capitalize; }\n .ops :global(.ops-num) { font-variant-numeric: tabular-nums; }\n .ops :global(.ops-row-sel > td) { background: var(--sg-selection-bg, #eff6ff); }\n\n /* Charts */\n .ops-chart-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px 0; flex: none; flex-wrap: wrap; }\n .ops-chart-head .ops-muted { font-size: 12px; }\n .ops-chart-body { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; padding: 4px 8px 8px; }\n .ops-chart-body > :global(*) { flex: 1 1 0; min-height: 0; }\n\n /* Incidents */\n .ops-inc { --sg-board-lane-w: 158px; }\n .ops-inc-bar { display: flex; align-items: center; gap: 10px; padding: 8px 10px; flex: none; }\n .ops-inc-bar .ops-muted { flex: 1; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n .ops-inc-grid { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }\n .ops-inc-grid > :global(*) { flex: 1 1 0; min-height: 0; }\n .ops-seg { display: inline-flex; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 7px; overflow: hidden; flex: none; }\n .ops-seg button { border: 0; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); font: inherit; font-size: 12px; padding: 4px 11px; cursor: pointer; }\n .ops-seg button + button { border-left: 1px solid var(--sg-border, #e5e7eb); }\n .ops-seg button.is-on { background: var(--c, var(--sg-fg, #0f172a)); color: var(--sg-bg, #fff); }\n .ops :global(.ops-card) { display: flex; flex-direction: column; gap: 6px; cursor: pointer; }\n .ops :global(.ops-card-top) { display: flex; align-items: center; gap: 6px; font-size: 11px; white-space: nowrap; }\n .ops :global(.ops-sev) { font-weight: 700; padding: 0 6px; border-radius: 4px; color: var(--c); background: color-mix(in srgb, var(--c) 14%, transparent); }\n .ops :global(.ops-card-id) { color: var(--sg-muted, #64748b); font-variant-numeric: tabular-nums; }\n .ops :global(.ops-card-src) { display: inline-flex; margin-left: auto; color: var(--sg-muted, #64748b); }\n .ops :global(.ops-card-who) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }\n .ops :global(.ops-card-age) { margin-left: auto; color: var(--sg-muted, #64748b); }\n .ops :global(.ops-card-title) { font-weight: 600; font-size: 12.5px; line-height: 1.35; }\n .ops :global(.ops-card-foot) { display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--sg-muted, #64748b); }\n\n /* Lists: deploys, on-call, alert feed */\n .ops-list { overflow: auto; padding: 0 0 4px; }\n .ops-list-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 12px; border-bottom: 1px solid var(--sg-border, #e5e7eb); font-size: 12px; position: sticky; top: 0; background: var(--sg-bg, #fff); z-index: 1; }\n .ops-dep, .ops-alert { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--sg-border, #e5e7eb); }\n .ops-dep:hover, .ops-alert:hover { background: var(--sg-row-hover-bg, #f8fafc); }\n .ops-dep-ic { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 6px; flex: none; border: 1px solid var(--sg-border, #e5e7eb); color: var(--sg-muted, #64748b); }\n .ops-dep-ic.is-rb { color: #b45309; }\n .ops-dep-txt { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }\n .ops-dep-txt > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .ops-dep-txt .ops-muted { font-size: 11.5px; }\n .ops-dep-msg { font-size: 12px; }\n .ops-dep-txt code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; }\n .ops-progress { display: block; height: 4px; margin-top: 4px; border-radius: 2px; background: var(--sg-border, #e5e7eb); overflow: hidden; }\n .ops-progress > span { display: block; height: 100%; background: var(--sg-accent, #2563eb); transition: width 1.2s linear; }\n .ops-alert.is-ack { opacity: 0.55; }\n .ops-tag { margin-left: 6px; font-size: 10.5px; padding: 0 6px; border-radius: 4px; border: 1px solid var(--sg-border, #e5e7eb); color: var(--sg-muted, #64748b); }\n .ops-tag-bad { color: #b91c1c; border-color: color-mix(in srgb, #dc2626 35%, transparent); }\n .ops-empty { padding: 18px 14px; color: var(--sg-muted, #64748b); font-size: 12.5px; line-height: 1.5; }\n\n /* Incident drawer (portalled, so its rules are not under .ops) */\n .ops-dr { display: flex; flex-direction: column; gap: 14px; font-size: 13px; color: var(--sg-fg, #0f172a); }\n .ops-dr .ops-muted { color: var(--sg-muted, #64748b); }\n .ops-dr-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }\n .ops-dr-label { width: 78px; font-size: 12px; font-weight: 600; color: var(--sg-muted, #64748b); }\n .ops-select { font: inherit; font-size: 13px; padding: 4px 8px; border: 1px solid var(--sg-input-border, var(--sg-border, #d1d5db)); border-radius: 6px; background: var(--sg-input-bg, var(--sg-bg, #fff)); color: var(--sg-fg, #0f172a); }\n .ops-dr-card { border: 1px solid var(--sg-border, #e5e7eb); border-radius: 10px; overflow: hidden; }\n .ops-dr-card-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; background: var(--sg-header-bg, #f8fafc); border-bottom: 1px solid var(--sg-border, #e5e7eb); }\n .ops-dr-card-head .ops-link { margin-left: auto; }\n .ops-dr-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); padding: 10px 12px; gap: 8px; font-variant-numeric: tabular-nums; }\n .ops-dr-stats span { display: flex; flex-direction: column; font-weight: 600; }\n .ops-dr-stats small { font-size: 11px; font-weight: 500; color: var(--sg-muted, #64748b); }\n .ops-dr-deploy { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--sg-border, #e5e7eb); font-size: 12px; }\n .ops-dr-h { margin: 4px 0 0; font-size: 13px; font-weight: 650; }\n .ops-tl { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; }\n .ops-tl-item { position: relative; display: flex; gap: 10px; padding: 0 0 12px; }\n .ops-tl-item:not(:last-child)::before { content: ''; position: absolute; left: 11px; top: 24px; bottom: 0; width: 1px; background: var(--sg-border, #e5e7eb); }\n .ops-tl-ic { display: grid; place-items: center; width: 23px; height: 23px; border-radius: 999px; flex: none; border: 1px solid var(--sg-border, #e5e7eb); background: var(--sg-bg, #fff); color: var(--sg-muted, #64748b); }\n /* Timeline kinds use the same status palette as the board. */\n .ops-tl-item[data-kind='open'] .ops-tl-ic { color: #dc2626; }\n .ops-tl-item[data-kind='ack'] .ops-tl-ic { color: #15803d; }\n .ops-tl-item[data-kind='deploy'] .ops-tl-ic { color: #b45309; }\n .ops-tl-item[data-kind='page'] .ops-tl-ic { color: #2563eb; }\n .ops-tl-txt { display: flex; flex-direction: column; gap: 1px; min-width: 0; padding-top: 2px; }\n .ops-tl-txt .ops-muted { font-size: 11.5px; }\n .ops-note { display: flex; flex-direction: column; gap: 6px; align-items: flex-end; }\n .ops-note textarea { width: 100%; box-sizing: border-box; resize: vertical; font: inherit; font-size: 13px; padding: 7px 9px; border: 1px solid var(--sg-input-border, var(--sg-border, #d1d5db)); border-radius: 6px; background: var(--sg-input-bg, var(--sg-bg, #fff)); color: var(--sg-fg, #0f172a); }\n .ops-note textarea:focus { outline: none; border-color: var(--sg-accent, #2563eb); }\n .ops-dr-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; font-size: 12px; }\n .ops-dr .ops-btn, .ops-dr-foot .ops-btn { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 11px; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 7px; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); font: inherit; font-size: 12.5px; cursor: pointer; }\n .ops-dr .ops-btn-sm { height: 26px; padding: 0 8px; font-size: 12px; }\n .ops-dr-foot .ops-btn-primary { background: var(--c); border-color: var(--c); color: #fff; }\n .ops-dr .ops-btn:disabled { opacity: 0.45; cursor: default; }\n .ops-dr .ops-link { border: 0; background: none; padding: 0; font: inherit; font-size: 12px; font-weight: 600; color: var(--sg-accent, #2563eb); cursor: pointer; }\n .ops-dr .ops-seg { display: inline-flex; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 7px; overflow: hidden; }\n .ops-dr .ops-seg button { border: 0; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); font: inherit; font-size: 12px; padding: 4px 10px; cursor: pointer; }\n .ops-dr .ops-seg button + button { border-left: 1px solid var(--sg-border, #e5e7eb); }\n .ops-dr .ops-seg button.is-on { background: var(--c, var(--sg-fg, #0f172a)); color: var(--sg-bg, #fff); }\n\n @media (max-width: 900px) {\n .ops-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }\n .ops-kpi:nth-child(3) { border-right: 0; }\n .ops-kpi:nth-child(-n + 3) { border-bottom: 1px solid var(--sg-border, #e5e7eb); }\n }\n @media (max-width: 639px), (max-height: 500px) and (pointer: coarse) {\n .ops { flex-shrink: 0; min-height: 900px; }\n .ops-dr-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }\n }\n</style>\n"
|
|
3057
|
+
},
|
|
3034
3058
|
{
|
|
3035
3059
|
"id": "51-ai-assistant",
|
|
3036
3060
|
"path": "examples/src/demos/51-ai-assistant.svelte",
|
|
@@ -3473,13 +3497,6 @@ export const examples = [
|
|
|
3473
3497
|
}
|
|
3474
3498
|
];
|
|
3475
3499
|
export const docs = [
|
|
3476
|
-
{
|
|
3477
|
-
"slug": "_internal/plans/spreadsheet-extension",
|
|
3478
|
-
"path": "docs/_internal/plans/spreadsheet-extension.md",
|
|
3479
|
-
"title": "Spreadsheet extension plan",
|
|
3480
|
-
"section": "_internal",
|
|
3481
|
-
"markdown": "# Spreadsheet extension plan\n\nInternal planning note. `docs/_internal/` is skipped by\n`tools/build-docs-index.mjs`, so nothing here reaches docs.json, llms.txt or\nthe site. Ship a feature, then move its sentence into the public docs.\n\nWritten 2026-09-18 against the `main` head after the \"Spreadsheet mode\"\ncommit (`43c01c2`). Accepted the same day.\n\n## Status\n\n**Every gap in section 2 is shipped**, bar one the measurements argued\nagainst: recalculation off the main thread, and the sparse storage that\nwould go with it. The bench says a 50k-row sheet opens in about half a\nsecond and an edit costs one evaluation, so a worker would add a boundary\nand buy nothing today; the row is left standing rather than struck through\nso the decision is visible rather than forgotten.\n\nShipped on the plan's branch, one commit per item:\n\n- Phase B item 1: the function packs (financial, math and statistics, text\n and date, CHOOSE / ROWS / COLUMNS, the ISERROR family), on by default\n rather than behind `withCustomFunctions`, since a sheet user expects PMT\n to work without registering anything. The parser reads an omitted\n argument as a blank.\n- Phase C items 1 to 4: Input Message and Circle Invalid Data; Format\n Cells Accounting and Special (the compiler learned padding tokens,\n conditions and integer masks); the Sort dialog; tab Hide, Unhide,\n Duplicate and a confirmed Delete.\n- Phase B item 2: ROW, COLUMN, ADDRESS, OFFSET and INDIRECT, with the\n volatile set in the workbook.\n- Phase C item 5: AutoFilter Date Filters, Filter by Color and Top 10.\n- Phase C item 6: the conditional formatting formula rule and data bars\n with a negative axis.\n- Phase C item 7: Trace Precedents, Trace Dependents and Remove Arrows.\n- Phase A items 1 to 3: `documentToXlsx` / `documentFromXlsx` with a\n round-trip test, and the File tab (New, Open, Save As, Export CSV, with\n Ctrl+O and Ctrl+S).\n- Phase C item 8, threads: `CommentThread` (author, time, replies,\n resolved) beside the plain note in the same map, the thread card in the\n comment box, `commentAuthor` on the shell, and Excel's threaded comment\n parts in the xlsx writer and reader.\n- Phase C item 8, protection: the Protect Sheet dialog with Excel's allow\n list, Allow Edit Ranges, `protection: { allow, ranges }` in the state\n beside the flag, and `sheetProtection` attributes and `protectedRanges`\n in the xlsx both ways. Phase C is complete.\n- Phase D, charts and pictures: `sheet/objects.ts` (the anchor, the\n chart's range and spec, shifting), `objects` per sheet in the document,\n the object layer in the shell with move, resize, select and delete, the\n Chart dialog, and Insert > Chart and Insert > Picture on the ribbon.\n Insert > Chart no longer needs `extras`.\n- Phase B item 4: the `SheetEngine` seam on `createWorkbook`, with the\n built-in engine behind it. A HyperFormula engine shipped here first and\n was then retired unreleased when the built-in library outgrew the reason\n for it; see the answer to question 2 in section 6.\n- Phase E item 3: `<sv-sheet>` under `@svgrid/enterprise/wc`, its surface\n generated from the shell's Props (element props, types, React and Vue\n wrappers, docs tables, a `--check` the tests run), built by\n `scripts/build-wc.mjs` with Svelte and the grid bundled in.\n- Phase B item 3: dynamic arrays. The array pack (FILTER, UNIQUE, SORT,\n SORTBY, SEQUENCE, TRANSPOSE, TEXTSPLIT), array arithmetic with Excel's\n broadcasting, spill ranges kept by the workbook with `#SPILL!` and\n `#CALC!`, the blue outline on the active spill, and array formulas with\n the dynamic-array metadata in the xlsx both ways.\n- Phase A items 4 and 5: `PageSetup` per sheet, the Page Layout tab\n (margins, orientation, size, print area, print titles, gridlines,\n headings, the Page Setup dialog), File > Print over `sheetPrintHtml`,\n and `pageSetup` / `pageMargins` / `printOptions` / the print names in\n the xlsx both ways. Phase A is complete.\n- Phase E item 4: `skills/svgrid/rules/sheet.md` with the shell's house\n rules, a `build_sheet` prompt in `@svgrid/mcp`, and a Spreadsheet block\n in Studio that emits `<SvSheet>` over `sheetCellsFromRows(allRows)`.\n- A PivotTable report filter: the Filters area written above the block,\n applied before anything is grouped, picked in the dialog or typed into\n the cell, which refreshes the pivot on the spot.\n- A trendline and a secondary axis in the Chart dialog, over the grid\n chart's own overlay and right axis, with the trendline in the .xlsx.\n- PivotTable Show Details: `pivotLayout` giving every line and column of a\n written pivot its field values, `pivotDrill` returning the records behind\n a cell, and the shell writing them to a sheet of their own.\n- Excel's `IMAGE` function: a picture in the cell rather than over it,\n `sheet/cell-images.ts` reading the call off the formula, drawn in the\n cell and on the printed page, and stored as `_xlfn.IMAGE`.\n- Sparklines and objects on the printed page: `sparklineSvg` over the\n grid's own geometry, a chart drawn offscreen to SVG at its own size, both\n placed against their anchor cell by `sheetPrintHtml`, and the default\n print area grown to hold a chart anchored below the numbers.\n- The two leftovers noted inside shipped rows: Sort On (cell colour and\n font colour) in the Sort dialog and the sort engine, and NUMBERVALUE.\n- Objects in the .xlsx: `sheet/xlsx-drawing.ts` (the drawing part, a\n picture in `xl/media`, a chart part carrying its references rather than\n cached numbers, and the reader that gives both back, two-cell anchors\n included), plus sparklines in the worksheet's x14 extension list both\n ways. A picture whose source is a URL is left out on purpose.\n- Presence, the last of Phase F item 3: `sheet/presence.ts` (a person, a\n stable colour, the sheet and rectangle they are on, the fifteen-second\n prune), a `presence` prop the shell draws over the cells and an\n `onPresence` callback for this user's own selection, a fifth delta kind\n that rides the same wire without touching the document, and demo 488\n showing both cursors.\n- The table styles gallery: `sheet/table-styles.ts` with Excel's eighteen\n built-in names and their accents, `style` on a table region, the shell\n drawing each table in its own colours, the gallery in the Create Table\n dialog with Insert > Table Styles beside it, and `tableStyleInfo` in the\n xlsx both ways.\n- The autosave-to-a-server recipe in the shell page, and the saved-state\n shape in the docs brought up to date with tables, iteration, links,\n sparklines and pivots.\n- Evaluate Formula and Error Checking: `sheet/evaluate-steps.ts` (a\n formula walked one part at a time, each part handed back to the workbook\n as a formula so the meanings cannot drift, an untaken IF branch skipped)\n and `sheet/error-check.ts` (error cells, and the formula that breaks its\n column's pattern), both as dialogs on the Formula Auditing group and both\n exported as functions. The shared printer learned the lambda call node\n while it was there, which fixes filling a `LAMBDA(...)(...)` cell down.\n- Iterative calculation: `workbook.iteration` and `setIteration`, a\n re-entrant read answering with the cell's previous value rather than\n `#CYCLE!`, passes over `graph.cycles()` until the largest move is under\n the tolerance or the cap is spent, Formulas > Calculation Options in the\n shell, the setting in `getState()` and in the xlsx as `calcPr`.\n- Format as Table: a table registry on the workbook wired into the eval\n context, so the structured references the parser already read finally\n resolve; Insert > Table and Ctrl+T with Excel's dialog, the banded look\n drawn rather than written, auto-expand that fills the calculated columns\n into a new row, the cells behind a structured reference recorded in the\n dependency graph, and the tables in the .xlsx both ways. `extras` is dead\n and the prop is ignored.\n- LET and LAMBDA, with MAP, BYROW, BYCOL, REDUCE, SCAN and MAKEARRAY: a\n lexical scope in the evaluator's context, lambdas as a value only the\n formula can hold, immediate and curried calls through a `(` node the\n parser builds, and the helpers spilling like the other array functions.\n The xlsx writer learned Excel's `_xlfn.` prefixes while it was there,\n which also fixes every dynamic-array formula written before now opening\n in Excel as `#NAME?`.\n- Hyperlinks (the last of the small gaps in section 2): `sheet/links.ts`\n with the links per sheet, Insert > Link and Ctrl+K, Insert > Remove, the\n `HYPERLINK` function and a clickable cell for it, internal targets that\n move the selection, and the links in the .xlsx both ways.\n- Phase F item 3: collaboration. `sheet/delta.ts` with `createDeltaStream`,\n `applySheetDelta` and four delta kinds (cells as raw text, a structural\n edit, one part of one sheet's state, the whole document for sheets and a\n restore), last-writer-wins per cell and said so. It needed three seams\n that ship on their own: `workbook.subscribeWrites`, `document.patch`, and\n a shell that follows its document rather than waiting for `refresh()`.\n Demo 488 wires two sheets together and logs the wire. Presence is left\n where the plan put it, as a grid overlay over the selection. Phase F is\n complete but for item 2, which the bench says is not called for yet.\n- Phase F item 1: the spreadsheet bench. `tools/bench/sheet-cases.mjs` fills\n a sheet with a formula per row at 1k, 10k and 50k, times opening it and\n one keystroke in it (plain, under a 10k-cell SUM, and at the top of a 10k\n chain), and counts CELL EVALUATIONS through the engine seam, which is what\n CI gates. The ceiling is published in `docs/help/benchmarks.md` and in the\n shell's own page. What the bench found and this fixed: a dependency chain\n longer than about a thousand rows overflowed the stack on the first\n keystroke and left `#NUM!` in the cell; the workbook now primes a deep\n chain from its far end. What it found and did NOT change: an edit costs\n one evaluation, a 50k-row sheet opens in about half a second and retains\n about 84 MB, so sparse storage and a worker (item 2) are not called for\n yet.\n- Phase D, PivotTable: `sheet/pivot-range.ts` (the definition, the records\n read from a range, the block the engine's result becomes, shifting) over\n the existing `pivot.ts`, `pivots` per sheet in the document, Insert >\n PivotTable and Insert > Refresh on the Tables group, and the Create\n PivotTable dialog with its field list. The result is written as cells in\n one undo. Phase D is complete but for the objects in the .xlsx.\n- Phase D, sparklines: `sheet/sparklines.ts` (the group, its two ranges,\n the series a cell draws, the shared scale, shifting), `sparklines` per\n sheet in the document, the Sparklines group on the Insert tab (Line,\n Column, Win/Loss, Edit, Clear), the Create Sparklines dialog, and the\n drawing inside the cell over the free `<SvSparkline>`. Not done: the\n sparklines in the .xlsx (Excel keeps them in an x14 extension) and on\n the printed page, which is built from what each cell says.\n- Phase E item 2: the RTL, touch and accessibility audits, as\n `tests/e2e/sheet-a11y.spec.ts` (an axe pass over every band of the shell\n on five demos, light and dark, plus the keyboard model), `sheet-rtl.spec.ts`\n and `mobile/sheet-touch.spec.ts`. What they found and what was fixed: the\n grid's pinned columns and row gutter stuck to the physical left edge\n (now logical insets), the object layer hung objects from the physical\n left (now the inline start), both tab strips lacked Home, End and\n focus-follows-selection, the Name Box carried the address as a\n placeholder rather than a value, the empty row-number corner, the\n focusable resize separator with no `aria-valuenow`, the watermark\n appended inside the `role=\"grid\"` table, and three small-text colours\n under 4.5:1 on a host theme's accent. Phase E is complete.\n- Phase E item 1: `SheetMessages`, `SheetLocalization` and the\n `localization` prop, threaded through Svelte context to the ribbon,\n the formula bar, the tab strip, every dialog and the status bar; the\n ribbon's keys are read off the model. The rule descriptions in the\n Rules Manager and the function names stay English.\n\nThen a quality pass over the whole branch, looking for defects rather than\nfeatures. What it found, each fixed with the test that would have caught\nit: a table left pointing at a sheet that had been renamed, and none on a\ncopied sheet; a table header nobody could read; every ribbon group\npublished as a landmark; a chart's trendline written where the schema wants\nits categories; a `javascript:` link followed out of a cell, and the same\none carried in from a file; a duplicated sheet sharing its objects'\nidentities with the sheet it came from, so Delete on the copy reached the\noriginal's selection; and `NUMBERVALUE` reading a thousands mark after the\ndecimal point as a number rather than as the typo Excel calls it.\n\nWhat the pass leaves behind: QA suites over the round trips, the engine\nseam, the package surface, hostile input, a stranger's file and a copied\nsheet; two that read the shell against its own tables (every `t('...')` has\na default, every ribbon action has a case); and a seeded generator that\nputs documents nobody wrote through both round trips, run to five hundred\nseeds with nothing to report.\n\nThen a second round, driving the shell in a browser rather than in jsdom,\nwhich is where the remaining defects were: a table printed plain, because\nits look is drawn over the cells and the printed page is built from them; a\nrefused link reported as an empty address, and a link the shell will not\nfollow reported as one that goes nowhere on this workbook; a table edit\nannouncing a new table; `<sv-sheet>` ignoring `sheet.data = [...]`, its own\nquick start, because a custom element is upgraded before the script that\nassigns its properties runs and the element read its own parked document\nback as content; and a protected sheet that kept its cells while its\ncharts, pictures and sparklines could still be changed. Browser specs for\nwhat File > Print hands the printer and for Save As then Open guard the two\nthat only a browser can see.\n\nThen a third round, on the question a spreadsheet is actually asked: can I\nopen my file. It turned into three more formats and a pass of Excel-parity\ndefects beside them.\n\nThe formats: OpenDocument both ways (`documentToOds` / `documentFromOds`),\nwhich is what LibreOffice Calc saves by default and one of the two things\nGoogle Sheets hands back, with the formulas translated between A1 and ODF's\nown grammar and the values carrying their type, so a date stays a date and\nmoney stays money; CSV in as well as out, with the separator guessed and a\nquoted field keeping its commas and line breaks; and Excel 97-2003, the\nbinary .xls, where the container is a compound file (a FAT filesystem in a\nfile, mini stream and all) and a formula is RPN tokens rather than text, so\nreading one means walking the tokens back into a formula and writing one\nmeans walking the AST into tokens. `documentFromFile` decides which of the\nfour a picked file is from its bytes rather than its name, and the File tab\ngrew Save As ODS and Save As XLS. All of it checked against a real\nLibreOffice in both directions, including every function in Excel's table\nwritten out, read there, written back and read here again, which is what\nturned up FLOOR taking its argument count from the table rather than from\nthe record.\n\nThe defects the same round found, each fixed with the test that would have\ncaught it: the lookup match modes and wildcards Excel's VLOOKUP, HLOOKUP,\nMATCH and XLOOKUP take; the start position FIND and SEARCH accept; Excel's\ntext prefix and its typed error values surviving a file; a reference\nrepointed when the cell it names is cut and moved, and Excel's cut at all;\ncopy and Clear Contents skipping a hidden row or column, as Excel does;\nSUBTOTAL ignoring what a filter hides, and a sort leaving hidden rows\nwhere they are; an error typed into a cell being that error rather than\ntext that reads like one; the filter arrows lost on the way into an .ods\nand out; a table's formulas lost in both files; a date saved to a file\ncoming back as 46204, because the number Excel counts went out with\nnothing on the cell to say it was a date; and `^` binding right where\nExcel binds it left, with unary minus looser than `^` where Excel binds it\ntighter, so `=2^3^2` was 512 against Excel's 64 and `=-2^2` was -4 against\nExcel's 4.\n\nDeviations from the plan: Data Validation is a plain dropdown, not a split\nbutton, because the ribbon model forbids a dropdown that emits its own\nface; the xlsx reader uses DOMParser (present in browsers and jsdom) and\nthrows a clear error where it is absent. The demos are registered in the\nexample gallery; their entries in the website's demo registry (a private\nsubmodule not checked out here) are still to add.\n\n## 1. Where the spreadsheet stands\n\nThree layers exist today, and any extension lands in one of them.\n\n| Layer | Package | Where | What it is |\n| --- | --- | --- | --- |\n| Sheet-shaped grid | `@svgrid/grid` (MIT) | `packages/grid/src/spreadsheet.ts`, `merges.ts`, `hyperformula-adapter.ts` | `spreadsheetLayout`, A..Z headers, row gutter, merged cells, fill handle, range selection, the optional HyperFormula peer |\n| Engine and keyboard layer | `@svgrid/enterprise/sheet` | `packages/enterprise/src/sheet/*.ts` | tokenizer, parser, evaluator, dependency graph, workbook, defined names, tables, format store, number-format compiler, paste special, find/replace, structure edits, freeze, validation, conditional formats, comments, protection, auto-filter, goal seek, Excel shortcuts |\n| Shell | `@svgrid/enterprise` | `packages/enterprise/src/SvSheet.svelte` (4114 lines) plus `SvSheetRibbon`, `SvFormulaBar`, `SvSheetTabs` and sixteen `SvSheet*` dialog components | ribbon with Home / Insert / Formulas / Data / Review / View, Name Box and fx bar, tab strip, status bar, the document model (`sheet/document.ts`) with `getState` / `setState` / `onChange` |\n\nMeasured while writing this note:\n\n| What | Count | How |\n| --- | --- | --- |\n| Functions implemented in `sheet/functions.ts` | 60 | `grep -c \"^ [A-Z][A-Z0-9]*:\"` |\n| Sheet demos (27, 83, 173, 207 to 212, 356, 452 to 466) | 25 | `ls examples/src/demos` |\n| Sheet test files (`sheet/*.test.ts`, `SvSheet*`, `SvFormulaBar*`) | 42 | `ls` |\n| Pending `.changeset` files about the sheet | many | `ls .changeset` - unreleased; the release that carries them has not been cut |\n\nTwo facts shaped the plan as it was written. Both have since been fixed;\nthe Status section above says by what.\n\n- **The shell cannot use HyperFormula.** `Workbook` evaluates through its\n own `evaluate`; `SvSheet.svelte` and `sheet/workbook.ts` do not mention\n HyperFormula. The adapter lives in the free grid for a plain `<SvGrid>`.\n Function breadth in the shell is therefore the 60 functions above.\n- **Files do not round-trip.** `import.ts` reads the first sheet of an xlsx\n as rows (its own comment: \"does NOT try to be a fully-featured Excel\n reader\"), `export.ts` writes grid rows, and the ribbon has no File tab and\n no save, open or print action (`RIBBON_ACTIONS` in `sheet/ribbon.ts`).\n A document made in the shell can only leave it as `getState()` JSON.\n\nThe public docs already list the intentional gaps under \"What it does not do\"\nin `docs/help/cells/spreadsheet-shell.md`. That list is the contract: every\nitem shipped from this plan deletes a line there.\n\n## 2. The gaps, graded\n\nEffort tags follow `docs/help/missing-features.md`: S under a week, M a few\nweeks, L a quarter-scale piece of work.\n\n### Files and print\n\n| Gap | Today | Effort |\n| --- | --- | --- |\n| ~~Open an xlsx as a whole document~~ | shipped: `documentFromXlsx`, File > Open, which also takes an .ods, an .xls and a .csv, decided by the bytes | done |\n| ~~Save the document as xlsx~~ | shipped: `documentToXlsx`, File > Save As, with Save As ODS and Save As XLS beside it | done |\n| ~~CSV out of the active sheet~~ | shipped: File > Export CSV, and CSV in through File > Open, with the separator guessed | done |\n| ~~Print and Page Layout~~ | shipped: the Page Layout tab, the Page Setup dialog, File > Print, `pageSetup` in the state and the xlsx | done |\n| ~~Persistence hooks (autosave to a server)~~ | shipped: the Autosave to a server recipe in the shell page, with the debounce, the one-in-flight rule and the revision header, and the state shape brought up to date | done |\n\n### Formula engine\n\n| Gap | Today | Effort |\n| --- | --- | --- |\n| ~~Financial functions (PMT, PV, FV, NPV, IRR, RATE, NPER)~~ | shipped, with IPMT, PPMT and SLN | done |\n| ~~Math and statistics (SUMPRODUCT, PRODUCT, CEILING, FLOOR, TRUNC, LOG, EXP, PI, RAND, RANDBETWEEN, LARGE, SMALL, PERCENTILE, QUARTILE, VAR, MODE, AVERAGEIFS, MAXIFS, MINIFS, CORREL, FORECAST)~~ | shipped | done |\n| ~~Text (PROPER, REPT, VALUE, CHAR, CODE, EXACT)~~ | shipped, with TEXTSPLIT from the spill work and NUMBERVALUE | done |\n| ~~Date (WEEKDAY, EDATE, NETWORKDAYS, WORKDAY, WEEKNUM, HOUR, MINUTE, SECOND, TIME)~~ | shipped, with DATEVALUE, TIMEVALUE, DAYS360, YEARFRAC | done |\n| ~~Reference functions (INDIRECT, OFFSET, ROW, COLUMN, ROWS, COLUMNS, ADDRESS, CHOOSE)~~ | shipped; INDIRECT and OFFSET are volatile, recomputed on every write | done |\n| ~~Dynamic arrays and spill (FILTER, UNIQUE, SORT, SORTBY, SEQUENCE, `#SPILL!`)~~ | shipped: `evaluateSpill`, spill ranges in the workbook, array arithmetic with broadcasting, TRANSPOSE and TEXTSPLIT too | done |\n| ~~LET / LAMBDA~~ | shipped, with MAP, BYROW, BYCOL, REDUCE, SCAN and MAKEARRAY, and the `_xlfn.` prefixes in the file | done |\n| ~~A pluggable engine (HyperFormula behind the shell)~~ | shipped as the `engine` option on `createWorkbook`, over the built-in engine. HyperFormula behind the shell was tried and retired unreleased: see section 6 question 2 | done |\n| ~~Iterative calculation (circular references with a cap)~~ | shipped: `workbook.iteration`, Formulas > Calculation Options, the two limits, `calcPr` in the xlsx both ways | done |\n\n### Ribbon parity\n\n| Gap | Today | Effort |\n| --- | --- | --- |\n| ~~Formula auditing: Trace Precedents / Dependents~~ | shipped, with Evaluate Formula and Error Checking beside them | done |\n| ~~Conditional formatting: formula rule, negative axis and second colour on data bars~~ | shipped | done |\n| ~~AutoFilter: Date Filters, Filter by Color, custom Top 10~~ | shipped | done |\n| ~~Validation: Input Message, Circle Invalid Data~~ | shipped | done |\n| ~~Format Cells: Accounting, Special~~ | shipped | done |\n| ~~Custom Sort dialog (several keys, header row)~~ | shipped, with Sort On: cell colour and font colour | done |\n| ~~Comments as threads (author, time, replies, resolve)~~ | shipped: the thread card, `commentAuthor`, and Excel's threaded parts in the xlsx | done |\n| ~~Protection: allowed ranges and the allow list~~ | shipped: the Protect Sheet dialog, Allow Edit Ranges, `sheetProtection` in the xlsx. A password is still not one: a lock in the browser is not a secret | done |\n| ~~Sheet tabs: hide / unhide, duplicate, delete with confirm~~ | shipped; move between workbooks not done | done |\n| ~~Format as Table~~ | shipped: `workbook.tables`, Insert > Table and Ctrl+T, the banded look, auto-expand, the styles gallery, the xlsx both ways | done |\n\n### Objects on the sheet\n\n| Gap | Today | Effort |\n| --- | --- | --- |\n| ~~Charts anchored to cells, fed by a range~~ | shipped: the object layer, Insert > Chart, the Chart dialog with a trendline and a secondary axis, and the chart part in the .xlsx both ways | done |\n| ~~Sparklines in cells~~ | shipped: the groups in the document, Insert > Sparklines (Line, Column, Win/Loss, Edit, Clear), drawn over `<SvSparkline>`, in the .xlsx both ways, and on the printed page | done |\n| ~~Hyperlinks (HYPERLINK function and Insert > Link)~~ | shipped: links per sheet, Insert > Link and Ctrl+K, the function, internal targets, and the xlsx both ways | done |\n| ~~Images floating over the cells~~ | shipped: Insert > Picture, carried in the document as a data URL, and in the .xlsx both ways. In a cell is shipped too, as Excel's `IMAGE` function. A floating picture that is a URL rather than a data URL is left out of the file | done |\n| ~~PivotTable from a range~~ | shipped: `SheetPivot` in the document, Insert > PivotTable, Refresh and Show Details, the result written as cells | done |\n\n### Reach\n\n| Gap | Today | Effort |\n| --- | --- | --- |\n| ~~Localised ribbon, dialog and status-bar strings~~ | shipped: `SheetMessages` in one flat map, `localization={{ text, locale }}`, the ribbon's keys read off the model | done |\n| ~~RTL sheet (columns run right to left, A on the right)~~ | shipped: audited with a Playwright spec, and the sticky pinning, the object layer and the row-resize maths fixed with logical properties | done |\n| ~~Touch: fill handle, range drag, ribbon on phones~~ | shipped: audited on a phone viewport with a Playwright spec, the shell's touch targets and the ribbon fold fixed where it found them | done |\n| ~~Accessibility audit of ribbon and dialogs~~ | shipped: axe over the shell in both themes, the ribbon tablist given a roving tabindex, and what it found fixed | done |\n| ~~`<sv-sheet>` web component with React / Vue / Angular wrappers~~ | shipped under `@svgrid/enterprise/wc`, its surface generated from the shell's props with a `--check` the tests run | done |\n| ~~Studio, MCP, skill rules know the sheet~~ | shipped: `skills/svgrid/rules/sheet.md`, a `build_sheet` prompt in `@svgrid/mcp`, and a Spreadsheet block in Studio | done |\n\n### Scale and collaboration\n\n| Gap | Today | Effort |\n| --- | --- | --- |\n| ~~Large sheets~~ | measured: `pnpm bench` over the engine at 1k, 10k and 50k rows, the ceiling published in `docs/help/benchmarks.md`, and the deep-chain stack overflow it found fixed. Sparse storage is not called for | done |\n| Recalculation off the main thread | none | L |\n| ~~Co-editing (deltas, presence, conflict)~~ | shipped: `createDeltaStream` with five delta kinds, `applySheetDelta`, the presence overlay and `onPresence`, last writer wins per cell and said so. No server and no operational transform, on purpose | done |\n\n## 3. Phases\n\nOrdered by what a buyer of the Enterprise pack asks for first. Each phase\nships on its own; none blocks the next except where marked.\n\n### Phase A. Files (L)\n\nThe single most-asked question a spreadsheet gets is \"can I open my file\".\n\n1. **`sheet/xlsx-document.ts`** with `documentToXlsx(doc)` and\n `documentFromXlsx(bytes)`. Reuse the OOXML writer in `export-ooxml.ts`\n and the zip reader in `import.ts`, but drive them from `SheetDocument`,\n not grid rows: cell text as `<f>` when it starts with `=`, formats from\n the format store as `<xf>` entries, merges, frozen panes, hidden lines,\n column widths and row heights, defined names, comments as legacy notes,\n data validation and conditional formatting where OOXML has a direct\n equivalent, tables. Import maps the same the other way.\n2. **Round-trip tests** in the style of `export-xlsx-roundtrip.test.ts`:\n build a document, write, read, compare `getState()`.\n3. **File tab** on the ribbon: New, Open, Save as xlsx, Export CSV, Print.\n Open and Save use the browser's file input and download; a host can take\n any of them over through `onAction`, which is how a server-backed app\n will save.\n4. ~~**Print**: a Page Layout tab with orientation, margins, print area,\n repeat header rows, feeding `export-print.ts` with the sheet's formats.~~\n Shipped, with a builder of its own (`sheet/print.ts`) rather than the\n grid's, because merges, widths and per-cell formats need a different\n table.\n5. ~~Docs: a new `docs/help/cells/sheet-files.md`; remove \"no Page Layout\"\n from the shell page; a demo that opens a bundled xlsx.~~ Done as the\n Files and Page Layout sections of the shell page rather than a page of\n their own, and demo 484 saves and reopens a file.\n\n### Phase B. Engine breadth (M, then L for spill)\n\n1. **Function packs** as separate modules under `sheet/functions/`\n (`financial.ts`, `math-stats.ts`, `text.ts`, `date.ts`, `reference.ts`),\n registered through the existing `withCustomFunctions` seam so the\n `/sheet` chunk does not grow for apps that never call them. Each pack\n ships with golden tests against values computed in Excel, extending\n `engine.golden.test.ts`, and entries in `function-catalog.ts` and\n `autocomplete.ts` `SIGNATURES` so the fx dialog and autocomplete see\n them.\n2. **Reference functions** need one engine change: `precedentsOf` cannot\n see through `INDIRECT` or `OFFSET`. Mark such cells volatile in the\n dependency graph (recompute on every change) as Excel does.\n3. ~~**Dynamic arrays**: `evaluate` returns a matrix, the workbook owns spill\n ranges, a blocked spill is `#SPILL!`, `translateFormula` and\n `fixupReferences` treat the anchor as the formula cell. This is the L\n item; do it after the packs so FILTER, UNIQUE, SORT and SEQUENCE land on\n a working spill.~~ Shipped: `evaluateSpill` beside `evaluate` rather\n than a matrix from it, so every existing caller keeps its value.\n4. ~~**Pluggable engine**: an `engine` option on `createWorkbook` with the\n built-in evaluator as default and a HyperFormula implementation moved\n from `packages/grid/src/hyperformula-adapter.ts`'s contract. Keeps the\n promise in `spreadsheet-formulas.md` (\"the HyperFormula adapter is still\n there\") true for the shell, not only for a bare grid.~~ Shipped, with a\n narrower seam than planned: an engine answers a formula's value and the\n grid it spills, and the workbook keeps the graph, the cache, the cycles\n and the spill ranges, since those are read off the reference grammar\n rather than the engine. Phase B is complete.\n\n### Phase C. Ribbon parity (M)\n\nSmall, independent items. Take them in this order, each one deleting a\nline from \"What it does not do\":\n\n1. Validation Input Message and Circle Invalid Data (S).\n2. Format Cells Accounting and Special (S); `compileNumberFormat` already\n handles the patterns, the dialog only lacks the pages.\n3. Custom Sort dialog (S).\n4. Sheet tab hide / unhide, duplicate, delete (S).\n5. AutoFilter Date Filters, Filter by Color, Top 10 with a count (M).\n6. Conditional formatting formula rule and two-colour data bars with a\n negative axis (M).\n7. Formula auditing arrows from `deps.ts` drawn on the grid's overlay\n layer, the same layer `spreadsheetLayout` uses for borders (M).\n8. ~~Comment threads and protection with allowed ranges (M each). Threads\n change the `comments` shape in `SheetState`; keep reading the old shape.~~ Shipped.\n\n### Phase D. Objects (L)\n\n~~Charts first, because the chart engine is already free in `@svgrid/grid`\nand demo 356 proves the data path. An object layer over the grid holds\nanchored rectangles (chart, image, sparkline group), stored in the document,\nmoved with insert and delete through `shiftRect` in `rects.ts`, and\nserialised by Phase A.~~ Charts, pictures and sparklines shipped: the\nlayer, the anchor, the Chart dialog, Insert > Chart and Insert > Picture,\nand the sparkline groups with their own dialog on the Insert tab. The PivotTable over a range came last, as\nplanned, on `pivot.ts` with the sheet range as its row source. Not done:\nany of them in the .xlsx (a chart part is a large piece of OOXML of its\nown, and a sparkline is an x14 extension).\n\n### Phase E. Reach (M)\n\n1. ~~**Localisation**: a `SheetMessages` type and a `localization` prop on\n `SvSheet`, threaded to the ribbon, dialogs and status bar the way\n `GridMessages` works. Format the status-bar numbers with the locale\n instead of `en-US`.~~ Shipped.\n2. ~~RTL, touch and accessibility audits with Playwright specs under\n `tests/`, fixing what they find.~~ Shipped, with the fixes listed in\n the Status section. Left as it is on purpose: a font colour or fill\n the DOCUMENT sets is used as it stands on a dark theme, as Excel does.\n3. ~~**`<sv-sheet>`**: a commercial web component entry under\n `@svgrid/enterprise` (it already builds a CDN bundle with Svelte\n external), with wrappers generated the way `grid-wc` generates React,\n Vue and Angular ones. Licensing rule from `AGENTS.md`: nothing moves into\n an MIT package.~~ Shipped as `@svgrid/enterprise/wc` with React and Vue\n wrappers; Angular uses the element with `CUSTOM_ELEMENTS_SCHEMA`, since a\n partial-Ivy build needs ng-packagr in the pack, which it does not carry.\n4. ~~Studio codegen for a sheet page, MCP eval prompts that ask for a sheet,\n and a `skills/svgrid/rules/sheet.md` with the shell's house rules\n (`refresh()` after outside writes, `cmd.batch` for one undo, qualified\n addresses in `formats`).~~ Shipped: a Spreadsheet block in Studio over\n `sheetCellsFromRows`, a `build_sheet` prompt on the MCP server, and the\n rules file.\n\n### Phase F. Scale and collaboration (L)\n\n1. ~~Measure first: a `tools/bench.mjs` case that fills a sheet with a\n formula per row at growing sizes and records type-to-paint time. Publish\n the ceiling in the docs rather than a guess.~~ Shipped, with the\n evaluation counters gated in CI and the ceiling in the docs.\n2. If the bench says so: sparse cell storage in `Workbook`, batched\n recalculation, and a worker build of the evaluator behind the `engine`\n seam from Phase B. The bench does not say so yet: one keystroke is one\n evaluation, 50k formula rows open in about half a second and retain\n about 84 MB. The one thing it did say, a deep chain overflowing the\n stack, is fixed. Revisit at 500k rows, or when a sheet arrives that is\n mostly empty, where dense `string[][]` storage is the waste.\n3. ~~Collaboration: turn `SheetChangeReason` into a delta stream (each\n reason already names its sheet and kind; add the payload), an\n `applyDelta` on the document, and a recipe with a socket server. Presence\n (other users' active cells) is a grid overlay. Conflict handling starts as\n last-writer-wins per cell, documented as such.~~ Shipped as\n `createDeltaStream` / `applySheetDelta`, with the socket recipe in the\n shell's docs and demo 488. Presence is still a grid overlay and still to\n do.\n\n## 4. Rules that apply to every phase\n\n- The sheet is Enterprise. New sheet code goes under `packages/enterprise`;\n a grid-level seam it needs (an overlay layer, an object host) goes in\n `packages/grid` as a registered feature so it tree-shakes.\n- Anything the shell does not need on first paint is a lazy import or a\n separate subpath, so `@svgrid/enterprise/sheet` stays the shortcuts and\n engine only.\n- A feature is done when it has: unit tests beside the module, a browser\n test where the grid is involved, a demo in `examples/src/demos/` with the\n matching entry in `website/src/lib/demos.ts` (`pnpm demos:count`), a\n docs page or section, the line removed from \"What it does not do\", a\n changeset, and the shortcut listed in `keyboard-shortcuts.md` when it has\n one.\n- No em-dash characters. No typed numbers: bundle size from `pnpm size`,\n demo counts from `pnpm demos:count`.\n- Excel's behaviour is the spec. When Excel and Google Sheets differ,\n follow Excel and note the difference in the docs.\n\n## 5. Suggested first milestone\n\nPhase A items 1 to 3 (xlsx in and out, File tab) plus Phase B item 1\n(function packs, financial first) and Phase C items 1 to 4. That is the set\na trial user hits in the first hour: open a file, see PMT work, validate\ninput, sort by two keys, save the file back. Everything else waits on\nfeedback from that.\n\n## 6. Open questions for the maintainer\n\n1. Should xlsx open and save be a ribbon File tab, or stay host-driven\n through `onAction` with only the functions exported? The plan assumes\n both: the tab by default, `onAction` to take it over.\n2. ~~Is HyperFormula behind the shell worth its licence story, or should the\n built-in engine grow until the adapter can be retired?~~ **Answered: the\n built-in engine.** It carries 156 functions now (`Object.keys(FUNCTIONS)`\n in `sheet/functions.ts`), including the packs the adapter was there to\n cover, so `createHyperFormulaEngine` is gone from `@svgrid/enterprise`\n before it was ever released. The `SheetEngine` seam stays, for an\n application with an evaluator of its own and for the bench, which counts\n evaluations through it. `createHyperFormulaSheet` in the free grid is a\n separate, row-shaped adapter for a plain `<SvGrid>` and is untouched.\n3. Does the `<sv-sheet>` element ship inside `@svgrid/enterprise` or as its\n own commercial package? Pricing decides that, not code.\n4. Collaboration: is a reference server part of the product, or only a\n recipe against the delta stream?\n"
|
|
3482
|
-
},
|
|
3483
3500
|
{
|
|
3484
3501
|
"slug": "brand/company-description",
|
|
3485
3502
|
"path": "docs/brand/company-description.md",
|
|
@@ -3548,35 +3565,35 @@ export const docs = [
|
|
|
3548
3565
|
"path": "docs/enterprise/README.md",
|
|
3549
3566
|
"title": "Enterprise feature pack",
|
|
3550
3567
|
"section": "Enterprise tier",
|
|
3551
|
-
"markdown": "# Enterprise feature pack\n\n`@svgrid/enterprise` is a paid add-on for `@svgrid/grid`. It bolts onto\nthe same `<SvGrid>` you already have and adds five feature areas: the\nserver-side row model, data export, data import, pivot tables, and the\nKanban, Scheduler and Spreadsheet views of the same grid. (The AI helpers\nare built in and **free** in `@svgrid/grid` - see\n[AI assistant](../help/ai.md).)\n\n\n\n```bash\npnpm add @svgrid/enterprise\n```\n\n```ts\nimport { installEnterprise, setLicenseKey } from '@svgrid/enterprise'\n\nsetLicenseKey('SVENTERPRISE-…') // once, at app startup\nconst pro = installEnterprise(api) // wraps a SvGridApi with Enterprise methods\n```\n\nThat's the whole integration. Every Enterprise helper hangs off the api object\nyou already have; the grid component stays Community.\n\n## What ships in Enterprise\n\n### [Data export](../help/export.md)\n\n`pro.exportData(opts)` writes the current view to Excel (xlsx), PDF,\nCSV, TSV, or HTML from a single call.\n\n**CSV, TSV and JSON export do not need Enterprise** - they are free in\n`@svgrid/grid`, along with copy-to-clipboard. What Enterprise adds is\nxlsx, PDF, styled HTML and XML, plus the unified entry point with\ncolumn resolution and group handling. See\n[the export page](../help/export.md) for the exact split.\n\nThe xlsx writer honours:\n\n- **Cell + row styles** via `opts.styles` - read the same `--sg-*`\n tokens the grid renders with so the file matches the theme.\n- **Page header + footer** lines with text or embedded images\n (`opts.header`, `opts.footer`).\n- **Embedded images** from cell values when columns are listed in\n `opts.imageFields`.\n- **Multiple sheets** in one workbook via `opts.sheets`.\n- **Printable view** via `pro.print(opts)` - opens a new window with\n repeat-on-page headers, cover page, page-size + orientation.\n\nDemos: [56 theme-matched](../../examples/src/demos/56-export-theme-matched.svelte),\n[57 header + footer + logo](../../examples/src/demos/57-export-header-footer-logo.svelte),\n[58 images](../../examples/src/demos/58-export-with-images.svelte),\n[59 multi-sheet](../../examples/src/demos/59-export-multi-sheet.svelte).\n\n### [Data import](../help/import.md)\n\n`pro.importData(opts)` reads an Excel / CSV / TSV / JSON file (or\ninline text), maps columns to your row shape, validates each row, and\nreturns a typed `ImportResult` with `rows`, `errors`, and a `summary`.\nYou decide whether to commit the rows into the grid or preview first.\n\nDemo: [53 Excel import](../../examples/src/demos/53-excel-import.svelte).\n\n### AI assistant - now built-in + free\n\nThe natural-language filter, smart-fill, summarise, classify, chart-this,\nfind-anomalies and natural-language-export helpers moved into the free\n`@svgrid/grid` package. Import them from `@svgrid/grid` and register a provider\nwith `setAIProvider(yourAdapter)` (no model client is bundled - you bring\nOpenAI / Anthropic / Ollama / local). See [AI assistant](../help/ai.md). The\nonly enterprise touch-point is `aiExport`'s write step, which uses this pack's\nexport engine for xlsx / pdf.\n\nDemo: [51 AI assistant](../../examples/src/demos/51-ai-assistant.svelte).\n\n### [Pivot tables](../help/pivot.md)\n\n`createPivotModel(data, config)` (or `pro.pivot.build(config)` against\nthe live api) returns `{ rows, columns }` you feed to a separate\n`<SvGrid>` instance. Supports row + column axes, eight built-in\naggregators (sum/avg/min/max/count/countDistinct/first/last) or\ncustom, grand-total row + column, subtotals, custom axis sort.\n\nDemo: [52 pivot table + designer](../../examples/src/demos/52-pivot-table.svelte).\n\n### [Server-Side Row Model](../help/server/server-grouping.md)\n\n`createServerRowModel(source, options)` runs over the free datasource\ncontract (`ServerDataSource.getRows`) and hands the grid one `rowModel`\nprop: lazy grouping and tree data one level at a time with a block cache\nper level, aggregates, child counts and grand totals, server-side pivot\nwith `SvPivotDesigner` in server mode, transactions on loaded rows,\npaging over the tree, and a selection kept as a rule so select-all\nreaches rows the grid never loaded, with a bulk edit by rule. Flat paging\nand infinite scroll stay free in `@svgrid/grid`.\n\nDemos: [467 one million rows](../../examples/src/demos/467-server-row-model-1m.svelte),\n[468 server pivot](../../examples/src/demos/468-server-pivot.svelte),\n[469 tree data](../../examples/src/demos/469-server-tree-data.svelte),\n[470 transactions](../../examples/src/demos/470-server-transactions.svelte),\n[471 selection by rule](../../examples/src/demos/471-server-selection.svelte),\n[472 request to SQL](../../examples/src/demos/472-server-sql-planner.svelte),\n[473 grouping rules](../../examples/src/demos/473-server-grouping-rules.svelte),\n[482 CRUD with a version check](../../examples/src/demos/482-server-crud.svelte),\n[483 master-detail](../../examples/src/demos/483-server-master-detail.svelte).\n\n### Views of the same grid\n\nThe `board` and `scheduler` props and their config types are free in\n`@svgrid/grid`; what Enterprise ships is the renderer behind each,\nregistered once with `enableBoardView()` or `enableSchedulerView()`\n(`installEnterprise(api)` calls both). The same rows, columns, sort and\nfilter feed every one of them. A [Gantt view](../help/rows/gantt.md) of the\nsame grid is in progress and due on 1 November 2026.\n\n- [Kanban board](../help/rows/kanban-board.md) - cards in lanes, drag-and-drop,\n swimlanes, WIP limits, a card drawer.\n- [Scheduler / calendar](../help/rows/scheduler.md) - month, week, day, agenda\n and timeline views with resources, recurrence and booking rules.\n- [The spreadsheet shell](../help/cells/spreadsheet-shell.md) - `<SvSheet />`\n wraps the grid in a ribbon, formula bar, gutter and sheet tabs over the\n [formula engine](../help/spreadsheet-formulas.md).\n\n## Licensing\n\
|
|
3568
|
+
"markdown": "# Enterprise feature pack\n\n`@svgrid/enterprise` is a paid add-on for `@svgrid/grid`. It bolts onto\nthe same `<SvGrid>` you already have and adds five feature areas: the\nserver-side row model, data export, data import, pivot tables, and the\nKanban, Scheduler and Spreadsheet views of the same grid. (The AI helpers\nare built in and **free** in `@svgrid/grid` - see\n[AI assistant](../help/ai.md).)\n\n\n\n```bash\npnpm add @svgrid/enterprise\n```\n\n```ts\nimport { installEnterprise, setLicenseKey } from '@svgrid/enterprise'\n\nsetLicenseKey('SVENTERPRISE-…') // once, at app startup\nconst pro = installEnterprise(api) // wraps a SvGridApi with Enterprise methods\n```\n\nThat's the whole integration. Every Enterprise helper hangs off the api object\nyou already have; the grid component stays Community.\n\n## What ships in Enterprise\n\n### [Data export](../help/export.md)\n\n`pro.exportData(opts)` writes the current view to Excel (xlsx), PDF,\nCSV, TSV, or HTML from a single call.\n\n**CSV, TSV and JSON export do not need Enterprise** - they are free in\n`@svgrid/grid`, along with copy-to-clipboard. What Enterprise adds is\nxlsx, PDF, styled HTML and XML, plus the unified entry point with\ncolumn resolution and group handling. See\n[the export page](../help/export.md) for the exact split.\n\nThe xlsx writer honours:\n\n- **Cell + row styles** via `opts.styles` - read the same `--sg-*`\n tokens the grid renders with so the file matches the theme.\n- **Page header + footer** lines with text or embedded images\n (`opts.header`, `opts.footer`).\n- **Embedded images** from cell values when columns are listed in\n `opts.imageFields`.\n- **Multiple sheets** in one workbook via `opts.sheets`.\n- **Printable view** via `pro.print(opts)` - opens a new window with\n repeat-on-page headers, cover page, page-size + orientation.\n\nDemos: [56 theme-matched](../../examples/src/demos/56-export-theme-matched.svelte),\n[57 header + footer + logo](../../examples/src/demos/57-export-header-footer-logo.svelte),\n[58 images](../../examples/src/demos/58-export-with-images.svelte),\n[59 multi-sheet](../../examples/src/demos/59-export-multi-sheet.svelte).\n\n### [Data import](../help/import.md)\n\n`pro.importData(opts)` reads an Excel / CSV / TSV / JSON file (or\ninline text), maps columns to your row shape, validates each row, and\nreturns a typed `ImportResult` with `rows`, `errors`, and a `summary`.\nYou decide whether to commit the rows into the grid or preview first.\n\nDemo: [53 Excel import](../../examples/src/demos/53-excel-import.svelte).\n\n### AI assistant - now built-in + free\n\nThe natural-language filter, smart-fill, summarise, classify, chart-this,\nfind-anomalies and natural-language-export helpers moved into the free\n`@svgrid/grid` package. Import them from `@svgrid/grid` and register a provider\nwith `setAIProvider(yourAdapter)` (no model client is bundled - you bring\nOpenAI / Anthropic / Ollama / local). See [AI assistant](../help/ai.md). The\nonly enterprise touch-point is `aiExport`'s write step, which uses this pack's\nexport engine for xlsx / pdf.\n\nDemo: [51 AI assistant](../../examples/src/demos/51-ai-assistant.svelte).\n\n### [Pivot tables](../help/pivot.md)\n\n`createPivotModel(data, config)` (or `pro.pivot.build(config)` against\nthe live api) returns `{ rows, columns }` you feed to a separate\n`<SvGrid>` instance. Supports row + column axes, eight built-in\naggregators (sum/avg/min/max/count/countDistinct/first/last) or\ncustom, grand-total row + column, subtotals, custom axis sort.\n\nDemo: [52 pivot table + designer](../../examples/src/demos/52-pivot-table.svelte).\n\n### [Server-Side Row Model](../help/server/server-grouping.md)\n\n`createServerRowModel(source, options)` runs over the free datasource\ncontract (`ServerDataSource.getRows`) and hands the grid one `rowModel`\nprop: lazy grouping and tree data one level at a time with a block cache\nper level, aggregates, child counts and grand totals, server-side pivot\nwith `SvPivotDesigner` in server mode, transactions on loaded rows,\npaging over the tree, and a selection kept as a rule so select-all\nreaches rows the grid never loaded, with a bulk edit by rule. Flat paging\nand infinite scroll stay free in `@svgrid/grid`.\n\nDemos: [467 one million rows](../../examples/src/demos/467-server-row-model-1m.svelte),\n[468 server pivot](../../examples/src/demos/468-server-pivot.svelte),\n[469 tree data](../../examples/src/demos/469-server-tree-data.svelte),\n[470 transactions](../../examples/src/demos/470-server-transactions.svelte),\n[471 selection by rule](../../examples/src/demos/471-server-selection.svelte),\n[472 request to SQL](../../examples/src/demos/472-server-sql-planner.svelte),\n[473 grouping rules](../../examples/src/demos/473-server-grouping-rules.svelte),\n[482 CRUD with a version check](../../examples/src/demos/482-server-crud.svelte),\n[483 master-detail](../../examples/src/demos/483-server-master-detail.svelte),\n[500 queries in a Web Worker](../../examples/src/demos/500-worker-data-source.svelte).\n\n### Views of the same grid\n\nThe `board` and `scheduler` props and their config types are free in\n`@svgrid/grid`; what Enterprise ships is the renderer behind each,\nregistered once with `enableBoardView()` or `enableSchedulerView()`\n(`installEnterprise(api)` calls both). The same rows, columns, sort and\nfilter feed every one of them. A [Gantt view](../help/rows/gantt.md) of the\nsame grid is in progress and due on 1 November 2026.\n\n- [Kanban board](../help/rows/kanban-board.md) - cards in lanes, drag-and-drop,\n swimlanes, WIP limits, a card drawer.\n- [Scheduler / calendar](../help/rows/scheduler.md) - month, week, day, agenda\n and timeline views with resources, recurrence and booking rules.\n- [The spreadsheet shell](../help/cells/spreadsheet-shell.md) - `<SvSheet />`\n wraps the grid in a ribbon, formula bar, gutter and sheet tabs over the\n [formula engine](../help/spreadsheet-formulas.md).\n\n## Licensing\n\nYou can evaluate the pack without asking for a key. Until a valid\nlicense key is set, everything still functions, and loading the\nEnterprise module displays a watermark on the grid and a console message\nabout the Enterprise package. Set a key once at app startup and both\ndisappear:\n\n```ts\nimport { setLicenseKey } from '@svgrid/enterprise'\nsetLicenseKey('SVENTERPRISE-XXXX-XXXX-XXXX')\n```\n\nFor dev builds, the demos use `setLicenseKey('SVENTERPRISE-DEV-DEMO')` to\nsuppress the watermark in screenshots.\n\nThe pack comes in two editions, both covering unlimited production apps.\n**Grid** is everything on this page except the spreadsheet and Studio;\n**Suite** is all of it. A key names its edition, and a key that predates\neditions reads as Suite. A Grid key that opens `<SvSheet>` is treated the\nsame way as an unlicensed build: the feature runs, with the watermark and a\nconsole notice naming the edition that covers it.\n\nPricing and the OEM tier for redistribution: <https://svgrid.com/pricing/>.\nSee also [licensing](./licensing.md).\n\n## How Enterprise integrates\n\n```ts\nimport { SvGrid, type SvGridApi } from '@svgrid/grid'\nimport { installEnterprise, setLicenseKey, type EnterpriseGridApi } from '@svgrid/enterprise'\n\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\n\nlet api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\n```\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n onApiReady={(next: SvGridApi<typeof features, Order>) => {\n api = installEnterprise(next)\n }}\n/>\n\n<button onclick={() => api?.exportData({ format: 'xlsx', filename: 'orders' })}>\n Export to Excel\n</button>\n```\n\n`installEnterprise` mutates and returns the same api object - existing\nreferences keep working. The `EnterpriseGridApi` type extends `SvGridApi` so\nthe grid's existing methods are still there alongside the new ones.\n\n## Tree-shaking\n\nEach Enterprise module is a separate subpath export:\n\n```ts\nimport { exportGrid } from '@svgrid/enterprise/export' // export only\nimport { importData } from '@svgrid/enterprise/import' // import only\nimport { createPivotModel } from '@svgrid/enterprise/pivot' // pivot only\n// AI helpers are free + built in: import { aiFilter } from '@svgrid/grid'\n```\n\nIf you only need export, the AI provider plumbing, pivot engine, and\nimport parser don't ship. The `@svgrid/enterprise` barrel import is\nconvenient; the subpaths are smaller.\n\n## Peer dependencies\n\n| Feature | Peer dep | Optional? |\n| ------- | -------------------- | ------------------------------------------ |\n| xlsx | `jszip` | Yes - only loaded when xlsx is exported. |\n| pdf | `pdfmake` | Yes - only loaded when pdf is exported. |\n| import | `jszip` (xlsx only) | Yes - only for xlsx import. |\n| AI | - | -. You bring your own provider client. |\n| pivot | - | -. Pure TypeScript, no runtime deps. |\n\nInstall only what you need:\n\n```bash\npnpm add @svgrid/enterprise jszip # community + Enterprise + xlsx\npnpm add @svgrid/enterprise jszip pdfmake # also pdf\n```\n\n## See also\n\n- [Getting started](../getting-started.md) - if you haven't seen the\n Community walkthrough yet.\n- [Help index](../help/index.md) - all topic pages including the\n Enterprise pages.\n- [Missing features](../help/missing-features.md) - the honest gap list.\n"
|
|
3552
3569
|
},
|
|
3553
3570
|
{
|
|
3554
3571
|
"slug": "enterprise/evaluation",
|
|
3555
3572
|
"path": "docs/enterprise/evaluation.md",
|
|
3556
3573
|
"title": "Enterprise evaluation",
|
|
3557
3574
|
"section": "Enterprise tier",
|
|
3558
|
-
"markdown": "# Enterprise evaluation\n\
|
|
3575
|
+
"markdown": "# Enterprise evaluation\n\nYou can try every `@svgrid/enterprise` feature without a key. Install it\nand use it. Nothing is locked; the page shows a small watermark and logs\na console message, and that is the only difference.\n\n\n\n## 1. Install\n\n```bash\nnpm install @svgrid/grid @svgrid/enterprise\nnpm install jszip pdfmake # only needed for Excel and PDF export\n```\n\n## 2. Add Enterprise to a grid\n\n```svelte\n<script>\n import { SvGrid } from '@svgrid/grid'\n import { installEnterprise, setLicenseKey } from '@svgrid/enterprise'\n\n setLicenseKey('SVENTERPRISE-DEV-DEMO') // local dev key, removes the watermark\n\n let api = $state(null)\n\n const rows = [\n { name: 'Ada Lovelace', team: 'Engineering', salary: 145000 },\n { name: 'Alan Turing', team: 'Research', salary: 160000 },\n ]\n const columns = [\n { field: 'name', header: 'Name' },\n { field: 'team', header: 'Team' },\n { field: 'salary', header: 'Salary' },\n ]\n</script>\n\n<SvGrid data={rows} {columns} onApiReady={(next) => (api = installEnterprise(next))} />\n\n<button onclick={() => api?.exportData({ format: 'xlsx', filename: 'team' })}>\n Export to Excel\n</button>\n```\n\nThe `<SvGrid>` component itself stays the same. `installEnterprise()` adds\nthe Enterprise methods to the grid API: `exportData`, `importData`,\n`print`, pivot and AI.\n\nFor a complete page with themes, PDF, CSV and print, see\n[Enterprise getting started](./getting-started.md).\n\n## 3. Try the features\n\nThe [demo gallery](https://svgrid.com/demos/) has a working demo for every\nEnterprise feature, with the source next to it. These pages cover each\none in detail:\n\n- [Export](../help/export.md) - Excel, PDF, CSV\n- [Import](../help/import.md) - column mapping and per-row validation\n\n## Without a key\n\nYou can skip `setLicenseKey()` entirely. Everything still runs, and you\nsee:\n\n- a small \"www.svgrid.com\" watermark on each grid, which fades after 5\n seconds\n- one console message per page load\n- a one-time upgrade card the first time you call an Enterprise feature\n\nNone of these make network calls or write cookies or storage (see\n[security](../help/security.md)).\n\nThis works the same on localhost, staging, or a demo deployment. You don't\nneed to ask anyone for a trial key. When you buy a license, call\n`setLicenseKey()` with your key and the watermark goes away.\n\n## Buying\n\n- **Grid Developer License**, $599 per developer: the enterprise grid.\n- **Suite Developer License**, $999 per developer: adds the spreadsheet\n (`<SvSheet>`) and Studio.\n\nBoth cover unlimited apps and are perpetual, with a year of updates and\nsupport. For 5+ developers, a PO, or redistribution as an SDK, contact\nsales. Details on the [pricing page](https://svgrid.com/pricing/).\n\n## See also\n\n- [Enterprise getting started](./getting-started.md) - a complete working page\n- [Enterprise licensing](./licensing.md) - key formats, seats, renewals\n- [Migration guides](../help/migrating-from-ag-grid.md) - moving from another grid\n"
|
|
3559
3576
|
},
|
|
3560
3577
|
{
|
|
3561
3578
|
"slug": "enterprise/getting-started",
|
|
3562
3579
|
"path": "docs/enterprise/getting-started.md",
|
|
3563
3580
|
"title": "Enterprise getting started: a complete example",
|
|
3564
3581
|
"section": "Enterprise tier",
|
|
3565
|
-
"markdown": "# Enterprise getting started: a complete example\n\nOne page, one file, from an empty folder to a working grid with Excel, PDF and\nCSV export. Nothing is elided - every command and every line below was run\nend to end against the published packages.\n\nIf you already have a project, skip to [step 2](#2-install).\n\n## 1. Create a project\n\n```bash\nnpx sv create svgrid-trial --template minimal --types ts --no-add-ons\ncd svgrid-trial\n```\n\nThose flags skip every interactive prompt, so the sequence is copy-pasteable.\nFor a plain Vite app instead, `npm create @svgrid@latest` scaffolds one with\nthe grid already wired in - the component below drops into either.\n\n## 2. Install\n\n```bash\nnpm install @svgrid/grid @svgrid/enterprise\nnpm install jszip pdfmake # optional peers, for Excel and PDF export\n```\n\n`jszip` and `pdfmake` are optional peer dependencies, lazy-loaded the first\ntime you actually call an export. Skip them if you only need CSV, TSV or HTML;\n`exportData` throws a message naming the missing package if you call a format\nwhose peer is absent.\n\n**No build configuration is required.** If you are on `@svgrid/enterprise`\n2.5.x or earlier, see [older versions](#older-versions) below - those releases\nshipped TypeScript source and needed two `optimizeDeps` entries in\n`vite.config.js`.\n\n## 3. The component\n\nReplace the contents of `src/routes/+page.svelte` (or `src/App.svelte` in a\nVite project) with this. It is self-contained: licence, theme, grid and\nexports, with nothing else to wire up.\n\n```svelte\n<script>\n import { SvGrid } from '@svgrid/grid'\n import { installEnterprise, setLicenseKey } from '@svgrid/enterprise'\n // One of 20 themes that ship with the package. Swap the id for material,\n // nord, dracula, fluent, carbon, ag-alpine, and so on. Each carries a full\n // light AND dark palette; dark activates on <html data-theme=\"dark\">.\n import '@svgrid/grid/themes/shadcn.css'\n\n // Once, before any Enterprise feature runs.
|
|
3582
|
+
"markdown": "# Enterprise getting started: a complete example\n\nOne page, one file, from an empty folder to a working grid with Excel, PDF and\nCSV export. Nothing is elided - every command and every line below was run\nend to end against the published packages.\n\nIf you already have a project, skip to [step 2](#2-install).\n\n## 1. Create a project\n\n```bash\nnpx sv create svgrid-trial --template minimal --types ts --no-add-ons\ncd svgrid-trial\n```\n\nThose flags skip every interactive prompt, so the sequence is copy-pasteable.\nFor a plain Vite app instead, `npm create @svgrid@latest` scaffolds one with\nthe grid already wired in - the component below drops into either.\n\n## 2. Install\n\n```bash\nnpm install @svgrid/grid @svgrid/enterprise\nnpm install jszip pdfmake # optional peers, for Excel and PDF export\n```\n\n`jszip` and `pdfmake` are optional peer dependencies, lazy-loaded the first\ntime you actually call an export. Skip them if you only need CSV, TSV or HTML;\n`exportData` throws a message naming the missing package if you call a format\nwhose peer is absent.\n\n**No build configuration is required.** If you are on `@svgrid/enterprise`\n2.5.x or earlier, see [older versions](#older-versions) below - those releases\nshipped TypeScript source and needed two `optimizeDeps` entries in\n`vite.config.js`.\n\n## 3. The component\n\nReplace the contents of `src/routes/+page.svelte` (or `src/App.svelte` in a\nVite project) with this. It is self-contained: licence, theme, grid and\nexports, with nothing else to wire up.\n\n```svelte\n<script>\n import { SvGrid } from '@svgrid/grid'\n import { installEnterprise, setLicenseKey } from '@svgrid/enterprise'\n // One of 20 themes that ship with the package. Swap the id for material,\n // nord, dracula, fluent, carbon, ag-alpine, and so on. Each carries a full\n // light AND dark palette; dark activates on <html data-theme=\"dark\">.\n import '@svgrid/grid/themes/shadcn.css'\n\n // Once, before any Enterprise feature runs. Optional while you evaluate:\n // without a key everything still works and shows a watermark.\n setLicenseKey('SVENTERPRISE-DEV-DEMO')\n\n let api = $state(null)\n let status = $state('')\n\n // Capabilities are boolean props - `sortable`, `filterable`, `editable`,\n // `groupable`, `pageable` - and each injects the feature it needs. For finer\n // control, register features explicitly with `tableFeatures({ ... })` and\n // pass them as `features`.\n\n // Starts 'light' rather than reading the DOM, so this file is safe to render\n // on the server too. The effect below syncs it once we are in the browser.\n let theme = $state('light')\n\n $effect(() => {\n const saved = localStorage.getItem('theme')\n if (saved) theme = saved\n })\n\n $effect(() => {\n document.documentElement.dataset.theme = theme\n try {\n localStorage.setItem('theme', theme)\n } catch (e) {\n // Private mode / storage disabled. The toggle still works for this tab.\n }\n })\n\n // Your data. Swap for a fetch() in onMount, a load function, or props.\n let rows = $state([\n { id: 1, name: 'Ada Lovelace', team: 'Engineering', salary: 145000, active: true },\n { id: 2, name: 'Alan Turing', team: 'Research', salary: 160000, active: true },\n { id: 3, name: 'Grace Hopper', team: 'Engineering', salary: 152000, active: false },\n { id: 4, name: 'Katherine Johnson', team: 'Data', salary: 138000, active: true },\n { id: 5, name: 'Edsger Dijkstra', team: 'Research', salary: 149000, active: false },\n ])\n\n const columns = [\n { field: 'name', header: 'Name', editorType: 'text', width: 200 },\n { field: 'team', header: 'Team', editorType: 'text', width: 150 },\n {\n field: 'salary',\n header: 'Salary',\n width: 130,\n align: 'right',\n format: { type: 'currency', currency: 'USD' },\n },\n { field: 'active', header: 'Active', editorType: 'checkbox', width: 90 },\n ]\n\n // installEnterprise() augments the grid API with the Pro methods:\n // exportData, importData, print, pivot, AI.\n async function run(label, fn) {\n if (!api) return\n try {\n await fn()\n status = `${label} ready`\n } catch (err) {\n status = `${label} failed: ${err instanceof Error ? err.message : String(err)}`\n }\n }\n</script>\n\n<main>\n <header>\n <div>\n <h1>SvGrid Enterprise</h1>\n <p>Sort, filter, select, and double-click a cell to edit.</p>\n </div>\n <button\n type=\"button\"\n onclick={() => (theme = theme === 'dark' ? 'light' : 'dark')}\n aria-label=\"Switch to {theme === 'dark' ? 'light' : 'dark'} mode\"\n >\n {theme === 'dark' ? 'Light' : 'Dark'}\n </button>\n </header>\n\n <div class=\"grid-shell\">\n <SvGrid\n data={rows}\n {columns}\n sortable\n filterable\n editable\n selectionMode=\"row\"\n showRowSelection={true}\n showRowNumbers={true}\n rowHeight={38}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => (api = installEnterprise(next))}\n />\n </div>\n\n <div class=\"actions\">\n <button type=\"button\" onclick={() => run('CSV', () => api.exportData({ format: 'csv', filename: 'team' }))}>\n Export CSV\n </button>\n <button type=\"button\" onclick={() => run('Excel', () => api.exportData({ format: 'xlsx', filename: 'team' }))}>\n Export Excel\n </button>\n <button type=\"button\" onclick={() => run('PDF', () => api.exportData({ format: 'pdf', filename: 'team' }))}>\n Export PDF\n </button>\n <button type=\"button\" onclick={() => run('Print', () => api.print())}>Print</button>\n {#if status}<span class=\"status\">{status}</span>{/if}\n </div>\n\n <p class=\"hint\">\n Export respects the current sort, filter and grouping. Change the theme\n import at the top of this file to re-skin the grid and this page together.\n </p>\n</main>\n\n<style>\n /* Page chrome reads the same --sg-* tokens as the grid, so it re-themes with it. */\n :global(body) {\n margin: 0;\n background: var(--sg-bg);\n color: var(--sg-fg);\n font-family: var(--sg-font, system-ui, sans-serif);\n }\n\n main { max-width: 760px; margin: 3rem auto; padding: 0 1rem; }\n header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }\n h1 { margin: 0; font-size: 1.4rem; }\n p { color: var(--sg-muted); }\n .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-top: 1rem; }\n\n button {\n flex: none;\n padding: 0.4rem 0.8rem;\n border: 1px solid var(--sg-border);\n border-radius: var(--sg-radius, 6px);\n background: var(--sg-bg-subtle, transparent);\n color: var(--sg-fg);\n font: inherit;\n font-size: 0.85rem;\n cursor: pointer;\n }\n button:hover { background: var(--sg-row-hover-bg); }\n button:focus-visible { outline: 2px solid var(--sg-accent); outline-offset: 2px; }\n\n .status { font-size: 0.82rem; color: var(--sg-muted); }\n .grid-shell { height: 320px; }\n .hint { font-size: 0.85rem; }\n</style>\n```\n\n## 4. Run it\n\n```bash\nnpm run dev\n```\n\nOpen <http://localhost:5173>. You should get a five-row grid: click a header to\nsort, use the funnel for Excel-style filtering, double-click a cell to edit,\nand the four buttons write real files.\n\n## What the three pieces do\n\n| Line | Why it's there |\n| --- | --- |\n| `setLicenseKey(...)` | Runs once, before any Enterprise call. Without it the pack still works, but watermarks and nudges the app. See [licensing](./licensing.md). |\n| `installEnterprise(next)` | Wraps the `SvGridApi` from `onApiReady` and returns it with `exportData`, `importData`, `print`, pivot and AI attached. The `<SvGrid>` component itself stays Community. |\n| `import '@svgrid/grid/themes/shadcn.css'` | Optional. Declares the `--sg-*` tokens for one of 20 presets. Without it the grid still renders, using the built-in fallbacks. |\n\n## Server-side rendering\n\nThe component above is SSR-safe as written: `theme` starts at a literal, and\nevery DOM and `localStorage` access sits inside `$effect`, which only runs in\nthe browser. No `export const ssr = false` is needed.\n\nIf you move DOM access to module scope or into component initialisation, it\nwill run on the server and throw. Keep it in `$effect` or `onMount`.\n\n## Older versions\n\n`@svgrid/enterprise` 2.5.x and earlier shipped TypeScript source rather than a\nbuilt bundle. Vite's dependency pre-bundler cannot parse the `.svelte.ts` rune\nmodules in it, so those versions need two entries in `vite.config.js`:\n\n```js\noptimizeDeps: {\n // Without this the dev server fails to start:\n // RolldownError ... Unexpected token (on `import type`)\n exclude: ['@svgrid/grid', '@svgrid/enterprise'],\n // Excluding it also stops ITS imports being pre-bundled, and jszip and\n // pdfmake are CommonJS. Without this, xlsx and pdf export fail with\n // \"JSZip is not a constructor\" / \"pdfMake.createPdf is not a function\".\n include: ['jszip', 'pdfmake/build/pdfmake', 'pdfmake/build/vfs_fonts'],\n},\n```\n\n2.6.0 moved the package to a built `dist`, so neither entry is needed. Upgrading\nis the better fix.\n\n## Next\n\n- [Evaluation](./evaluation.md) - what shows without a key, and how to remove it.\n- [Licensing](./licensing.md) - key formats, seats, renewals.\n- [Data export](../help/export.md) - styles, headers, images, multi-sheet.\n- [Data import](../help/import.md) - column mapping and per-row validation.\n"
|
|
3566
3583
|
},
|
|
3567
3584
|
{
|
|
3568
3585
|
"slug": "enterprise/licensing",
|
|
3569
3586
|
"path": "docs/enterprise/licensing.md",
|
|
3570
3587
|
"title": "Enterprise licensing",
|
|
3571
3588
|
"section": "Enterprise tier",
|
|
3572
|
-
"markdown": "# Enterprise licensing\n\n`@svgrid/enterprise` comes with a **30-day evaluation period**: an\nevaluation key, issued on request, runs the full package without the\nwatermark for 30 days. Loaded without a license key, the Enterprise\nmodule displays a watermark in the bottom-right corner of the grid and a\nconsole message about the Enterprise package; every feature still works.\nSet a key once at app startup and both disappear.\n\n\n\n## Setting the key\n\n```ts\nimport { setLicenseKey } from '@svgrid/enterprise'\n\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\n```\n\nCall this before the first `<SvGrid>` mounts. In Vite/SvelteKit,\nexpose the key as `VITE_SVPRO_KEY=SVENTERPRISE-...` in your `.env` (NOT\nchecked in) - or read it from your config service.\n\n## License tiers\n\nEvery Enterprise license is a **perpetual license** that includes **1 year of\nupdates and support**. The price shown **renews automatically each year**\nto keep updates and support active; **cancel anytime** and you keep every\nversion released during your paid term.\n\n| License | What it covers | Price (per developer) | Support |\n| ---------------------------------- | ------------------------------------------------------------------ | --------------------- | ----------------------------------------- |\n| **Grid Developer License** | Enterprise grid features. No spreadsheet, no Studio | **$599** | Email (next business day) + private Slack |\n| **Suite Developer License** | Everything in Grid, plus the spreadsheet and Studio | **$999** | Email (next business day) + private Slack |\n| **Enterprise / volume** | Suite, site or organisation-wide | Custom quote | Priority + named contact, NDA / PO |\n| **OEM / redistribution** | Shipping SvGrid to third parties as a component or app builder | Custom quote | Priority + named contact, NDA / PO |\n\nBoth developer licenses cover an **unlimited number of production apps** in your\norganisation. The line between them is the feature set, not the app count.\n\n**Grid ($599)** is the enterprise grid: server-side row model, grouping and tree\ndata, pivot, export and import, print, the advanced filter, alerts, the selection\nbar, and the Kanban, scheduler and Gantt renderers.\n\n**Suite ($999)** adds the two things that are their own products:\n\n- the **spreadsheet**: `<SvSheet>`, the `<sv-sheet>` element, the formula engine,\n and the `.xlsx` / `.xls` / `.ods` / `.csv` readers and writers;\n- **Studio**: the designer, the code generator, and the SQL data sources.\n\n> Priced **per developer** - engineers who write or modify code that imports\n> `@svgrid/enterprise`. Production seats / end users are unlimited. One license key\n> activates every grid in scope - no per-page or per-component accounting.\n> Teams of 5+ and multi-year terms get volume discounts; email\n> `sales@jqwidgets.com`.\n\n### When you need an OEM license\n\nA developer seat covers your own application, including a SaaS product your\ncustomers pay for. It does not cover handing SvGrid to third parties as\nsomething they build with: shipping it as a component or SDK, embedding it in an\napp builder or low-code platform where your customers author their own screens,\nor reselling it white-labelled. Those need an OEM license, priced separately from\nseats. Section 4A of the [EULA](/docs/legal/EULA) has the exact test. If you are\nnot sure which side of the line you are on, email `sales@jqwidgets.com` and\ndescribe the product; the answer is usually one message.\n\n## License key format\n\n```\nSVENTERPRISE-SUITE-XXXX-XXXX-XXXX-XXXX\n│ │\n│ └── edition: GRID or SUITE\n└── prefix the runtime recognises; the rest identifies your license\n```\n\nA key issued before editions existed carries no edition segment. Those keys read\nas Suite, so nothing an existing customer already ships changes.\n\nThe check is purely client-side and **no network call** is ever made to\nvalidate, so air-gapped deployments work out of the box.\n\nIt is also deliberately not cryptography. The runtime classifies the key\nstring - prefix recognised, which edition it names, on the revoked list, a\n`DEV` / `EVAL` sentinel, or a paid key - and nothing more (`checkLicenseKey` in\n`packages/enterprise/src/license-core.ts`). Anyone with devtools can read a key\nout of a deployed bundle, and an unlicensed build still runs; it just shows a\nwatermark and logs a one-time notice. The license is a legal agreement, not a\ntechnical lock, and we would rather say so than imply a DRM scheme that isn't\nthere. Keys are revocable: a key we revoke stops being accepted in later\nreleases.\n\n## Per-environment keys\n\nDifferent keys for different deployment stages so revoking is surgical:\n\n```ts\nconst KEY = {\n development: import.meta.env.VITE_SVPRO_DEV_KEY,\n staging: import.meta.env.VITE_SVPRO_STAGING_KEY,\n production: import.meta.env.VITE_SVPRO_PROD_KEY,\n}[import.meta.env.MODE] ?? ''\n\nsetLicenseKey(KEY)\n```\n\n## Dev / demo key\n\nFor demos + integration tests, use the published sentinel:\n\n```ts\nsetLicenseKey('SVENTERPRISE-DEV-DEMO')\n```\n\nThis suppresses the watermark for local development. **Do not ship\nthis key to production** - it's a dev-only convenience and will be\nrevoked in any production-domain validation pipeline.\n\n## What happens without a key\n\n| Surface | Behaviour |\n| --------------------------- | ---------------------------------------------------------- |\n| Grid render | Works. |\n| Editing, sort, filter | Works. |\n| `pro.exportData(...)` | Works. |\n| `pro.importData(...)` | Works. |\n| `pro.ai.*` | Works. |\n| `createPivotModel(...)` | Works. |\n| Studio: designer / panels | Works. |\n| Studio: `createSqlDataSource` | Works. |\n| Studio: MCP generator | Works; output carries a one-line notice. |\n| Watermark | Visible on every grid instance. |\n| Console message | About the Enterprise package, logged once per page load. |\n\nThat is the evaluation period: every feature, with the watermark and the\nconsole message until a key is set, and a 30-day evaluation key that\nremoves them while you decide. Once you're sold, drop in a key.\n\n## A Grid key on a Suite feature\n\nThe same rule, for the same reason. A Grid Developer License that opens\n`<SvSheet>`, calls the formula engine, or reaches a Studio data source gets the\nwatermark and a one-time console notice naming the feature. Nothing stops\nworking and nothing is hidden, so a Grid-tier team can try the spreadsheet in\ntheir own app before deciding to upgrade, exactly as an unlicensed team can.\n\n```ts\nimport { licenseCovers } from '@svgrid/enterprise'\n\nlicenseCovers('spreadsheet') // false on a GRID key, true on SUITE\n```\n\nUse `licenseCovers` if you want to hide a Suite-only entry point in your own UI\nrather than let it nudge.\n\n## Studio (data-app generator)\n\nThe Studio - the schema designer, edit panel, master-detail, the SQL,\nREST and Supabase data sources, and the AI generator - is covered by the\n**Suite Developer License**. There is no Studio-only key and no\nper-feature entitlement inside it: a Suite key covers the whole Studio\ntogether with the spreadsheet and the grid. Loaded without a Suite key,\nevery Studio surface still works and displays the watermark and the\nconsole message about the Enterprise package, as the rest of the package\ndoes.\n\nIt has two places you set the key, because it runs in two places:\n\n**1. In your app (the browser)** - the same `setLicenseKey()` you\nalready call. The designer, edit panel, master-detail, and data sources\nall read it. Nothing new to do.\n\n```ts\nimport { setLicenseKey } from '@svgrid/enterprise'\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\n```\n\n**2. In the AI generator (the MCP server)** - the generator runs in a\nNode process (Claude Code / Desktop), so it reads the key from the\n`SVGRID_LICENSE_KEY` environment variable in your MCP config. Set it\nonce:\n\n```jsonc\n{\n \"mcpServers\": {\n \"svgrid\": {\n \"command\": \"npx\",\n \"args\": [\"@svgrid/mcp\"],\n \"env\": { \"SVGRID_LICENSE_KEY\": \"SVENTERPRISE-...\" }\n }\n }\n}\n```\n\nWithout it, the generator still produces code - it just prepends a\none-line commercial notice, and the generated app carries the usual\nwatermark until you call `setLicenseKey()` in it. Same honor-system,\nsame key format, no network calls.\n\n## License renewal\n\nThe license itself is perpetual; what renews each year is your\n**updates-and-support term**. It renews automatically until you cancel\n(cancel anytime). The key keeps validating every version released during\na paid term, and if you cancel or let the term lapse, your installed\nversion keeps working - you just stop receiving new releases and support.\n\n- 60 days before the term ends: yellow nudge on the watermark\n- 30 days: orange\n- 7 days: red\n- After the term ends (if you cancelled): features keep working; only\n versions released after your paid term need a renewed term\n\nWe never disable working features on a paying customer.\n\n## Audit trail\n\nEach license key has a stable ID embedded. The same key activates as\nmany builds as you want; the ID is what we cross-reference against\nyour subscription in support tickets. **No telemetry is sent** -\nsupport uses the ID you give us, not anything we phoned home for.\n\n## See also\n\n- [Enterprise evaluation](./evaluation.md) - 30-day evaluation flow\n- [Enterprise support](./support.md) - SLAs, escalation, contact channels\n- [Pricing](https://svgrid.com/pricing/) - canonical pricing source\n"
|
|
3589
|
+
"markdown": "# Enterprise licensing\n\nYou can evaluate `@svgrid/enterprise` without asking for a key. Loaded\nwithout a license key, the Enterprise module displays a watermark in the\nbottom-right corner of the grid and a console message about the\nEnterprise package; every feature still works. Set a key once at app\nstartup and both disappear.\n\n\n\n## Setting the key\n\n```ts\nimport { setLicenseKey } from '@svgrid/enterprise'\n\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\n```\n\nCall this before the first `<SvGrid>` mounts. In Vite/SvelteKit,\nexpose the key as `VITE_SVPRO_KEY=SVENTERPRISE-...` in your `.env` (NOT\nchecked in) - or read it from your config service.\n\n## License tiers\n\nEvery Enterprise license is a **perpetual license** that includes **1 year of\nupdates and support**. The price shown **renews automatically each year**\nto keep updates and support active; **cancel anytime** and you keep every\nversion released during your paid term.\n\n| License | What it covers | Price (per developer) | Support |\n| ---------------------------------- | ------------------------------------------------------------------ | --------------------- | ----------------------------------------- |\n| **Grid Developer License** | Enterprise grid features. No spreadsheet, no Studio | **$599** | Email (next business day) + private Slack |\n| **Suite Developer License** | Everything in Grid, plus the spreadsheet and Studio | **$999** | Email (next business day) + private Slack |\n| **Enterprise / volume** | Suite, site or organisation-wide | Custom quote | Priority + named contact, NDA / PO |\n| **OEM / redistribution** | Shipping SvGrid to third parties as a component or app builder | Custom quote | Priority + named contact, NDA / PO |\n\nBoth developer licenses cover an **unlimited number of production apps** in your\norganisation. The line between them is the feature set, not the app count.\n\n**Grid ($599)** is the enterprise grid: server-side row model, grouping and tree\ndata, pivot, export and import, print, the advanced filter, alerts, the selection\nbar, and the Kanban, scheduler and Gantt renderers.\n\n**Suite ($999)** adds the two things that are their own products:\n\n- the **spreadsheet**: `<SvSheet>`, the `<sv-sheet>` element, the formula engine,\n and the `.xlsx` / `.xls` / `.ods` / `.csv` readers and writers;\n- **Studio**: the designer, the code generator, and the SQL data sources.\n\n> Priced **per developer** - engineers who write or modify code that imports\n> `@svgrid/enterprise`. Production seats / end users are unlimited. One license key\n> activates every grid in scope - no per-page or per-component accounting.\n> Teams of 5+ and multi-year terms get volume discounts; email\n> `sales@jqwidgets.com`.\n\n### When you need an OEM license\n\nA developer seat covers your own application, including a SaaS product your\ncustomers pay for. It does not cover handing SvGrid to third parties as\nsomething they build with: shipping it as a component or SDK, embedding it in an\napp builder or low-code platform where your customers author their own screens,\nor reselling it white-labelled. Those need an OEM license, priced separately from\nseats. Section 4A of the [EULA](/docs/legal/EULA) has the exact test. If you are\nnot sure which side of the line you are on, email `sales@jqwidgets.com` and\ndescribe the product; the answer is usually one message.\n\n## License key format\n\n```\nSVENTERPRISE-SUITE-XXXX-XXXX-XXXX-XXXX\n│ │\n│ └── edition: GRID or SUITE\n└── prefix the runtime recognises; the rest identifies your license\n```\n\nA key issued before editions existed carries no edition segment. Those keys read\nas Suite, so nothing an existing customer already ships changes.\n\nThe check is purely client-side and **no network call** is ever made to\nvalidate, so air-gapped deployments work out of the box.\n\nIt is also deliberately not cryptography. The runtime classifies the key\nstring - prefix recognised, which edition it names, on the revoked list, a\n`DEV` / `EVAL` sentinel, or a paid key - and nothing more (`checkLicenseKey` in\n`packages/enterprise/src/license-core.ts`). Anyone with devtools can read a key\nout of a deployed bundle, and an unlicensed build still runs; it just shows a\nwatermark and logs a one-time notice. The license is a legal agreement, not a\ntechnical lock, and we would rather say so than imply a DRM scheme that isn't\nthere. Keys are revocable: a key we revoke stops being accepted in later\nreleases.\n\n## Per-environment keys\n\nDifferent keys for different deployment stages so revoking is surgical:\n\n```ts\nconst KEY = {\n development: import.meta.env.VITE_SVPRO_DEV_KEY,\n staging: import.meta.env.VITE_SVPRO_STAGING_KEY,\n production: import.meta.env.VITE_SVPRO_PROD_KEY,\n}[import.meta.env.MODE] ?? ''\n\nsetLicenseKey(KEY)\n```\n\n## Dev / demo key\n\nFor demos + integration tests, use the published sentinel:\n\n```ts\nsetLicenseKey('SVENTERPRISE-DEV-DEMO')\n```\n\nThis suppresses the watermark for local development. **Do not ship\nthis key to production** - it's a dev-only convenience and will be\nrevoked in any production-domain validation pipeline.\n\n## What happens without a key\n\n| Surface | Behaviour |\n| --------------------------- | ---------------------------------------------------------- |\n| Grid render | Works. |\n| Editing, sort, filter | Works. |\n| `pro.exportData(...)` | Works. |\n| `pro.importData(...)` | Works. |\n| `pro.ai.*` | Works. |\n| `createPivotModel(...)` | Works. |\n| Studio: designer / panels | Works. |\n| Studio: `createSqlDataSource` | Works. |\n| Studio: MCP generator | Works; output carries a one-line notice. |\n| Watermark | Visible on every grid instance. |\n| Console message | About the Enterprise package, logged once per page load. |\n\nThat is the evaluation: every feature, with the watermark and the\nconsole message until a key is set. Once you're sold, drop in a key.\n\n## A Grid key on a Suite feature\n\nThe same rule, for the same reason. A Grid Developer License that opens\n`<SvSheet>`, calls the formula engine, or reaches a Studio data source gets the\nwatermark and a one-time console notice naming the feature. Nothing stops\nworking and nothing is hidden, so a Grid-tier team can try the spreadsheet in\ntheir own app before deciding to upgrade, exactly as an unlicensed team can.\n\n```ts\nimport { licenseCovers } from '@svgrid/enterprise'\n\nlicenseCovers('spreadsheet') // false on a GRID key, true on SUITE\n```\n\nUse `licenseCovers` if you want to hide a Suite-only entry point in your own UI\nrather than let it nudge.\n\n## Studio (data-app generator)\n\nThe Studio - the schema designer, edit panel, master-detail, the SQL,\nREST and Supabase data sources, and the AI generator - is covered by the\n**Suite Developer License**. There is no Studio-only key and no\nper-feature entitlement inside it: a Suite key covers the whole Studio\ntogether with the spreadsheet and the grid. Loaded without a Suite key,\nevery Studio surface still works and displays the watermark and the\nconsole message about the Enterprise package, as the rest of the package\ndoes.\n\nIt has two places you set the key, because it runs in two places:\n\n**1. In your app (the browser)** - the same `setLicenseKey()` you\nalready call. The designer, edit panel, master-detail, and data sources\nall read it. Nothing new to do.\n\n```ts\nimport { setLicenseKey } from '@svgrid/enterprise'\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\n```\n\n**2. In the AI generator (the MCP server)** - the generator runs in a\nNode process (Claude Code / Desktop), so it reads the key from the\n`SVGRID_LICENSE_KEY` environment variable in your MCP config. Set it\nonce:\n\n```jsonc\n{\n \"mcpServers\": {\n \"svgrid\": {\n \"command\": \"npx\",\n \"args\": [\"@svgrid/mcp\"],\n \"env\": { \"SVGRID_LICENSE_KEY\": \"SVENTERPRISE-...\" }\n }\n }\n}\n```\n\nWithout it, the generator still produces code - it just prepends a\none-line commercial notice, and the generated app carries the usual\nwatermark until you call `setLicenseKey()` in it. Same honor-system,\nsame key format, no network calls.\n\n## License renewal\n\nThe license itself is perpetual; what renews each year is your\n**updates-and-support term**. It renews automatically until you cancel\n(cancel anytime). The key keeps validating every version released during\na paid term, and if you cancel or let the term lapse, your installed\nversion keeps working - you just stop receiving new releases and support.\n\n- 60 days before the term ends: yellow nudge on the watermark\n- 30 days: orange\n- 7 days: red\n- After the term ends (if you cancelled): features keep working; only\n versions released after your paid term need a renewed term\n\nWe never disable working features on a paying customer.\n\n## Audit trail\n\nEach license key has a stable ID embedded. The same key activates as\nmany builds as you want; the ID is what we cross-reference against\nyour subscription in support tickets. **No telemetry is sent** -\nsupport uses the ID you give us, not anything we phoned home for.\n\n## See also\n\n- [Enterprise evaluation](./evaluation.md) - trying Enterprise without a key\n- [Enterprise support](./support.md) - SLAs, escalation, contact channels\n- [Pricing](https://svgrid.com/pricing/) - canonical pricing source\n"
|
|
3573
3590
|
},
|
|
3574
3591
|
{
|
|
3575
3592
|
"slug": "enterprise/studio",
|
|
3576
3593
|
"path": "docs/enterprise/studio.md",
|
|
3577
3594
|
"title": "SvGrid Studio",
|
|
3578
3595
|
"section": "Studio",
|
|
3579
|
-
"markdown": "# SvGrid Studio\n\nStudio is the grid-centric data-app layer in `@svgrid/enterprise`: turn a\ndatabase or schema into a **production-ready CRUD screen** - grid, edit form,\nand SvelteKit API route - in seconds. One `EntitySchema` drives the grid\ncolumns, the edit form, validation, and the generated code.\n\n\n\nIt is part of the **Enterprise** license, with its 30-day evaluation period:\nloaded without a key, everything runs and the watermark and the console\nmessage about the Enterprise package are displayed. See\n[licensing](./licensing.md#studio-data-app-generator).\n\n> **Just want to run it?** One command scaffolds a complete, working app - no\n> manual setup, no database required:\n>\n> ```bash\n> npm create @svgrid/studio@latest my-app\n> cd my-app && npm install && npm run dev\n> ```\n>\n> You'll be asked to pick a **theme** (one of `@svgrid/grid`'s 19 built-in\n> presets) and **light or dark** mode - or skip the prompts with\n> `-- --theme material --dark`. Open `http://localhost:5173` and you have a\n> real SvelteKit app - nav shell, **Customers** and **Orders** screens (linked\n> by a searchable lookup), grid + modal create/edit/delete, seeded data.\n> Nothing to configure. See\n> [Getting started](./studio/getting-started.md#fastest-path-a-downloadable-ready-to-run-example)\n> for what to do next.\n\n> **Want to build it yourself, step by step?** Follow the\n> **[Getting started](./studio/getting-started.md)** guide - from an empty app to\n> a working Customers screen, no prior SvGrid experience assumed.\n\n> **Prefer not to write code?** Build the same app **visually** - point, click,\n> preview, then press one button to generate it. Try it right now, no install,\n> at **[svgrid.com/studio](https://svgrid.com/studio)** - or see\n> **[Launch the designer](./studio/launch.md)** for the local CLI version\n> (auto-saves to disk, generates straight into a folder), or open a one-click\n> **[sample app](./studio/samples.md)**.\n\n> **Live demos:** [Live SQL](https://svgrid.com/demos/193-studio-live-sql/)\n> (the whole stack over real Postgres in the browser via PGlite) ·\n> [Supabase](https://svgrid.com/demos/194-studio-supabase/) (your own hosted\n> Postgres, straight from the browser) ·\n> [Chart](https://svgrid.com/demos/197-studio-chart/) (a live chart panel\n> beside the grid; click to filter).\n\n---\n\n## A CRUD app in under a minute\n\nThis assumes you already have a SvelteKit app and a `customers` table in your\ndatabase. Starting from nothing? Use **[Getting started](./studio/getting-started.md)**\ninstead - it builds the same screen from an empty folder, no database required.\n\n```bash\nnpm i @svgrid/grid @svgrid/enterprise pg # pg = driver for your database\nnpx @svgrid/studio add customers --db postgres --url \"$DATABASE_URL\"\nnpm run dev # open /customers\n```\n\n`add customers` introspects the `customers` table and writes three files - the\nentity schema, a SvelteKit `+server.ts` API route, and a `+page.svelte` screen -\ngiving you a working `/customers` page with **search, sort, pagination,\nmulti-select delete, and create/edit**. Re-running `add` is safe: only the\n`svgrid:managed` regions are regenerated.\n\n> The grid themes its own borders, backgrounds, and hover states out of the box. Font is\n> the one thing it inherits from your page rather than forcing - if your app doesn't set\n> one yet (a bare `npx sv create` scaffold doesn't), add a `body { font-family: ... }`\n> rule or a [`--sg-font`](./studio/theming.md) token, or the screen renders in the\n> browser's default serif font.\n\n---\n\n## How it fits together\n\nOne `EntitySchema` is the single source of truth. The grid columns, the edit\nform, the dashboard, the generated SvelteKit code, and the data layer all derive\nfrom it - so a change to the schema flows everywhere at once.\n\n\n\nNew here? [Concepts](./studio/concepts.md) walks this picture end to end and\ndefines every Studio term (screen, block, project model, managed region).\n\n---\n\n## Data binding options\n\nStudio binds a screen to data through one small contract - `ServerDataSource`\n(read + create + update + delete). Every option below implements it, so the\ngrid, form, sorting, filtering, and pagination work identically no matter where\nthe data lives. Pick your source:\n\n| Option | Use it for | Guide |\n| --- | --- | --- |\n| **Databases** | PostgreSQL, Supabase, MySQL / MariaDB, SQL Server, SQLite | [Databases](./studio/databases.md) |\n| **Drizzle schema** | scaffold from a Drizzle `schema.ts` file (no live DB needed) | [Drizzle schema](./studio/drizzle.md) |\n| **REST / custom API** | an existing HTTP API, or any backend you wire by hand | [REST & custom APIs](./studio/rest-api.md) |\n| **In-memory** | prototypes, tests, static data, seeding a schema | [In-memory](./studio/in-memory.md) |\n\nNew to how binding works? Start with **[Data binding](./studio/data-binding.md)** -\nthe `ServerDataSource` contract and how sort / filter / page / CRUD flow.\n\n---\n\n## Three ways to build\n\nAll three produce the same output, because they share one `scaffold()` core.\n\n**1. [CLI](./studio/cli.md)** - `npx @svgrid/studio add ...` (deterministic, no\nAI required).\n\n**2. [AI / MCP](./studio/ai-generation.md)** - with `@svgrid/mcp` configured, ask\nyour agent (Claude Code, Cursor, ...) to build a screen for a table; it calls\n`introspect_source` -> `scaffold_entity`, and the output is compile-verified\nbefore it's written.\n\n**3. [Visual app designer](./studio/app-designer.md)** - run `npx @svgrid/studio\ndesigner` to open the full app builder (`SvStudioDesigner`): compose grids, charts,\ndashboards, boards, schedulers, and master-detail across **many entities and\nscreens**, bind each to real data, write code-behind, and click **Generate app**.\nThis is the rich, multi-screen builder you see when you launch Studio.\n\n\n\nWant to embed a schema editor in your **own** app instead? The\n[`<SvSchemaDesigner>`](./studio/designer.md) component is a smaller,\n**single-entity** widget - author one entity's fields + validation with a live grid\n+ form preview, and you own where the schema is stored.\n\nHere a ready-made **CRM sample** is open in the app designer - a forecast dashboard\nwith KPI tiles and deal-value charts, all built by pointing and clicking, no code:\n\n\n\n---\n\n## What you get in a generated screen\n\nA shippable data screen, not a stub:\n\n- **Grid** with server-side **sort**, **filter**, **global search**, and\n columns that fit the width.\n- **Native pagination footer** driven by the server row count.\n- **Create / edit** in a themed **modal or slide-over** panel with **built-in\n validation** (required, email/URL, min/max, length, regex, plus any Zod /\n Standard Schema validator you add).\n- **Multi-select delete** with **optimistic updates** (rows change instantly,\n roll back on error).\n- **Loading, error, and empty** states, light/dark theming via the grid tokens.\n\n\n\n---\n\n## The building blocks (use them directly)\n\n| Piece | What |\n| --- | --- |\n| `EntitySchema` | one model -> grid columns (`schemaToColumns`) + form fields (`schemaToFormFields`) + validation |\n| `SvGridEditPanel` | schema-driven create / edit form (drawer / modal / inline), with FK lookup fields |\n| `createRelationLookup` / `SvLookupInput` | searchable foreign-key picker over any `ServerDataSource` ([Relations](./studio/relations.md)) |\n| `SvGridMasterDetail` | expand a row into a nested grid of related records |\n| `createSqlDataSource` | a `ServerDataSource` over any SQL client (`execute(text, params)`) |\n| `createSupabaseDataSource` | a `ServerDataSource` over a `supabase-js` client (browser + RLS) |\n| `createSupabaseRealtime` | live grid: refetch / flash rows on Postgres change streams ([Real-time](./studio/realtime.md)) |\n| `SvAuthGate` / `createSupabaseAuth` | secure a screen behind Supabase Auth; RLS scopes data per user ([Auth](./studio/auth.md)) |\n| `SvSchemaChart` | a chart bound to the schema; aggregates client-side or via a source's `getAggregate` (`GROUP BY`) ([Dashboards](./studio/dashboards.md)) |\n| `scaffoldApp` | scaffold a whole multi-entity app - nav + home + every screen |\n| `createRestDataSource` | a `ServerDataSource` over an HTTP/JSON API (overridable request/response) |\n| `introspectSupabaseTable` | detect a Supabase table's columns -> `EntitySchema`, from the browser |\n| `createKitDataSource` / `createKitHandlers` | the SvelteKit client + server transport |\n| `createServerDataSource` | server-side sort / filter / page + optimistic CRUD |\n| `SvSchemaDesigner` | the visual schema designer + live preview + code generation |\n\nThe grid renders its **own** sort, filter, and pagination UI in server mode via\n`externalSort` / `externalFilter` / `externalPagination` - you pass `rowCount`\nand `pageIndex`, it emits change events, you fetch the page.\n\n---\n\n## Production & enterprise ready\n\n- **Real code, no lock-in** - Studio generates plain SvelteKit files you own and\n can edit. There is no runtime, no proprietary host.\n- **No telemetry, no network calls** - licensing is validated locally\n ([details](./licensing.md)); nothing phones home.\n- **Parameterized SQL** - every value is bound, and identifiers come from the\n schema-whitelisted plan, so there is no injection surface in the data layer.\n- **SvelteKit-native** - server `load` / actions, `$lib`, SSR-safe, idiomatic\n Svelte 5 runes.\n- **Bring your own key** - the AI generator uses your own model API key; your\n data and schema never touch our servers.\n- **One license** - Studio is part of the Enterprise license; no separate SKU.\n\n---\n\n## All Studio topics\n\nEvery Studio page, in reading order. The sidebar follows the same order.\n\n**Start here**\n\n- [Getting started](./studio/getting-started.md) - zero to a working CRUD screen, step by step\n- [Concepts](./studio/concepts.md) - the mental model: one `EntitySchema` drives screens, data, and code\n- [Sample apps](./studio/samples.md) - ready-made apps to open, explore, and rebind to your data\n- [Build a CRM](./studio/tutorial-crm.md) - companies, contacts, deals - a multi-entity app end to end\n\n**Three ways to build**\n\n- [The Studio CLI](./studio/cli.md) - the `add` command + all flags\n- [Launch the designer](./studio/launch.md) - `npx @svgrid/studio designer`, saved to disk as you edit\n- [Visual app designer](./studio/app-designer.md) - the full multi-screen builder (`SvStudioDesigner`)\n- [Schema designer](./studio/designer.md) - the embeddable single-entity widget (`SvSchemaDesigner`)\n- [AI generation](./studio/ai-generation.md) - the MCP server + agents\n\n**Connect to data**\n\n- [Data binding](./studio/data-binding.md) - the `ServerDataSource` contract; read this one first\n- [Databases](./studio/databases.md) - PostgreSQL, Supabase, MySQL / MariaDB, SQL Server, SQLite, Turso\n- [Local database](./studio/local-database.md) - Postgres in the browser (PGlite), no server needed\n- [Supabase](./studio/supabase.md) - hosted Postgres, from the browser or the server\n- [Drizzle schema](./studio/drizzle.md) - scaffold from a `schema.ts` file\n- [Prisma schema](./studio/prisma.md) - scaffold from a `schema.prisma` file\n- [REST & custom APIs](./studio/rest-api.md) - any HTTP / bespoke backend\n- [OData & GraphQL](./studio/odata-graphql.md) - map the grid request to either protocol\n- [In-memory](./studio/in-memory.md) - static / prototype data\n\n**One-page CRUD tutorials**\n\n- [Postgres CRUD grid](./studio/postgres-grid.md) - pick this if you have a Postgres connection string\n- [Supabase CRUD grid](./studio/supabase-grid.md) - pick this if your data lives in Supabase\n- [REST CRUD grid](./studio/rest-grid.md) - pick this if you already have an HTTP API\n- [Supabase Northwind sample](./studio/supabase-sample.md) - a relational practice dataset, ready to load\n\n**Screens & features**\n\n- [The EntitySchema](./studio/schema.md) - the model everything derives from\n- [Sorting, filtering & paging](./studio/server-grid.md) - the grid's server-mode UI\n- [Edit forms & validation](./studio/edit-forms.md) - `SvGridEditPanel`\n- [Relations](./studio/relations.md) - foreign keys, lookup pickers, related entities from the schema\n- [Master-detail](./studio/master-detail.md) - the `SvGridMasterDetail` component\n- [Dashboards & charts](./studio/dashboards.md) - KPI tiles + `SvSchemaChart` over your entities\n- [Scheduler](./studio/scheduler.md) - a calendar view of any entity with dates\n- [Dock layout](./studio/dock-layout.md) - dockable, floatable panes for workspace screens\n- [Navigation](./studio/navigation.md) - drill-through pages and row action buttons\n- [Real-time](./studio/realtime.md) - live grid over Supabase change streams\n\n**Logic & generated code**\n\n- [Business logic](./studio/business-logic.md) - computed fields, hooks, server-enforced rules\n- [Code-behind](./studio/code-behind.md) - typed event handlers that survive regeneration\n- [Code generation](./studio/code-generation.md) - what's emitted + safe regeneration\n\n**Auth & security**\n\n- [Auth & secured screens](./studio/auth.md) - Supabase Auth + RLS per user\n- [Access control](./studio/access-control.md) - roles gating screens and write actions\n- [Audit log](./studio/audit-log.md) - a server-side change trail with a viewer screen\n\n**Ship it**\n\n- [Themes & styling](./studio/theming.md) - `--sg-*` tokens, light / dark, your design system\n- [Internationalization](./studio/i18n.md) - message catalog + locale switcher\n- [Accessibility](./studio/accessibility.md) - what's handled for you and what you own\n- [Testing](./studio/testing.md) - data source, component, and end-to-end layers\n- [Deploying a Studio app](./studio/deployment.md) - production checklist\n\n**Reference & help**\n\n- [API index](./studio/api.md) - every Studio export, grouped by area\n- [Troubleshooting & FAQ](./studio/troubleshooting.md) - common issues + answers\n\n**Enterprise**\n\n- [Licensing](./licensing.md) · [MCP server](../help/mcp-server.md) · [Evaluation](./evaluation.md)\n"
|
|
3596
|
+
"markdown": "# SvGrid Studio\n\nStudio is the grid-centric data-app layer in `@svgrid/enterprise`: turn a\ndatabase or schema into a **production-ready CRUD screen** - grid, edit form,\nand SvelteKit API route - in seconds. One `EntitySchema` drives the grid\ncolumns, the edit form, validation, and the generated code.\n\n\n\nIt is part of the **Enterprise** license, and you can evaluate it\nwithout a key: loaded without one, everything runs and the watermark and the console\nmessage about the Enterprise package are displayed. See\n[licensing](./licensing.md#studio-data-app-generator).\n\n> **Just want to run it?** One command scaffolds a complete, working app - no\n> manual setup, no database required:\n>\n> ```bash\n> npm create @svgrid/studio@latest my-app\n> cd my-app && npm install && npm run dev\n> ```\n>\n> You'll be asked to pick a **theme** (one of `@svgrid/grid`'s 19 built-in\n> presets) and **light or dark** mode - or skip the prompts with\n> `-- --theme material --dark`. Open `http://localhost:5173` and you have a\n> real SvelteKit app - nav shell, **Customers** and **Orders** screens (linked\n> by a searchable lookup), grid + modal create/edit/delete, seeded data.\n> Nothing to configure. See\n> [Getting started](./studio/getting-started.md#fastest-path-a-downloadable-ready-to-run-example)\n> for what to do next.\n\n> **Want to build it yourself, step by step?** Follow the\n> **[Getting started](./studio/getting-started.md)** guide - from an empty app to\n> a working Customers screen, no prior SvGrid experience assumed.\n\n> **Prefer not to write code?** Build the same app **visually** - point, click,\n> preview, then press one button to generate it. Try it right now, no install,\n> at **[svgrid.com/studio](https://svgrid.com/studio)** - or see\n> **[Launch the designer](./studio/launch.md)** for the local CLI version\n> (auto-saves to disk, generates straight into a folder), or open a one-click\n> **[sample app](./studio/samples.md)**.\n\n> **Live demos:** [Live SQL](https://svgrid.com/demos/193-studio-live-sql/)\n> (the whole stack over real Postgres in the browser via PGlite) ·\n> [Supabase](https://svgrid.com/demos/194-studio-supabase/) (your own hosted\n> Postgres, straight from the browser) ·\n> [Chart](https://svgrid.com/demos/197-studio-chart/) (a live chart panel\n> beside the grid; click to filter).\n\n---\n\n## A CRUD app in under a minute\n\nThis assumes you already have a SvelteKit app and a `customers` table in your\ndatabase. Starting from nothing? Use **[Getting started](./studio/getting-started.md)**\ninstead - it builds the same screen from an empty folder, no database required.\n\n```bash\nnpm i @svgrid/grid @svgrid/enterprise pg # pg = driver for your database\nnpx @svgrid/studio add customers --db postgres --url \"$DATABASE_URL\"\nnpm run dev # open /customers\n```\n\n`add customers` introspects the `customers` table and writes three files - the\nentity schema, a SvelteKit `+server.ts` API route, and a `+page.svelte` screen -\ngiving you a working `/customers` page with **search, sort, pagination,\nmulti-select delete, and create/edit**. Re-running `add` is safe: only the\n`svgrid:managed` regions are regenerated.\n\n> The grid themes its own borders, backgrounds, and hover states out of the box. Font is\n> the one thing it inherits from your page rather than forcing - if your app doesn't set\n> one yet (a bare `npx sv create` scaffold doesn't), add a `body { font-family: ... }`\n> rule or a [`--sg-font`](./studio/theming.md) token, or the screen renders in the\n> browser's default serif font.\n\n---\n\n## How it fits together\n\nOne `EntitySchema` is the single source of truth. The grid columns, the edit\nform, the dashboard, the generated SvelteKit code, and the data layer all derive\nfrom it - so a change to the schema flows everywhere at once.\n\n\n\nNew here? [Concepts](./studio/concepts.md) walks this picture end to end and\ndefines every Studio term (screen, block, project model, managed region).\n\n---\n\n## Data binding options\n\nStudio binds a screen to data through one small contract - `ServerDataSource`\n(read + create + update + delete). Every option below implements it, so the\ngrid, form, sorting, filtering, and pagination work identically no matter where\nthe data lives. Pick your source:\n\n| Option | Use it for | Guide |\n| --- | --- | --- |\n| **Databases** | PostgreSQL, Supabase, MySQL / MariaDB, SQL Server, SQLite | [Databases](./studio/databases.md) |\n| **Drizzle schema** | scaffold from a Drizzle `schema.ts` file (no live DB needed) | [Drizzle schema](./studio/drizzle.md) |\n| **REST / custom API** | an existing HTTP API, or any backend you wire by hand | [REST & custom APIs](./studio/rest-api.md) |\n| **In-memory** | prototypes, tests, static data, seeding a schema | [In-memory](./studio/in-memory.md) |\n\nNew to how binding works? Start with **[Data binding](./studio/data-binding.md)** -\nthe `ServerDataSource` contract and how sort / filter / page / CRUD flow.\n\n---\n\n## Three ways to build\n\nAll three produce the same output, because they share one `scaffold()` core.\n\n**1. [CLI](./studio/cli.md)** - `npx @svgrid/studio add ...` (deterministic, no\nAI required).\n\n**2. [AI / MCP](./studio/ai-generation.md)** - with `@svgrid/mcp` configured, ask\nyour agent (Claude Code, Cursor, ...) to build a screen for a table; it calls\n`introspect_source` -> `scaffold_entity`, and the output is compile-verified\nbefore it's written.\n\n**3. [Visual app designer](./studio/app-designer.md)** - run `npx @svgrid/studio\ndesigner` to open the full app builder (`SvStudioDesigner`): compose grids, charts,\ndashboards, boards, schedulers, and master-detail across **many entities and\nscreens**, bind each to real data, write code-behind, and click **Generate app**.\nThis is the rich, multi-screen builder you see when you launch Studio.\n\n\n\nWant to embed a schema editor in your **own** app instead? The\n[`<SvSchemaDesigner>`](./studio/designer.md) component is a smaller,\n**single-entity** widget - author one entity's fields + validation with a live grid\n+ form preview, and you own where the schema is stored.\n\nHere a ready-made **CRM sample** is open in the app designer - a forecast dashboard\nwith KPI tiles and deal-value charts, all built by pointing and clicking, no code:\n\n\n\n---\n\n## What you get in a generated screen\n\nA shippable data screen, not a stub:\n\n- **Grid** with server-side **sort**, **filter**, **global search**, and\n columns that fit the width.\n- **Native pagination footer** driven by the server row count.\n- **Create / edit** in a themed **modal or slide-over** panel with **built-in\n validation** (required, email/URL, min/max, length, regex, plus any Zod /\n Standard Schema validator you add).\n- **Multi-select delete** with **optimistic updates** (rows change instantly,\n roll back on error).\n- **Loading, error, and empty** states, light/dark theming via the grid tokens.\n\n\n\n---\n\n## The building blocks (use them directly)\n\n| Piece | What |\n| --- | --- |\n| `EntitySchema` | one model -> grid columns (`schemaToColumns`) + form fields (`schemaToFormFields`) + validation |\n| `SvGridEditPanel` | schema-driven create / edit form (drawer / modal / inline), with FK lookup fields |\n| `createRelationLookup` / `SvLookupInput` | searchable foreign-key picker over any `ServerDataSource` ([Relations](./studio/relations.md)) |\n| `SvGridMasterDetail` | expand a row into a nested grid of related records |\n| `createSqlDataSource` | a `ServerDataSource` over any SQL client (`execute(text, params)`) |\n| `createSupabaseDataSource` | a `ServerDataSource` over a `supabase-js` client (browser + RLS) |\n| `createSupabaseRealtime` | live grid: refetch / flash rows on Postgres change streams ([Real-time](./studio/realtime.md)) |\n| `SvAuthGate` / `createSupabaseAuth` | secure a screen behind Supabase Auth; RLS scopes data per user ([Auth](./studio/auth.md)) |\n| `SvSchemaChart` | a chart bound to the schema; aggregates client-side or via a source's `getAggregate` (`GROUP BY`) ([Dashboards](./studio/dashboards.md)) |\n| `scaffoldApp` | scaffold a whole multi-entity app - nav + home + every screen |\n| `createRestDataSource` | a `ServerDataSource` over an HTTP/JSON API (overridable request/response) |\n| `introspectSupabaseTable` | detect a Supabase table's columns -> `EntitySchema`, from the browser |\n| `createKitDataSource` / `createKitHandlers` | the SvelteKit client + server transport |\n| `createServerDataSource` | server-side sort / filter / page + optimistic CRUD |\n| `SvSchemaDesigner` | the visual schema designer + live preview + code generation |\n\nThe grid renders its **own** sort, filter, and pagination UI in server mode via\n`externalSort` / `externalFilter` / `externalPagination` - you pass `rowCount`\nand `pageIndex`, it emits change events, you fetch the page.\n\n---\n\n## Production & enterprise ready\n\n- **Real code, no lock-in** - Studio generates plain SvelteKit files you own and\n can edit. There is no runtime, no proprietary host.\n- **No telemetry, no network calls** - licensing is validated locally\n ([details](./licensing.md)); nothing phones home.\n- **Parameterized SQL** - every value is bound, and identifiers come from the\n schema-whitelisted plan, so there is no injection surface in the data layer.\n- **SvelteKit-native** - server `load` / actions, `$lib`, SSR-safe, idiomatic\n Svelte 5 runes.\n- **Bring your own key** - the AI generator uses your own model API key; your\n data and schema never touch our servers.\n- **One license** - Studio is part of the Enterprise license; no separate SKU.\n\n---\n\n## All Studio topics\n\nEvery Studio page, in reading order. The sidebar follows the same order.\n\n**Start here**\n\n- [Getting started](./studio/getting-started.md) - zero to a working CRUD screen, step by step\n- [Concepts](./studio/concepts.md) - the mental model: one `EntitySchema` drives screens, data, and code\n- [Sample apps](./studio/samples.md) - ready-made apps to open, explore, and rebind to your data\n- [Build a CRM](./studio/tutorial-crm.md) - companies, contacts, deals - a multi-entity app end to end\n\n**Three ways to build**\n\n- [The Studio CLI](./studio/cli.md) - the `add` command + all flags\n- [Launch the designer](./studio/launch.md) - `npx @svgrid/studio designer`, saved to disk as you edit\n- [Visual app designer](./studio/app-designer.md) - the full multi-screen builder (`SvStudioDesigner`)\n- [Schema designer](./studio/designer.md) - the embeddable single-entity widget (`SvSchemaDesigner`)\n- [AI generation](./studio/ai-generation.md) - the MCP server + agents\n\n**Connect to data**\n\n- [Data binding](./studio/data-binding.md) - the `ServerDataSource` contract; read this one first\n- [Databases](./studio/databases.md) - PostgreSQL, Supabase, MySQL / MariaDB, SQL Server, SQLite, Turso\n- [Local database](./studio/local-database.md) - Postgres in the browser (PGlite), no server needed\n- [Supabase](./studio/supabase.md) - hosted Postgres, from the browser or the server\n- [Drizzle schema](./studio/drizzle.md) - scaffold from a `schema.ts` file\n- [Prisma schema](./studio/prisma.md) - scaffold from a `schema.prisma` file\n- [REST & custom APIs](./studio/rest-api.md) - any HTTP / bespoke backend\n- [OData & GraphQL](./studio/odata-graphql.md) - map the grid request to either protocol\n- [In-memory](./studio/in-memory.md) - static / prototype data\n\n**One-page CRUD tutorials**\n\n- [Postgres CRUD grid](./studio/postgres-grid.md) - pick this if you have a Postgres connection string\n- [Supabase CRUD grid](./studio/supabase-grid.md) - pick this if your data lives in Supabase\n- [REST CRUD grid](./studio/rest-grid.md) - pick this if you already have an HTTP API\n- [Supabase Northwind sample](./studio/supabase-sample.md) - a relational practice dataset, ready to load\n\n**Screens & features**\n\n- [The EntitySchema](./studio/schema.md) - the model everything derives from\n- [Sorting, filtering & paging](./studio/server-grid.md) - the grid's server-mode UI\n- [Edit forms & validation](./studio/edit-forms.md) - `SvGridEditPanel`\n- [Relations](./studio/relations.md) - foreign keys, lookup pickers, related entities from the schema\n- [Master-detail](./studio/master-detail.md) - the `SvGridMasterDetail` component\n- [Dashboards & charts](./studio/dashboards.md) - KPI tiles + `SvSchemaChart` over your entities\n- [Scheduler](./studio/scheduler.md) - a calendar view of any entity with dates\n- [Dock layout](./studio/dock-layout.md) - dockable, floatable panes for workspace screens\n- [Navigation](./studio/navigation.md) - drill-through pages and row action buttons\n- [Real-time](./studio/realtime.md) - live grid over Supabase change streams\n\n**Logic & generated code**\n\n- [Business logic](./studio/business-logic.md) - computed fields, hooks, server-enforced rules\n- [Code-behind](./studio/code-behind.md) - typed event handlers that survive regeneration\n- [Code generation](./studio/code-generation.md) - what's emitted + safe regeneration\n\n**Auth & security**\n\n- [Auth & secured screens](./studio/auth.md) - Supabase Auth + RLS per user\n- [Access control](./studio/access-control.md) - roles gating screens and write actions\n- [Audit log](./studio/audit-log.md) - a server-side change trail with a viewer screen\n\n**Ship it**\n\n- [Themes & styling](./studio/theming.md) - `--sg-*` tokens, light / dark, your design system\n- [Internationalization](./studio/i18n.md) - message catalog + locale switcher\n- [Accessibility](./studio/accessibility.md) - what's handled for you and what you own\n- [Testing](./studio/testing.md) - data source, component, and end-to-end layers\n- [Deploying a Studio app](./studio/deployment.md) - production checklist\n\n**Reference & help**\n\n- [API index](./studio/api.md) - every Studio export, grouped by area\n- [Troubleshooting & FAQ](./studio/troubleshooting.md) - common issues + answers\n\n**Enterprise**\n\n- [Licensing](./licensing.md) · [MCP server](../help/mcp-server.md) · [Evaluation](./evaluation.md)\n"
|
|
3580
3597
|
},
|
|
3581
3598
|
{
|
|
3582
3599
|
"slug": "enterprise/studio/access-control",
|
|
@@ -3597,7 +3614,7 @@ export const docs = [
|
|
|
3597
3614
|
"path": "docs/enterprise/studio/ai-generation.md",
|
|
3598
3615
|
"title": "AI generation",
|
|
3599
3616
|
"section": "Studio",
|
|
3600
|
-
"markdown": "# AI generation\n\nThe `@svgrid/mcp` server exposes Studio to AI coding agents (Claude Code, Cursor,\nCodex, ...) through the Model Context Protocol. Ask your agent to build a screen\nfor a table and it introspects, scaffolds, and verifies - producing the same code\nthe [CLI](./cli.md) and [designer](./designer.md) do.\n\n\n\n## How it fits together\n\nThe MCP server makes **no model calls of its own**. It hands your agent a set of\ntools; the agent's own model decides when to call them. So the loop is:\n\n```\nyou -> your agent (its model) -> svgrid MCP tools -> files on disk\n ^ |\n +-------- svelte-check verify <----------+\n```\n\nYour schema and data stay on your machine; nothing is sent to our servers.\n\n## Configure the MCP server\n\nAdd it to your agent's MCP config (the key is passed as an env var, since the\nserver runs in a Node process):\n\n```jsonc\n{\n \"mcpServers\": {\n \"svgrid\": {\n \"command\": \"npx\",\n \"args\": [\"@svgrid/mcp\"],\n \"env\": { \"SVGRID_LICENSE_KEY\": \"SVENTERPRISE-...\" }\n }\n }\n}\n```\n\nThe same block works across hosts - only the file it lives in differs:\n\n| Host | Config location |\n| --- | --- |\n| Claude Code | `.mcp.json` at the project root, or `claude mcp add` |\n| Cursor | `.cursor/mcp.json` |\n| Windsurf | `~/.codeium/windsurf/mcp_config.json` |\n| Codex / other | the host's `mcpServers` config |\n\nRestart (or reload) the agent so it picks up the server, then confirm the\n`svgrid` tools are listed.\n\n## The tools\n\nAlongside the read-only knowledge tools (examples, docs, API reference), the\nserver exposes two generation tools:\n\n| Tool | What it does |\n| --- | --- |\n| `svgrid_scaffold` with `schemaOnly: true` | Infer an `EntitySchema` from a Drizzle schema file (`from:\"drizzle\"`) or sample JSON rows (`from:\"json\"`). Returns a **draft** to review. |\n| `svgrid_scaffold` | Generate the SvelteKit files - from a Drizzle schema, sample rows, or an `EntitySchema` you already have, in one call. 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. |\n\n## Drive the whole project model\n\nBeyond single screens, the server exposes the full\n[project model](./concepts.md#the-project-model) - the same\n`studio.config.json` the visual designer edits - as four `studio_*` tools.\nYour agent can build a complete multi-screen app, or continue editing one the\ndesigner produced, and hand it back.\n\n**They are opt-in.** Set `SVGRID_MCP_STUDIO=1`, or a valid\n`SVGRID_LICENSE_KEY`, in the MCP server's env. Tools that need a licence to be\nuseful should not cost every other user context on every request.\n\n| Tool | What it does |\n| --- | --- |\n| `studio_project` | `new` an empty project, `load` a `studio.config.json`, `describe` the current one (entities, screens, block ids, theme, RBAC, auth, deploy), `config` to get it back as a string for round-tripping, `capabilities` to list the block kinds, UI component keys, theme presets, source kinds and deploy targets this version supports |\n| `studio_apply` | A **batch** of model changes applied in order: `add_entity`, `add_screen`, `add_block`, `add_component`, `update_block`, `remove_block`, `move_block`, `update_screen`, `remove_screen` |\n| `studio_configure` | Project-wide settings in one call: `theme`, `auth`, `access`, `tenancy`, `data_layer`, `job`, `deploy_target`, `screen_layout`, `form_layout`, `field_conditions`, `entity_source` |\n| `studio_build` | `validate` the project (errors + warnings), then `generate` the full runnable SvelteKit app - every file, ready to write and `svelte-check` |\n\nCall `studio_project` with `action: \"capabilities\"` before applying anything:\nit reports exactly what the installed version supports, so the agent uses real\nblock kinds and component keys rather than plausible-looking ones.\n\n`studio_apply` takes a batch on purpose. This was 27 separate tools, one per\nmutation, which made a five-screen app twenty-odd round trips and cost every\nuser of the free grid ~3,741 tokens of tool definitions on every request. A\nwhole screen is now one call:\n\n```json\n{\n \"ops\": [\n { \"op\": \"add_entity\", \"rows\": [{ \"id\": 1, \"subject\": \"Login fails\", \"status\": \"open\" }], \"name\": \"tickets\" },\n { \"op\": \"add_screen\", \"title\": \"Dashboard\" },\n { \"op\": \"add_block\", \"screen\": \"dashboard\", \"kind\": \"kpi\", \"entity\": \"tickets\" },\n { \"op\": \"add_block\", \"screen\": \"dashboard\", \"kind\": \"chart\", \"entity\": \"tickets\" }\n ]\n}\n```\n\nIf an op fails, the response says which one and what had already applied, so\nthe agent retries the tail rather than the whole batch.\n\nThe 27 individual tool names still answer, so an existing prompt keeps working\n- they are simply no longer advertised.\n\nA prompt that exercises the loop end to end:\n\n> \"Using the svgrid MCP: new project 'Support desk'. Add a `tickets` entity from\n> these sample rows, a dashboard screen with a KPI and a chart over tickets,\n> RBAC with an agent role that cannot delete, dark theme, then validate and\n> generate the app.\"\n\n## Step by step\n\n1. **Point it at a source.** A Drizzle schema file, or a handful of sample rows.\n2. **Introspect.** The agent calls `svgrid_scaffold` with `schemaOnly: true` and shows you the drafted\n `EntitySchema` - field names, types, primary key, guessed formats.\n3. **Refine (optional).** Correct a type, mark a field hidden or read-only, add\n validation - in chat, or later in the [visual designer](./app-designer.md).\n4. **Scaffold.** The agent calls `svgrid_scaffold` again without `schemaOnly`; the files come back already\n run through the Svelte compiler.\n5. **Verify.** The agent runs your project's `svelte-check`; if anything fails it\n iterates. This is the loop that keeps AI output trustworthy.\n\n## Prompts that work\n\nFrom a Drizzle schema:\n\n> \"Using the svgrid MCP, build a CRUD screen for the `customers` table in\n> `src/lib/db/schema.ts`.\"\n\nFrom sample data, when there is no schema yet:\n\n> \"Here are five example rows of our invoices. Use the svgrid MCP to introspect a\n> schema, then scaffold a CRUD screen at `/invoices`.\"\n>\n> ```json\n> [{ \"id\": \"INV-1\", \"customer\": \"Acme\", \"amount\": 4200, \"paid\": true, \"due\": \"2026-07-01\" }]\n> ```\n\nRefining before you commit:\n\n> \"Show me the drafted schema first. Mark `internalNotes` hidden, make `email`\n> required, and set `status` to an enum of draft/sent/paid before scaffolding.\"\n\n## What comes back\n\n`svgrid_scaffold` writes three files (the same layout as the CLI and designer):\n\n```\nsrc/lib/customers.schema.ts # the EntitySchema + row type\nsrc/routes/api/customers/+server.ts # createKitHandlers data endpoint\nsrc/routes/customers/+page.svelte # the grid + edit-panel screen\n```\n\nEach carries `svgrid:managed` markers so a re-generation updates the managed\nregions and leaves your hand-written code untouched. See\n[code generation](./code-generation.md) for the anatomy of each file.\n\n## Bring your own key\n\nThe generator uses **your** agent's model and API key - your schema and data\nnever touch our servers. The MCP server itself makes no model calls; it provides\nintrospection + scaffolding + verification tools that the host agent drives.\n\n## Licensing\n\
|
|
3617
|
+
"markdown": "# AI generation\n\nThe `@svgrid/mcp` server exposes Studio to AI coding agents (Claude Code, Cursor,\nCodex, ...) through the Model Context Protocol. Ask your agent to build a screen\nfor a table and it introspects, scaffolds, and verifies - producing the same code\nthe [CLI](./cli.md) and [designer](./designer.md) do.\n\n\n\n## How it fits together\n\nThe MCP server makes **no model calls of its own**. It hands your agent a set of\ntools; the agent's own model decides when to call them. So the loop is:\n\n```\nyou -> your agent (its model) -> svgrid MCP tools -> files on disk\n ^ |\n +-------- svelte-check verify <----------+\n```\n\nYour schema and data stay on your machine; nothing is sent to our servers.\n\n## Configure the MCP server\n\nAdd it to your agent's MCP config (the key is passed as an env var, since the\nserver runs in a Node process):\n\n```jsonc\n{\n \"mcpServers\": {\n \"svgrid\": {\n \"command\": \"npx\",\n \"args\": [\"@svgrid/mcp\"],\n \"env\": { \"SVGRID_LICENSE_KEY\": \"SVENTERPRISE-...\" }\n }\n }\n}\n```\n\nThe same block works across hosts - only the file it lives in differs:\n\n| Host | Config location |\n| --- | --- |\n| Claude Code | `.mcp.json` at the project root, or `claude mcp add` |\n| Cursor | `.cursor/mcp.json` |\n| Windsurf | `~/.codeium/windsurf/mcp_config.json` |\n| Codex / other | the host's `mcpServers` config |\n\nRestart (or reload) the agent so it picks up the server, then confirm the\n`svgrid` tools are listed.\n\n## The tools\n\nAlongside the read-only knowledge tools (examples, docs, API reference), the\nserver exposes two generation tools:\n\n| Tool | What it does |\n| --- | --- |\n| `svgrid_scaffold` with `schemaOnly: true` | Infer an `EntitySchema` from a Drizzle schema file (`from:\"drizzle\"`) or sample JSON rows (`from:\"json\"`). Returns a **draft** to review. |\n| `svgrid_scaffold` | Generate the SvelteKit files - from a Drizzle schema, sample rows, or an `EntitySchema` you already have, in one call. 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. |\n\n## Drive the whole project model\n\nBeyond single screens, the server exposes the full\n[project model](./concepts.md#the-project-model) - the same\n`studio.config.json` the visual designer edits - as four `studio_*` tools.\nYour agent can build a complete multi-screen app, or continue editing one the\ndesigner produced, and hand it back.\n\n**They are opt-in.** Set `SVGRID_MCP_STUDIO=1`, or a valid\n`SVGRID_LICENSE_KEY`, in the MCP server's env. Tools that need a licence to be\nuseful should not cost every other user context on every request.\n\n| Tool | What it does |\n| --- | --- |\n| `studio_project` | `new` an empty project, `load` a `studio.config.json`, `describe` the current one (entities, screens, block ids, theme, RBAC, auth, deploy), `config` to get it back as a string for round-tripping, `capabilities` to list the block kinds, UI component keys, theme presets, source kinds and deploy targets this version supports |\n| `studio_apply` | A **batch** of model changes applied in order: `add_entity`, `add_screen`, `add_block`, `add_component`, `update_block`, `remove_block`, `move_block`, `update_screen`, `remove_screen` |\n| `studio_configure` | Project-wide settings in one call: `theme`, `auth`, `access`, `tenancy`, `data_layer`, `job`, `deploy_target`, `screen_layout`, `form_layout`, `field_conditions`, `entity_source` |\n| `studio_build` | `validate` the project (errors + warnings), then `generate` the full runnable SvelteKit app - every file, ready to write and `svelte-check` |\n\nCall `studio_project` with `action: \"capabilities\"` before applying anything:\nit reports exactly what the installed version supports, so the agent uses real\nblock kinds and component keys rather than plausible-looking ones.\n\n`studio_apply` takes a batch on purpose. This was 27 separate tools, one per\nmutation, which made a five-screen app twenty-odd round trips and cost every\nuser of the free grid ~3,741 tokens of tool definitions on every request. A\nwhole screen is now one call:\n\n```json\n{\n \"ops\": [\n { \"op\": \"add_entity\", \"rows\": [{ \"id\": 1, \"subject\": \"Login fails\", \"status\": \"open\" }], \"name\": \"tickets\" },\n { \"op\": \"add_screen\", \"title\": \"Dashboard\" },\n { \"op\": \"add_block\", \"screen\": \"dashboard\", \"kind\": \"kpi\", \"entity\": \"tickets\" },\n { \"op\": \"add_block\", \"screen\": \"dashboard\", \"kind\": \"chart\", \"entity\": \"tickets\" }\n ]\n}\n```\n\nIf an op fails, the response says which one and what had already applied, so\nthe agent retries the tail rather than the whole batch.\n\nThe 27 individual tool names still answer, so an existing prompt keeps working\n- they are simply no longer advertised.\n\nA prompt that exercises the loop end to end:\n\n> \"Using the svgrid MCP: new project 'Support desk'. Add a `tickets` entity from\n> these sample rows, a dashboard screen with a KPI and a chart over tickets,\n> RBAC with an agent role that cannot delete, dark theme, then validate and\n> generate the app.\"\n\n## Step by step\n\n1. **Point it at a source.** A Drizzle schema file, or a handful of sample rows.\n2. **Introspect.** The agent calls `svgrid_scaffold` with `schemaOnly: true` and shows you the drafted\n `EntitySchema` - field names, types, primary key, guessed formats.\n3. **Refine (optional).** Correct a type, mark a field hidden or read-only, add\n validation - in chat, or later in the [visual designer](./app-designer.md).\n4. **Scaffold.** The agent calls `svgrid_scaffold` again without `schemaOnly`; the files come back already\n run through the Svelte compiler.\n5. **Verify.** The agent runs your project's `svelte-check`; if anything fails it\n iterates. This is the loop that keeps AI output trustworthy.\n\n## Prompts that work\n\nFrom a Drizzle schema:\n\n> \"Using the svgrid MCP, build a CRUD screen for the `customers` table in\n> `src/lib/db/schema.ts`.\"\n\nFrom sample data, when there is no schema yet:\n\n> \"Here are five example rows of our invoices. Use the svgrid MCP to introspect a\n> schema, then scaffold a CRUD screen at `/invoices`.\"\n>\n> ```json\n> [{ \"id\": \"INV-1\", \"customer\": \"Acme\", \"amount\": 4200, \"paid\": true, \"due\": \"2026-07-01\" }]\n> ```\n\nRefining before you commit:\n\n> \"Show me the drafted schema first. Mark `internalNotes` hidden, make `email`\n> required, and set `status` to an enum of draft/sent/paid before scaffolding.\"\n\n## What comes back\n\n`svgrid_scaffold` writes three files (the same layout as the CLI and designer):\n\n```\nsrc/lib/customers.schema.ts # the EntitySchema + row type\nsrc/routes/api/customers/+server.ts # createKitHandlers data endpoint\nsrc/routes/customers/+page.svelte # the grid + edit-panel screen\n```\n\nEach carries `svgrid:managed` markers so a re-generation updates the managed\nregions and leaves your hand-written code untouched. See\n[code generation](./code-generation.md) for the anatomy of each file.\n\n## Bring your own key\n\nThe generator uses **your** agent's model and API key - your schema and data\nnever touch our servers. The MCP server itself makes no model calls; it provides\nintrospection + scaffolding + verification tools that the host agent drives.\n\n## Licensing\n\nYou can evaluate generation without a key: it runs\nand prepends a one-line commercial notice, and the generated app displays\nthe watermark and the console message about the Enterprise package until\nyou call `setLicenseKey()`. Set `SVGRID_LICENSE_KEY` in the MCP config to license it. See\n[licensing](../licensing.md#studio-data-app-generator).\n\n## See also\n\n- [The Studio CLI](./cli.md) - the deterministic, no-AI path\n- [Visual app designer](./app-designer.md) - refine an AI draft by hand before generating\n- [Code generation](./code-generation.md) - the anatomy of the emitted files\n- [MCP server](../../help/mcp-server.md) - full MCP reference\n"
|
|
3601
3618
|
},
|
|
3602
3619
|
{
|
|
3603
3620
|
"slug": "enterprise/studio/api",
|
|
@@ -3639,7 +3656,7 @@ export const docs = [
|
|
|
3639
3656
|
"path": "docs/enterprise/studio/cli.md",
|
|
3640
3657
|
"title": "The Studio CLI",
|
|
3641
3658
|
"section": "Studio",
|
|
3642
|
-
"markdown": "# The Studio CLI\n\n`@svgrid/studio` is the command-line generator. One command reads a schema or a\nlive database and writes a working CRUD screen into your SvelteKit app.\n\n\n\n> **New here?** Start with [Getting started](./getting-started.md) for the full\n> step-by-step path (create an app, install, first screen). This page is the CLI\n> reference.\n\n```bash\nnpx @svgrid/studio add <name> [--from <schema> | --db <dialect> --url <conn>] [options]\n```\n\n## `init`\n\nThe guided path: answer a few questions and get a working app. This is also what\nruns when you type `npx @svgrid/studio` with no arguments in a terminal.\n\n```bash\nnpx @svgrid/studio init [--db <dialect> --url <conn>
|
|
3659
|
+
"markdown": "# The Studio CLI\n\n`@svgrid/studio` is the command-line generator. One command reads a schema or a\nlive database and writes a working CRUD screen into your SvelteKit app.\n\n\n\n> **New here?** Start with [Getting started](./getting-started.md) for the full\n> step-by-step path (create an app, install, first screen). This page is the CLI\n> reference.\n\n```bash\nnpx @svgrid/studio add <name> [--from <schema> | --db <dialect> --url <conn>] [options]\n```\n\n## `init`\n\nThe guided path: answer a few questions and get a working app. This is also what\nruns when you type `npx @svgrid/studio` with no arguments in a terminal.\n\n```bash\nnpx @svgrid/studio init [--db <dialect> --url <conn> | --from <schema> | --dataset <id>] [--out <dir>]\n```\n\nIt asks where the data comes from, which tables to include, which pages each\ntable gets, and which theme to use - then writes a runnable SvelteKit app plus a\n`studio.config.json` you can reopen in the [designer](./app-designer.md).\n\nEvery table you pick gets a **list** screen (searchable grid with status pills\nand totals), a **manage** screen (grid + editable record panel), and - when other\ntables relate to it - a **record page** with a tab per related collection. The\napp leads with an overview dashboard over its most-referenced table.\n\n| Flag | Description |\n| --- | --- |\n| `--db <dialect>` | Skip the source question and read a live database: `postgres` \\| `supabase` \\| `mysql` \\| `mssql` \\| `sqlite`. The driver is installed for you. |\n| `--url <conn>` | Connection string (or file path for SQLite) for `--db`. |\n| `--from <path>` | Build from a Drizzle `schema.ts` or a Prisma `schema.prisma`: its tables, columns and foreign keys, served from memory with sample rows. |\n| `--supabase-url <url>` | Read a Supabase project over its REST API instead - no driver, no connection string. |\n| `--supabase-key <anon>` | The project's anon key, paired with `--supabase-url`. |\n| `--dataset <id>` | Start from sample data: `customers-orders`, `products-categories`, `projects-tasks`, `employees-departments`, `tickets-accounts`. |\n| `--title <name>` | App name. |\n| `--out <dir>` | Folder to write the app into (default: `.`). |\n| `--theme <id>` | Design-system preset (see [Theming](./theming.md)). |\n| `--dark` | Start in dark mode. |\n| `-y`, `--yes` | Take every default and ask nothing - useful in scripts and CI. |\n\n```bash\n# guided, from your own database\nnpx @svgrid/studio init --db postgres --url $DATABASE_URL --out my-app\n\n# from Supabase, over the REST API (no driver to install)\nnpx @svgrid/studio init --supabase-url https://xxxx.supabase.co --supabase-key $SUPABASE_ANON_KEY\n\n# no questions at all: a seeded sample app\nnpx @svgrid/studio init --yes --dataset projects-tasks --out demo\n```\n\nThe designer's **New app** button runs the same flow visually, and produces the\nsame project - pick whichever suits you.\n\n## `designer`\n\nOpen the visual [app designer](./app-designer.md) in your browser, auto-saving\nto `studio.config.json` and generating the app to a folder. See\n[Launch the designer](./launch.md) for the full guide.\n\n```bash\nnpx @svgrid/studio designer [--config <path>] [--out <dir>] [--port <n>] [--no-open]\n```\n\n## `add`\n\nGenerates three files for an entity: the schema module, the API route, and the\npage.\n\n| Flag | Description |\n| --- | --- |\n| `--from <path>` | Introspect a schema file: a Drizzle `schema.ts` or a Prisma `schema.prisma` (auto-detected). |\n| `--db <dialect>` | Connect to a live database: `postgres` \\| `supabase` \\| `mysql` \\| `mssql` \\| `sqlite`. |\n| `--url <conn>` | Connection string (or file path for SQLite) for `--db`. |\n| `--all` | Scaffold a screen for **every** table/model - works with `--from` or `--db`. |\n| `--table <name>` | Which table/model to use (defaults to `<name>`). |\n| `--sql` | Emit a `createSqlDataSource` with an `execute()` stub instead of a live driver. |\n| `--route <seg>` | Route segment (default: `<name>` / table name). |\n| `--api <path>` | API route path (default: `/api/<route>`). |\n| `--theme <id>` | Design-system preset for the generated screens (default: `ember`, the demo theme). The tokens go in the page's `<svelte:head>` inside `@layer svgrid-studio`, so an app that already defines its own `--sg-*` tokens keeps its look. |\n| `--dark` | Start in dark mode. |\n| `--title <name>` | With `--all`: the app title. Defaults to the title in `studio.config.json`, then to the one in the current layout. |\n| `-h`, `--help` | Show help. |\n\n`add --all` rewrites `src/routes/+layout.svelte` and the home page. In an app\n`init` generated, the new layout keeps the `app.css` / `custom.css` imports, the\napp title, and the theme from `studio.config.json`.\n\n## Examples\n\n```bash\n# from a live database\nnpx @svgrid/studio add customers --db postgres --url \"$DATABASE_URL\"\n\n# every table in the database\nnpx @svgrid/studio add --all --db mysql --url \"$DATABASE_URL\"\n\n# from a Drizzle schema file, a specific table, custom route\nnpx @svgrid/studio add orders --from src/lib/db/schema.ts --table orders --route sales/orders\n\n# every model in a Prisma schema (relations become lookups)\nnpx @svgrid/studio add --all --from prisma/schema.prisma\n\n# SQLite file\nnpx @svgrid/studio add todos --db sqlite --url ./data.db\n```\n\n## What it writes\n\nFor `add customers`:\n\n```\nsrc/lib/customers.schema.ts # EntitySchema + row type\nsrc/routes/api/customers/+server.ts # API route (createKitHandlers + a data source)\nsrc/routes/customers/+page.svelte # the screen: grid + edit panel\n```\n\nThen `npm run dev` and open `/customers`.\n\n## A whole app with `--all`\n\n`--all` scaffolds **every** table/model, plus an app shell that ties them\ntogether. It works from a live database (`--db`) **or** a schema file (`--from`\na Drizzle `schema.ts` or Prisma `schema.prisma`):\n\n```\nsrc/routes/+layout.svelte # nav sidebar linking every entity screen\nsrc/routes/+page.svelte # home page: a card per entity\nsrc/routes/<entity>/... # one screen per table/model (as above)\n```\n\nForeign keys are resolved across the whole set - a Drizzle `.references()` or a\nPrisma `@relation` becomes a searchable lookup - so each relation points at the\nright related screen and every `/api/<entity>` route it needs exists. The same\noutput is available programmatically as\n[`scaffoldApp(schemas, options)`](./code-generation.md) from `@svgrid/enterprise/studio`,\nfed by `introspectDrizzleAll` / `introspectPrismaAll`.\n\n## The other commands\n\n`designer` and `add` are the daily drivers; the binary has four more:\n\n| Command | What it does |\n| --- | --- |\n| `svgrid-studio dev` | The designer and the **running generated app side by side** - the designer writes real files into the app folder and Vite hot-reloads them. `--app-port <n>` sets the app's port. |\n| `svgrid-studio openapi <file\\|url>` | Import an OpenAPI (JSON) spec: paths + schemas become entities with REST sources in `studio.config.json`. See [REST & custom APIs](./rest-api.md). |\n| `svgrid-studio eject [--fragment]` | Write the full app (or, with `--fragment`, a drop-in set of files for an existing app) from `studio.config.json` without opening the designer. |\n| `svgrid-studio deploy [--target <p>] [--dry-run]` | Build, then deploy through the provider's own CLI. Target: `vercel` \\| `netlify` \\| `cloudflare` \\| `node`, resolved from the flag, the config, or `svelte.config.js`. `--dry-run` prints the commands. |\n\nUseful `designer` flags beyond the basics: `--template crm|ecommerce|projects|support`\nopens a [sample app](./samples.md) directly, and `--ai` enables the built-in\ncopilot (reads `ANTHROPIC_API_KEY` from the environment).\n\n## Safe regeneration\n\nEvery generated file wraps its body in `svgrid:managed` markers. Re-running\n`add` replaces **only** the managed region and preserves everything you wrote\noutside it - so you can regenerate after a schema change without losing\ncustomizations. See [Code generation](./code-generation.md).\n\n## Verification\n\nAfter writing files, run your project's own check to confirm they compile:\n\n```bash\nnpx svelte-check\n```\n\n## Requirements\n\n`@svgrid/grid` and `@svgrid/enterprise` in your project, plus the driver for your\ndatabase (`pg` / `mysql2` / `mssql` / `better-sqlite3`) when using `--db`. Studio is part of the [Enterprise license](../licensing.md); it runs\nwithout a key while you evaluate it.\n\n## See also\n\n- [Databases](./databases.md) · [Drizzle schema](./drizzle.md)\n- [AI generation](./ai-generation.md) - the same engine, driven by an AI agent\n- [Visual app designer](./app-designer.md) - the same engine, driven by a UI\n"
|
|
3643
3660
|
},
|
|
3644
3661
|
{
|
|
3645
3662
|
"slug": "enterprise/studio/code-behind",
|
|
@@ -3660,7 +3677,7 @@ export const docs = [
|
|
|
3660
3677
|
"path": "docs/enterprise/studio/concepts.md",
|
|
3661
3678
|
"title": "Concepts",
|
|
3662
3679
|
"section": "Studio",
|
|
3663
|
-
"markdown": "# Concepts\n\nThe mental model behind Studio: one `EntitySchema` drives the screens, the data\nbinding, and the generated code. This page walks that pipeline once, defines\nevery Studio term, and ends with a table for picking which build tool fits how\nyou work. Ten minutes here makes every other Studio page shorter.\n\n\n\n## The pipeline\n\nEverything in Studio is one flow, left to right:\n\n1. **An `EntitySchema` describes your data.** Field names, types, validation,\n labels, relations. You get one by introspection (a live database table, a\n Drizzle or Prisma schema file, an OpenAPI spec, a CSV, sample JSON) or by\n authoring it in the designer. See [The EntitySchema](./schema.md).\n2. **Screens arrange blocks over entities.** A screen is one route in your app.\n It holds blocks - a grid, a chart, a KPI tile, a board, a calendar - each\n bound to an entity. The visual [app designer](./app-designer.md) is where\n you compose them; the CLI and AI produce the same structures.\n3. **A `ServerDataSource` moves the data.** Read, create, update, delete - one\n small contract that every backend implements (SQL databases, Supabase,\n REST, in-memory, Postgres-in-the-browser). Sorting, filtering, paging, and\n editing work identically no matter where the data lives. See\n [Data binding](./data-binding.md).\n4. **Codegen writes real SvelteKit files.** The schema, a `+server.ts` API\n route, and a `+page.svelte` screen - plain code you own, no runtime, no\n proprietary host. Generated sections sit inside `svgrid:managed` region\n markers, so re-generating updates them without touching your edits. See\n [Code generation](./code-generation.md).\n\n\n\nA change flows forward automatically: add a field to the schema and the grid\ncolumn, the form input, its validation, and the generated code all pick it up.\n\n## The project model\n\nThe designer edits a single JSON document - the **project** - with this shape:\n\n- **project** - title, theme, default data source, plus optional\n [auth](./auth.md), [access control](./access-control.md),\n [audit](./audit-log.md), [i18n](./i18n.md), and deploy settings.\n - **entities** - one `EntitySchema` per table / collection.\n - **screens** - one per route. Each screen has:\n - **blocks** - the data-bound building pieces (grid, form, chart,\n dashboard, kpi, gauge, tree, tabs, accordion, master-detail, lookup,\n pivot, filter, record, board, calendar, detail, component).\n - a **layout** - `grid` (12-column flow, the default), `stack`, `split`\n (resizable panes), `dock` (dockable / floatable panes, see\n [Dock layout](./dock-layout.md)), or `canvas` (free-form placement on a\n 12-column cell grid).\n - a **render mode** - `ssr` (emits idiomatic SvelteKit `+page.server.ts`\n load + form actions; the default for screens in a database-backed app) or\n `spa` (the page fetches through the data source in the browser). See\n [Code generation](./code-generation.md).\n - optional **code-behind** - a user-owned `handlers.ts` companion for\n event handlers, written once and never regenerated.\n\nWhen you run the [local designer](./launch.md), the project auto-saves to\n`studio.config.json` in your working folder as you edit; **Generate app** turns\nit into the SvelteKit project. The same file is what the\n[MCP tools](./ai-generation.md) read and write, so a coding agent and the\ndesigner can work on one project interchangeably.\n\n## Glossary\n\n| Term | Meaning |\n| --- | --- |\n| **EntitySchema** | The model of one entity: fields, types, validation, labels, relations. Everything else derives from it. [Schema](./schema.md) |\n| **Screen** | One route / page of the generated app; holds blocks and a layout. [App designer](./app-designer.md) |\n| **Block** | A data-bound piece placed on a screen: grid, chart, KPI, board, calendar, and so on. [App designer](./app-designer.md) |\n| **Companion block** | A block that works alongside a grid on the same screen and shares its data, like a filter panel or a record panel. [App designer](./app-designer.md) |\n| **Project model** | The single JSON document (`studio.config.json`) holding entities, screens, sources, theme, auth. This page, above |\n| **ServerDataSource** | The read + create + update + delete contract every backend implements. [Data binding](./data-binding.md) |\n| **Managed region** | A `svgrid:managed` marker pair in a generated file; regeneration rewrites only what is inside. [Code generation](./code-generation.md) |\n| **Code-behind** | A user-owned `handlers.ts` next to a generated screen for typed event handlers; created once, never overwritten. [Code-behind](./code-behind.md) |\n| **Scaffold** | The codegen step: schema in, SvelteKit files out. Shared by the CLI, the designer, and the AI path. [CLI](./cli.md) |\n| **Introspection** | Reading an existing source (database table, Drizzle / Prisma schema, OpenAPI spec, CSV) to produce an `EntitySchema`. [Databases](./databases.md) |\n| **Evaluation period** | Enterprise licensing without a hard stop:
|
|
3680
|
+
"markdown": "# Concepts\n\nThe mental model behind Studio: one `EntitySchema` drives the screens, the data\nbinding, and the generated code. This page walks that pipeline once, defines\nevery Studio term, and ends with a table for picking which build tool fits how\nyou work. Ten minutes here makes every other Studio page shorter.\n\n\n\n## The pipeline\n\nEverything in Studio is one flow, left to right:\n\n1. **An `EntitySchema` describes your data.** Field names, types, validation,\n labels, relations. You get one by introspection (a live database table, a\n Drizzle or Prisma schema file, an OpenAPI spec, a CSV, sample JSON) or by\n authoring it in the designer. See [The EntitySchema](./schema.md).\n2. **Screens arrange blocks over entities.** A screen is one route in your app.\n It holds blocks - a grid, a chart, a KPI tile, a board, a calendar - each\n bound to an entity. The visual [app designer](./app-designer.md) is where\n you compose them; the CLI and AI produce the same structures.\n3. **A `ServerDataSource` moves the data.** Read, create, update, delete - one\n small contract that every backend implements (SQL databases, Supabase,\n REST, in-memory, Postgres-in-the-browser). Sorting, filtering, paging, and\n editing work identically no matter where the data lives. See\n [Data binding](./data-binding.md).\n4. **Codegen writes real SvelteKit files.** The schema, a `+server.ts` API\n route, and a `+page.svelte` screen - plain code you own, no runtime, no\n proprietary host. Generated sections sit inside `svgrid:managed` region\n markers, so re-generating updates them without touching your edits. See\n [Code generation](./code-generation.md).\n\n\n\nA change flows forward automatically: add a field to the schema and the grid\ncolumn, the form input, its validation, and the generated code all pick it up.\n\n## The project model\n\nThe designer edits a single JSON document - the **project** - with this shape:\n\n- **project** - title, theme, default data source, plus optional\n [auth](./auth.md), [access control](./access-control.md),\n [audit](./audit-log.md), [i18n](./i18n.md), and deploy settings.\n - **entities** - one `EntitySchema` per table / collection.\n - **screens** - one per route. Each screen has:\n - **blocks** - the data-bound building pieces (grid, form, chart,\n dashboard, kpi, gauge, tree, tabs, accordion, master-detail, lookup,\n pivot, filter, record, board, calendar, detail, component).\n - a **layout** - `grid` (12-column flow, the default), `stack`, `split`\n (resizable panes), `dock` (dockable / floatable panes, see\n [Dock layout](./dock-layout.md)), or `canvas` (free-form placement on a\n 12-column cell grid).\n - a **render mode** - `ssr` (emits idiomatic SvelteKit `+page.server.ts`\n load + form actions; the default for screens in a database-backed app) or\n `spa` (the page fetches through the data source in the browser). See\n [Code generation](./code-generation.md).\n - optional **code-behind** - a user-owned `handlers.ts` companion for\n event handlers, written once and never regenerated.\n\nWhen you run the [local designer](./launch.md), the project auto-saves to\n`studio.config.json` in your working folder as you edit; **Generate app** turns\nit into the SvelteKit project. The same file is what the\n[MCP tools](./ai-generation.md) read and write, so a coding agent and the\ndesigner can work on one project interchangeably.\n\n## Glossary\n\n| Term | Meaning |\n| --- | --- |\n| **EntitySchema** | The model of one entity: fields, types, validation, labels, relations. Everything else derives from it. [Schema](./schema.md) |\n| **Screen** | One route / page of the generated app; holds blocks and a layout. [App designer](./app-designer.md) |\n| **Block** | A data-bound piece placed on a screen: grid, chart, KPI, board, calendar, and so on. [App designer](./app-designer.md) |\n| **Companion block** | A block that works alongside a grid on the same screen and shares its data, like a filter panel or a record panel. [App designer](./app-designer.md) |\n| **Project model** | The single JSON document (`studio.config.json`) holding entities, screens, sources, theme, auth. This page, above |\n| **ServerDataSource** | The read + create + update + delete contract every backend implements. [Data binding](./data-binding.md) |\n| **Managed region** | A `svgrid:managed` marker pair in a generated file; regeneration rewrites only what is inside. [Code generation](./code-generation.md) |\n| **Code-behind** | A user-owned `handlers.ts` next to a generated screen for typed event handlers; created once, never overwritten. [Code-behind](./code-behind.md) |\n| **Scaffold** | The codegen step: schema in, SvelteKit files out. Shared by the CLI, the designer, and the AI path. [CLI](./cli.md) |\n| **Introspection** | Reading an existing source (database table, Drizzle / Prisma schema, OpenAPI spec, CSV) to produce an `EntitySchema`. [Databases](./databases.md) |\n| **Evaluation period** | Enterprise licensing without a hard stop: no key needed to evaluate; without a key a watermark and a console message about the Enterprise package when the module loads, nothing breaks. [Licensing](../licensing.md) |\n\n## Which tool when\n\nAll three build paths share one scaffold core and produce the same output, so\nthis is a workflow choice, not a feature choice - and you can switch anytime.\n\n| Tool | Pick it if | Page |\n| --- | --- | --- |\n| **CLI** - `npx @svgrid/studio add ...` | you want one deterministic command per screen, in scripts or CI, no AI involved | [The Studio CLI](./cli.md) |\n| **AI via MCP** - `@svgrid/mcp` | you already work in a coding agent (Claude Code, Cursor, ...) and want to describe screens in plain language | [AI generation](./ai-generation.md) |\n| **Visual designer** - `npx @svgrid/studio designer` | you want to see the app while composing it, or you are not writing code at all | [Visual app designer](./app-designer.md) |\n\n## See also\n\n- [Getting started](./getting-started.md) - build your first screen step by step\n- [Data binding](./data-binding.md) - the `ServerDataSource` contract in detail\n- [Code generation](./code-generation.md) - the emitted files and safe regeneration\n"
|
|
3664
3681
|
},
|
|
3665
3682
|
{
|
|
3666
3683
|
"slug": "enterprise/studio/dashboards",
|
|
@@ -3723,7 +3740,7 @@ export const docs = [
|
|
|
3723
3740
|
"path": "docs/enterprise/studio/getting-started.md",
|
|
3724
3741
|
"title": "Getting started",
|
|
3725
3742
|
"section": "Studio",
|
|
3726
|
-
"markdown": "# Getting started\n\nThis is the gentlest path into SvGrid Studio. By the end you will have a real,\nworking **Customers** screen - a grid you can sort, filter, and page, with a\ncreate / edit form and delete - running on your machine. No prior experience\nwith SvGrid is assumed.\n\nIf you would rather just click around first, open a live demo - no install\nneeded:\n\n- **[Studio live SQL](https://svgrid.com/demos/193-studio-live-sql/)** - the whole stack in the browser, backed by real Postgres via PGlite\n- **[Supabase](https://svgrid.com/demos/194-studio-supabase/)** - connect your own hosted Postgres\n- **[The designer itself](https://svgrid.com/studio)** - open a sample app and edit it, nothing to install\n\n**Choose your tutorial.** This page needs no database and teaches the whole\nshape; the one-page tutorials build the same screen against a real backend.\nThey all end in the same place, so pick by what you have:\n\n| You have | Follow | What it adds |\n| --- | --- | --- |\n| Nothing yet | this page | the full path: install, schema, generate, run, change |\n| A Postgres connection string | [Postgres CRUD grid](./postgres-grid.md) | a server route querying your database |\n| A Supabase project | [Supabase CRUD grid](./supabase-grid.md) | browser client, keys, Row-Level Security |\n| An HTTP / JSON API | [REST CRUD grid](./rest-grid.md) | the REST adapter, no server route at all |\n| An afternoon | [Build a CRM](./tutorial-crm.md) | multi-entity: relations, master-detail, a real DB |\n\n---\n\n## Guided path - answer a few questions, get the app\n\nIf you already know where your data lives, let Studio ask:\n\n```bash\nnpx @svgrid/studio init\n```\n\nIt asks four things - where the data comes from (sample data, your database,\nin-browser Postgres, or a REST API), which tables you want, which pages each\ntable gets, and what it should look like - then writes a runnable SvelteKit app\nwith a list, an edit form and a record page per table, plus an overview\ndashboard.\n\nPointing it at a real database is one line, and Studio installs the driver for\nyou:\n\n```bash\nnpx @svgrid/studio init --db postgres --url $DATABASE_URL --out my-app\n```\n\nOn Supabase there is no driver to install at all - it is read over the project's\nREST API:\n\n```bash\nnpx @svgrid/studio init --supabase-url https://xxxx.supabase.co --supabase-key $SUPABASE_ANON_KEY\n```\n\nPrefer clicking? The visual designer has the same wizard behind its **New app**\nbutton - or open [svgrid.com/studio/new](https://svgrid.com/studio/new) to start\none in the browser. Both paths run the same generator, so they produce the same\napp. See [The Studio CLI](./cli.md#init) for every flag.\n\n---\n\n## Fastest path - a downloadable, ready-to-run example\n\nRather have a working project on your machine than type code into a blank\nfile? One command scaffolds a complete SvelteKit app with everything already\nwired up:\n\n```bash\nnpm create @svgrid/studio@latest my-app\n```\n\nYou'll be asked to pick a **theme** - one of `@svgrid/grid`'s 19 built-in\npresets (shadcn, Tailwind, Material, Excel, Fluent, and more) - and whether to\nstart in **light or dark** mode. Scripting this instead? Both are flags:\n\n```bash\nnpm create @svgrid/studio@latest my-app -- --theme material --dark\n```\n\nThen:\n\n```bash\ncd my-app\nnpm install\nnpm run dev\n```\n\nOpen `http://localhost:5173`. You get:\n\n- A **nav shell** and a home page (`src/routes/+layout.svelte`).\n- Two linked entities - **Customers** and **Orders** - each a full grid +\n modal create/edit/delete screen. Orders has a searchable lookup back to\n Customers, so you can see how relations work.\n- **Seeded in-memory data** - nothing to install or configure, no database.\n- The **theme and mode you picked**, applied to the whole app - not just the grid.\n\nIt is a real project, not a read-only demo - edit it, add fields, connect a\ndatabase, deploy it. A few places to start:\n\n| Want to... | Edit |\n| --- | --- |\n| Add or change a field | `src/lib/schemas.ts` - the grid and the form update together |\n| See how a screen is built | `src/lib/EntityScreen.svelte` - the reusable grid + modal CRUD screen every route uses |\n| Connect a real database | `src/lib/data.ts` - swap `createInMemoryDataSource` for `createSqlDataSource` / `createSupabaseDataSource` (see [Databases](./databases.md)) |\n| Add another screen from a live table | `npx @svgrid/studio add invoices --db postgres --url \"$DATABASE_URL\"` |\n\nThe template's own `README.md` covers the same ground once you're in the\nproject. Prefer to see each piece built up by hand instead, or add a screen to\nan *existing* app rather than a fresh one? Continue below.\n\n---\n\n## Three ways to build - pick yours\n\n\n\nThis is the visual designer - a screens list on the left, your data previewed\nlive in the middle, and simple property panels on the right. You point, click, and\npress **Generate**:\n\n\n\n- **Visual designer (no code).** You never write code - you point, click, and\n preview, then press one button to generate the finished app. Try it\n immediately, no install, at **[svgrid.com/studio](https://svgrid.com/studio)** -\n or run it locally with **[Launch the designer](./launch.md)**, which\n auto-saves to disk and generates straight into a folder. The\n [sample apps](./samples.md) let you open a complete, realistic app in one\n click and point it at your own data.\n- **CLI.** One deterministic command per screen: `npx @svgrid/studio add ...`\n introspects your table or schema and writes the files. No AI involved. This\n page uses the CLI from Step 3 on - continue below.\n- **AI via MCP.** With [`@svgrid/mcp`](./ai-generation.md) configured, ask your\n coding agent to build the screen; it introspects, scaffolds, and\n compile-verifies through the same core the CLI uses.\n\nAll three produce the **same generated code** - pick whichever fits how you\nwork, and switch freely later.\n\n---\n\n## What you need\n\n- **[Node.js](https://nodejs.org) 18 or newer.** Check with `node -v` in a\n terminal. If that errors, install Node first.\n- **A terminal** and a code editor (VS Code is fine).\n- **A SvelteKit app.** Don't have one? Create one in 30 seconds:\n\n ```bash\n npx sv create my-app # pick \"SvelteKit minimal\", TypeScript: yes\n cd my-app\n npm install\n ```\n\nYou do **not** need a database to start - the first screen below runs on\nin-memory data. You can point it at PostgreSQL, Supabase, MySQL, and others\nlater without changing the UI.\n\n---\n\n## Step 1 - Install\n\nInside your app folder:\n\n```bash\nnpm i @svgrid/grid @svgrid/enterprise\n```\n\n- `@svgrid/grid` is the grid itself.\n- `@svgrid/enterprise` adds Studio: the schema, the edit form, and the data-source\n helpers. It comes with a 30-day evaluation period; loaded without a key\n it displays a watermark and a console message about the Enterprise\n package, and everything still runs. See [licensing](../licensing.md).\n\n---\n\n## Step 2 - Describe your data once\n\nThe generator needs one description of your table. If you have a live database\nit can introspect it directly (Step 3 shows that variant). Here we stay\ndatabase-free: describe the table in a small Drizzle schema file, which the\ngenerator **reads as text** - it never connects to anything.\n\n```bash\nnpm i -D drizzle-orm\n```\n\n(`drizzle-orm` is only there so the schema file type-checks; nothing runs\nagainst a database. It is also the natural next step when you do add one.)\n\nCreate `src/lib/db/schema.ts`:\n\n```ts\nimport { pgTable, text, integer, boolean } from 'drizzle-orm/pg-core'\n\nexport const customers = pgTable('customers', {\n id: text('id').primaryKey(),\n name: text('name').notNull(),\n email: text('email').notNull(),\n tier: text('tier').notNull().default('free'),\n mrr: integer('mrr'),\n active: boolean('active'),\n})\n```\n\nA Prisma `schema.prisma` works the same way. Prefer to write Studio's own\nmodel - the `EntitySchema` - by hand instead? That is the\n[appendix](#appendix-wire-it-by-hand-no-generator) at the bottom of this page.\n\n---\n\n## Step 3 - Generate the screen\n\nOne command:\n\n```bash\nnpx @svgrid/studio add customers --from src/lib/db/schema.ts\n```\n\nHave a live database instead? Same command, different source - no schema file\nneeded:\n\n```bash\nnpx @svgrid/studio add customers --db postgres --url \"$DATABASE_URL\"\n```\n\nEither way it writes **three files**, and the screen is done. A quick tour of\nwhat you now own:\n\n**1. `src/lib/customers.schema.ts` - the model.** The generator turned your\ntable into an `EntitySchema` - the single object that drives the grid columns,\nthe form fields, and validation:\n\n```ts\nexport type CustomersRow = {\n id: string\n name: string\n email: string\n tier: string\n mrr: number | null\n active: boolean | null\n}\n\nexport const customersSchema: EntitySchema<CustomersRow> = {\n name: 'customers',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\n { field: 'name', type: 'text', required: true },\n { field: 'email', type: 'text', required: true },\n // ...\n ],\n}\n```\n\nEvery field option (labels, enum choices, min/max, regex, custom validators) is\nexplained in [The EntitySchema](./schema.md).\n\n**2. `src/routes/api/customers/+server.ts` - the API route.** A\n`ServerDataSource` (read + create + update + delete) exposed over one SvelteKit\nendpoint. With `--from` it starts in-memory so it runs immediately; with `--db`\nit is already wired to your database:\n\n```ts\nconst source = createInMemoryDataSource<CustomersRow>([], customersSchema)\n\nexport const { POST } = createKitHandlers({ schema: customersSchema, source })\n```\n\nSwapping in a real database later means replacing that one `source` line - the\npage never changes. See [Databases](./databases.md).\n\n**3. `src/routes/customers/+page.svelte` - the screen.** The grid with\nserver-side sort, filter, global search, a native pagination footer,\nmulti-select delete with optimistic updates, and a modal create / edit form -\nall reading through the API route:\n\n```ts\nconst source = createKitDataSource<CustomersRow>({ endpoint: '/api/customers' })\nconst columns = schemaToColumns(customersSchema)\nconst controller = createServerDataSource<CustomersRow>(source, {\n pageSize: 25, optimistic: true,\n getRowId: (r) => String(r.id),\n onChange: (s) => (state = s),\n})\n```\n\nEach file wraps its generated body in `// svgrid:managed:start` /\n`// svgrid:managed:end` markers. Everything you write **outside** the markers\nis yours; re-running `add` only rewrites what is inside. That is what makes\nStep 5 safe.\n\n> One thing the generated screen inherits from your page: the **font**. A bare\n> `npx sv create` app sets no CSS at all, so add a\n> `body { font-family: system-ui, sans-serif }` rule (or a\n> [`--sg-font`](./theming.md) token) once, or the page renders in the\n> browser's default serif. Borders, backgrounds, and hover states the grid\n> themes itself.\n\n---\n\n## Step 4 - Run it\n\n```bash\nnpm run dev\n```\n\nOpen the URL it prints (usually `http://localhost:5173`) and go to\n**`/customers`**. With `--from` the grid starts empty (in-memory source, no\nseed) - click **New** and add two or three customers, then try the screen:\n\n- Click a **column header** to sort.\n- Type in the **filter row** under a header to filter (it stays focused as you type).\n- Click a **row** to edit. The form validates as you type - clear a required\n field and watch it complain.\n- Select rows with the checkboxes and **Delete** them - the grid updates\n instantly and rolls back if the server says no.\n- Page through with the **native pager** at the bottom.\n\n\n\n---\n\n## Step 5 - Change something\n\nBecause the schema drives everything, changes are one edit. Add a column to\n`src/lib/db/schema.ts`:\n\n```ts\ncountry: text('country'),\n```\n\nThen re-run the exact same command:\n\n```bash\nnpx @svgrid/studio add customers --from src/lib/db/schema.ts\n```\n\nThe managed regions are regenerated: the grid gets a **Country** column and the\nedit form gets a **Country** input. Anything you wrote outside the\n`svgrid:managed` markers - extra buttons, styles, handlers - is untouched.\nThat round trip (change schema, re-run, keep your code) is the everyday\nworkflow; [Code generation](./code-generation.md) explains the rules.\n\nPrefer not to re-run the generator? Editing the generated\n`customers.schema.ts` directly works too - grid and form update together from\nthe one schema object.\n\n---\n\n## Where to go next\n\nYou have the whole shape now. The usual next steps:\n\n- **Understand the model** - [Concepts](./concepts.md) walks the pipeline\n (schema, screens, data source, codegen) once and defines every Studio term.\n- **Connect a real database** - re-run `add` with `--db` and a connection\n string, or swap the one `source` line in the API route; the page does not\n change. For Supabase, follow the one-page\n **[Supabase CRUD grid tutorial](./supabase-grid.md)**; for SQL, see\n [Databases](./databases.md).\n- **Design visually** - `npx @svgrid/studio designer` opens the full app builder:\n compose screens across entities, bind data, and click *Generate app*. See the\n [Visual app designer](./app-designer.md). (To embed a single-entity schema editor\n in your own app, see the [Schema designer](./designer.md).)\n- **Build a full app** - the [Build a CRM tutorial](./tutorial-crm.md) wires up\n companies, contacts, and deals with relations and master-detail.\n\n---\n\n## Appendix: wire it by hand (no generator)\n\nEverything the generator wrote in Step 3 can be built up by hand - useful when\nyou want to see exactly how the pieces fit, or to embed a Studio screen in an\nunusual spot. Two files replace the three generated ones (no API route: here\nthe data source lives in the page itself).\n\nFirst, the `EntitySchema` - Studio's own model, the object the generator\nderived from your Drizzle file. Create `src/lib/customers.ts`:\n\n```ts\nimport type { EntitySchema } from '@svgrid/enterprise'\n\nexport type Customer = {\n id: string\n name: string\n email: string\n tier: 'free' | 'pro' | 'enterprise'\n mrr: number\n active: boolean\n}\n\nexport const customersSchema: EntitySchema<Customer> = {\n name: 'customers',\n label: 'Customer',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\n { field: 'name', type: 'text', required: true, minLength: 2 },\n { field: 'email', type: 'text', label: 'Email', required: true, format: 'email' },\n { field: 'tier', type: 'enum', options: [\n { value: 'free', label: 'Free' },\n { value: 'pro', label: 'Pro' },\n { value: 'enterprise', label: 'Enterprise' },\n ] },\n { field: 'mrr', type: 'number', label: 'MRR ($)', min: 0 },\n { field: 'active', type: 'boolean' },\n ],\n}\n```\n\nThen the page. `createInMemoryDataSource` provides the `ServerDataSource`\ncontract over a plain array, `createServerDataSource` runs sort / filter /\npage / CRUD against it, and the grid + edit panel render it. Create\n`src/routes/customers/+page.svelte`:\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, createServerDataSource, type ServerState } from '@svgrid/grid'\n import { SvGridEditPanel, createInMemoryDataSource, schemaToColumns } from '@svgrid/enterprise'\n import { customersSchema, type Customer } from '$lib/customers'\n\n const seed: Customer[] = [\n { id: 'c1', name: 'Ada Lovelace', email: 'ada@analytic.io', tier: 'enterprise', mrr: 1200, active: true },\n { id: 'c2', name: 'Alan Turing', email: 'alan@bletchley.uk', tier: 'pro', mrr: 240, active: true },\n { id: 'c3', name: 'Grace Hopper', email: 'grace@navy.mil', tier: 'enterprise', mrr: 980, active: true },\n ]\n\n const columns = schemaToColumns(customersSchema)\n const source = createInMemoryDataSource(seed, customersSchema)\n\n let view = $state<ServerState<Customer>>({\n rows: [], total: 0, loading: false, saving: false, error: null,\n pageIndex: 0, pageSize: 10, pageCount: 1, sortModel: [], filterModel: {},\n })\n let editing = $state<Customer | null | undefined>(undefined)\n let genId = 4\n\n const controller = createServerDataSource(source, {\n pageSize: 10, optimistic: true, getRowId: (r) => r.id,\n onChange: (s) => (view = s),\n })\n controller.refresh()\n\n async function save({ mode, id, values }) {\n if (mode === 'create') { await controller.createRow({ id: `c${genId++}`, ...values }); controller.setPage(view.pageCount - 1) }\n else if (id) { await controller.updateRow(id, values) }\n editing = undefined\n }\n</script>\n\n<style>\n :global(body) {\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n }\n</style>\n\n<button onclick={() => (editing = null)}>+ New customer</button>\n\n<SvGrid\n data={view.rows} {columns} loading={view.loading}\n fitColumns enableRowSummaries={false}\n sortable externalSort onSortingChange={(s) => controller.setSort(s)}\n filterable filterMode=\"row\" externalFilter\n onFiltersChange={(f) => controller.setFilter({\n global: f.global || undefined,\n columns: Object.fromEntries(f.columns.map((c) => [c.id, { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues }])),\n })}\n onRowClick={(e) => (editing = e.row)}\n showPagination externalPagination\n rowCount={view.total} pageIndex={view.pageIndex} pageSize={view.pageSize}\n onPaginationChange={({ pageIndex, pageSize }) => pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex)}\n/>\n\n{#if editing !== undefined}\n <SvGridEditPanel schema={customersSchema} row={editing} presentation=\"modal\"\n onSubmit={save} onCancel={() => (editing = undefined)} />\n{/if}\n```\n\nThe `<style>` block is just a plain font reset - a fresh `npx sv create` app ships no CSS\nat all, so without it the page falls back to the browser's default serif font. `<SvGrid>`\nand `<SvGridEditPanel>` already theme their own borders, backgrounds, and hover states out\nof the box (via [`--sg-*` tokens](../../help/tokens.md) with built-in fallbacks) - font is\nthe one thing they intentionally inherit from the page rather than force, so it fits\nwhatever type your app already uses. If your app already sets a body font (or a\n[`--sg-font`](./theming.md) token), skip this block.\n\n---\n\n<!-- tutorial:install-studio -->\n<figure class=\"docs-tutorial\" id=\"tutorial-install-studio\" data-docs-tutorial=\"install-studio\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/install-studio.mp4\" poster=\"/tutorials/install-studio.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"SvGrid Studio: database to CRUD app, 72 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/install-studio.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/install-studio.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>SvGrid Studio: database to CRUD app</strong> (72 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>SvGrid Studio turns tables into a SvelteKit app. From the command line, one create command scaffolds a runnable app with sample tables.</p>\n<p>Point init at a real database and it reads the tables, then writes a list, an edit form and a record page for each one.</p>\n<p>The same generator runs in the browser. The designer opens with a wizard: start from sample data, your own database, or blank.</p>\n<p>Pick a dataset, choose which pages each table gets, and open the app.</p>\n<p>Two tables became six screens: an overview with a KPI and a chart, a list, a manage page and a detail page per table, all on the canvas to rearrange.</p>\n<p>Preview runs the app as a user sees it: the grid, the new record button, the navigation.</p>\n<p>Generate app writes the SvelteKit project: twenty-seven files, typed schemas, routes and a CI workflow. Download the zip or open it in StackBlitz.</p>\n</details>\n</figure>\n<!-- /tutorial:install-studio -->\n\n## See also\n\n- [SvGrid Studio overview](../studio.md)\n- [Concepts](./concepts.md) - the mental model + glossary\n- [Data binding](./data-binding.md) - the `ServerDataSource` contract in depth\n- [Edit forms & validation](./edit-forms.md)\n- [Troubleshooting & FAQ](./troubleshooting.md)\n"
|
|
3743
|
+
"markdown": "# Getting started\n\nThis is the gentlest path into SvGrid Studio. By the end you will have a real,\nworking **Customers** screen - a grid you can sort, filter, and page, with a\ncreate / edit form and delete - running on your machine. No prior experience\nwith SvGrid is assumed.\n\nIf you would rather just click around first, open a live demo - no install\nneeded:\n\n- **[Studio live SQL](https://svgrid.com/demos/193-studio-live-sql/)** - the whole stack in the browser, backed by real Postgres via PGlite\n- **[Supabase](https://svgrid.com/demos/194-studio-supabase/)** - connect your own hosted Postgres\n- **[The designer itself](https://svgrid.com/studio)** - open a sample app and edit it, nothing to install\n\n**Choose your tutorial.** This page needs no database and teaches the whole\nshape; the one-page tutorials build the same screen against a real backend.\nThey all end in the same place, so pick by what you have:\n\n| You have | Follow | What it adds |\n| --- | --- | --- |\n| Nothing yet | this page | the full path: install, schema, generate, run, change |\n| A Postgres connection string | [Postgres CRUD grid](./postgres-grid.md) | a server route querying your database |\n| A Supabase project | [Supabase CRUD grid](./supabase-grid.md) | browser client, keys, Row-Level Security |\n| An HTTP / JSON API | [REST CRUD grid](./rest-grid.md) | the REST adapter, no server route at all |\n| An afternoon | [Build a CRM](./tutorial-crm.md) | multi-entity: relations, master-detail, a real DB |\n\n---\n\n<!-- tutorial:install-studio -->\n<figure class=\"docs-tutorial docs-tutorial-player\" id=\"tutorial-install-studio\" data-docs-tutorial=\"install-studio\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/install-studio.mp4\" poster=\"/tutorials/install-studio.poster.webp\" width=\"960\" height=\"540\" controls playsinline preload=\"none\" aria-label=\"SvGrid Studio: database to CRUD app, 69 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/install-studio.vtt\">Your browser does not play embedded video. <a href=\"/tutorials/install-studio.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>SvGrid Studio: database to CRUD app</strong> (1:09, with narration).</figcaption>\n</figure>\n<!-- /tutorial:install-studio -->\n\n## Guided path - answer a few questions, get the app\n\nIf you already know where your data lives, let Studio ask:\n\n```bash\nnpx @svgrid/studio init\n```\n\nIt asks four things - where the data comes from (sample data, your database,\nin-browser Postgres, or a REST API), which tables you want, which pages each\ntable gets, and what it should look like - then writes a runnable SvelteKit app\nwith a list, an edit form and a record page per table, plus an overview\ndashboard.\n\nPointing it at a real database is one line, and Studio installs the driver for\nyou:\n\n```bash\nnpx @svgrid/studio init --db postgres --url $DATABASE_URL --out my-app\n```\n\nOn Supabase there is no driver to install at all - it is read over the project's\nREST API:\n\n```bash\nnpx @svgrid/studio init --supabase-url https://xxxx.supabase.co --supabase-key $SUPABASE_ANON_KEY\n```\n\nPrefer clicking? The visual designer has the same wizard behind its **New app**\nbutton - or open [svgrid.com/studio/new](https://svgrid.com/studio/new) to start\none in the browser. Both paths run the same generator, so they produce the same\napp. See [The Studio CLI](./cli.md#init) for every flag.\n\n---\n\n## Fastest path - a downloadable, ready-to-run example\n\nRather have a working project on your machine than type code into a blank\nfile? One command scaffolds a complete SvelteKit app with everything already\nwired up:\n\n```bash\nnpm create @svgrid/studio@latest my-app\n```\n\nYou'll be asked to pick a **theme** - one of `@svgrid/grid`'s 19 built-in\npresets (shadcn, Tailwind, Material, Excel, Fluent, and more) - and whether to\nstart in **light or dark** mode. Scripting this instead? Both are flags:\n\n```bash\nnpm create @svgrid/studio@latest my-app -- --theme material --dark\n```\n\nThen:\n\n```bash\ncd my-app\nnpm install\nnpm run dev\n```\n\nOpen `http://localhost:5173`. You get:\n\n- A **nav shell** and a home page (`src/routes/+layout.svelte`).\n- Two linked entities - **Customers** and **Orders** - each a full grid +\n modal create/edit/delete screen. Orders has a searchable lookup back to\n Customers, so you can see how relations work.\n- **Seeded in-memory data** - nothing to install or configure, no database.\n- The **theme and mode you picked**, applied to the whole app - not just the grid.\n\nIt is a real project, not a read-only demo - edit it, add fields, connect a\ndatabase, deploy it. A few places to start:\n\n| Want to... | Edit |\n| --- | --- |\n| Add or change a field | `src/lib/schemas.ts` - the grid and the form update together |\n| See how a screen is built | `src/lib/EntityScreen.svelte` - the reusable grid + modal CRUD screen every route uses |\n| Connect a real database | `src/lib/data.ts` - swap `createInMemoryDataSource` for `createSqlDataSource` / `createSupabaseDataSource` (see [Databases](./databases.md)) |\n| Add another screen from a live table | `npx @svgrid/studio add invoices --db postgres --url \"$DATABASE_URL\"` |\n\nThe template's own `README.md` covers the same ground once you're in the\nproject. Prefer to see each piece built up by hand instead, or add a screen to\nan *existing* app rather than a fresh one? Continue below.\n\n---\n\n## Three ways to build - pick yours\n\n\n\nThis is the visual designer - a screens list on the left, your data previewed\nlive in the middle, and simple property panels on the right. You point, click, and\npress **Generate**:\n\n\n\n- **Visual designer (no code).** You never write code - you point, click, and\n preview, then press one button to generate the finished app. Try it\n immediately, no install, at **[svgrid.com/studio](https://svgrid.com/studio)** -\n or run it locally with **[Launch the designer](./launch.md)**, which\n auto-saves to disk and generates straight into a folder. The\n [sample apps](./samples.md) let you open a complete, realistic app in one\n click and point it at your own data.\n- **CLI.** One deterministic command per screen: `npx @svgrid/studio add ...`\n introspects your table or schema and writes the files. No AI involved. This\n page uses the CLI from Step 3 on - continue below.\n- **AI via MCP.** With [`@svgrid/mcp`](./ai-generation.md) configured, ask your\n coding agent to build the screen; it introspects, scaffolds, and\n compile-verifies through the same core the CLI uses.\n\nAll three produce the **same generated code** - pick whichever fits how you\nwork, and switch freely later.\n\n---\n\n## What you need\n\n- **[Node.js](https://nodejs.org) 18 or newer.** Check with `node -v` in a\n terminal. If that errors, install Node first.\n- **A terminal** and a code editor (VS Code is fine).\n- **A SvelteKit app.** Don't have one? Create one in 30 seconds:\n\n ```bash\n npx sv create my-app # pick \"SvelteKit minimal\", TypeScript: yes\n cd my-app\n npm install\n ```\n\nYou do **not** need a database to start - the first screen below runs on\nin-memory data. You can point it at PostgreSQL, Supabase, MySQL, and others\nlater without changing the UI.\n\n---\n\n## Step 1 - Install\n\nInside your app folder:\n\n```bash\nnpm i @svgrid/grid @svgrid/enterprise\n```\n\n- `@svgrid/grid` is the grid itself.\n- `@svgrid/enterprise` adds Studio: the schema, the edit form, and the data-source\n helpers. No key is needed to evaluate it; loaded without a key\n it displays a watermark and a console message about the Enterprise\n package, and everything still runs. See [licensing](../licensing.md).\n\n---\n\n## Step 2 - Describe your data once\n\nThe generator needs one description of your table. If you have a live database\nit can introspect it directly (Step 3 shows that variant). Here we stay\ndatabase-free: describe the table in a small Drizzle schema file, which the\ngenerator **reads as text** - it never connects to anything.\n\n```bash\nnpm i -D drizzle-orm\n```\n\n(`drizzle-orm` is only there so the schema file type-checks; nothing runs\nagainst a database. It is also the natural next step when you do add one.)\n\nCreate `src/lib/db/schema.ts`:\n\n```ts\nimport { pgTable, text, integer, boolean } from 'drizzle-orm/pg-core'\n\nexport const customers = pgTable('customers', {\n id: text('id').primaryKey(),\n name: text('name').notNull(),\n email: text('email').notNull(),\n tier: text('tier').notNull().default('free'),\n mrr: integer('mrr'),\n active: boolean('active'),\n})\n```\n\nA Prisma `schema.prisma` works the same way. Prefer to write Studio's own\nmodel - the `EntitySchema` - by hand instead? That is the\n[appendix](#appendix-wire-it-by-hand-no-generator) at the bottom of this page.\n\n---\n\n## Step 3 - Generate the screen\n\nOne command:\n\n```bash\nnpx @svgrid/studio add customers --from src/lib/db/schema.ts\n```\n\nHave a live database instead? Same command, different source - no schema file\nneeded:\n\n```bash\nnpx @svgrid/studio add customers --db postgres --url \"$DATABASE_URL\"\n```\n\nEither way it writes **three files**, and the screen is done. A quick tour of\nwhat you now own:\n\n**1. `src/lib/customers.schema.ts` - the model.** The generator turned your\ntable into an `EntitySchema` - the single object that drives the grid columns,\nthe form fields, and validation:\n\n```ts\nexport type CustomersRow = {\n id: string\n name: string\n email: string\n tier: string\n mrr: number | null\n active: boolean | null\n}\n\nexport const customersSchema: EntitySchema<CustomersRow> = {\n name: 'customers',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\n { field: 'name', type: 'text', required: true },\n { field: 'email', type: 'text', required: true },\n // ...\n ],\n}\n```\n\nEvery field option (labels, enum choices, min/max, regex, custom validators) is\nexplained in [The EntitySchema](./schema.md).\n\n**2. `src/routes/api/customers/+server.ts` - the API route.** A\n`ServerDataSource` (read + create + update + delete) exposed over one SvelteKit\nendpoint. With `--from` it starts in-memory so it runs immediately; with `--db`\nit is already wired to your database:\n\n```ts\nconst source = createInMemoryDataSource<CustomersRow>([], customersSchema)\n\nexport const { POST } = createKitHandlers({ schema: customersSchema, source })\n```\n\nSwapping in a real database later means replacing that one `source` line - the\npage never changes. See [Databases](./databases.md).\n\n**3. `src/routes/customers/+page.svelte` - the screen.** The grid with\nserver-side sort, filter, global search, a native pagination footer,\nmulti-select delete with optimistic updates, and a modal create / edit form -\nall reading through the API route:\n\n```ts\nconst source = createKitDataSource<CustomersRow>({ endpoint: '/api/customers' })\nconst columns = schemaToColumns(customersSchema)\nconst controller = createServerDataSource<CustomersRow>(source, {\n pageSize: 25, optimistic: true,\n getRowId: (r) => String(r.id),\n onChange: (s) => (state = s),\n})\n```\n\nEach file wraps its generated body in `// svgrid:managed:start` /\n`// svgrid:managed:end` markers. Everything you write **outside** the markers\nis yours; re-running `add` only rewrites what is inside. That is what makes\nStep 5 safe.\n\n> One thing the generated screen inherits from your page: the **font**. A bare\n> `npx sv create` app sets no CSS at all, so add a\n> `body { font-family: system-ui, sans-serif }` rule (or a\n> [`--sg-font`](./theming.md) token) once, or the page renders in the\n> browser's default serif. Borders, backgrounds, and hover states the grid\n> themes itself.\n\n---\n\n## Step 4 - Run it\n\n```bash\nnpm run dev\n```\n\nOpen the URL it prints (usually `http://localhost:5173`) and go to\n**`/customers`**. With `--from` the grid starts empty (in-memory source, no\nseed) - click **New** and add two or three customers, then try the screen:\n\n- Click a **column header** to sort.\n- Type in the **filter row** under a header to filter (it stays focused as you type).\n- Click a **row** to edit. The form validates as you type - clear a required\n field and watch it complain.\n- Select rows with the checkboxes and **Delete** them - the grid updates\n instantly and rolls back if the server says no.\n- Page through with the **native pager** at the bottom.\n\n\n\n---\n\n## Step 5 - Change something\n\nBecause the schema drives everything, changes are one edit. Add a column to\n`src/lib/db/schema.ts`:\n\n```ts\ncountry: text('country'),\n```\n\nThen re-run the exact same command:\n\n```bash\nnpx @svgrid/studio add customers --from src/lib/db/schema.ts\n```\n\nThe managed regions are regenerated: the grid gets a **Country** column and the\nedit form gets a **Country** input. Anything you wrote outside the\n`svgrid:managed` markers - extra buttons, styles, handlers - is untouched.\nThat round trip (change schema, re-run, keep your code) is the everyday\nworkflow; [Code generation](./code-generation.md) explains the rules.\n\nPrefer not to re-run the generator? Editing the generated\n`customers.schema.ts` directly works too - grid and form update together from\nthe one schema object.\n\n---\n\n## Where to go next\n\nYou have the whole shape now. The usual next steps:\n\n- **Understand the model** - [Concepts](./concepts.md) walks the pipeline\n (schema, screens, data source, codegen) once and defines every Studio term.\n- **Connect a real database** - re-run `add` with `--db` and a connection\n string, or swap the one `source` line in the API route; the page does not\n change. For Supabase, follow the one-page\n **[Supabase CRUD grid tutorial](./supabase-grid.md)**; for SQL, see\n [Databases](./databases.md).\n- **Design visually** - `npx @svgrid/studio designer` opens the full app builder:\n compose screens across entities, bind data, and click *Generate app*. See the\n [Visual app designer](./app-designer.md). (To embed a single-entity schema editor\n in your own app, see the [Schema designer](./designer.md).)\n- **Build a full app** - the [Build a CRM tutorial](./tutorial-crm.md) wires up\n companies, contacts, and deals with relations and master-detail.\n\n---\n\n## Appendix: wire it by hand (no generator)\n\nEverything the generator wrote in Step 3 can be built up by hand - useful when\nyou want to see exactly how the pieces fit, or to embed a Studio screen in an\nunusual spot. Two files replace the three generated ones (no API route: here\nthe data source lives in the page itself).\n\nFirst, the `EntitySchema` - Studio's own model, the object the generator\nderived from your Drizzle file. Create `src/lib/customers.ts`:\n\n```ts\nimport type { EntitySchema } from '@svgrid/enterprise'\n\nexport type Customer = {\n id: string\n name: string\n email: string\n tier: 'free' | 'pro' | 'enterprise'\n mrr: number\n active: boolean\n}\n\nexport const customersSchema: EntitySchema<Customer> = {\n name: 'customers',\n label: 'Customer',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\n { field: 'name', type: 'text', required: true, minLength: 2 },\n { field: 'email', type: 'text', label: 'Email', required: true, format: 'email' },\n { field: 'tier', type: 'enum', options: [\n { value: 'free', label: 'Free' },\n { value: 'pro', label: 'Pro' },\n { value: 'enterprise', label: 'Enterprise' },\n ] },\n { field: 'mrr', type: 'number', label: 'MRR ($)', min: 0 },\n { field: 'active', type: 'boolean' },\n ],\n}\n```\n\nThen the page. `createInMemoryDataSource` provides the `ServerDataSource`\ncontract over a plain array, `createServerDataSource` runs sort / filter /\npage / CRUD against it, and the grid + edit panel render it. Create\n`src/routes/customers/+page.svelte`:\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, createServerDataSource, type ServerState } from '@svgrid/grid'\n import { SvGridEditPanel, createInMemoryDataSource, schemaToColumns } from '@svgrid/enterprise'\n import { customersSchema, type Customer } from '$lib/customers'\n\n const seed: Customer[] = [\n { id: 'c1', name: 'Ada Lovelace', email: 'ada@analytic.io', tier: 'enterprise', mrr: 1200, active: true },\n { id: 'c2', name: 'Alan Turing', email: 'alan@bletchley.uk', tier: 'pro', mrr: 240, active: true },\n { id: 'c3', name: 'Grace Hopper', email: 'grace@navy.mil', tier: 'enterprise', mrr: 980, active: true },\n ]\n\n const columns = schemaToColumns(customersSchema)\n const source = createInMemoryDataSource(seed, customersSchema)\n\n let view = $state<ServerState<Customer>>({\n rows: [], total: 0, loading: false, saving: false, error: null,\n pageIndex: 0, pageSize: 10, pageCount: 1, sortModel: [], filterModel: {},\n })\n let editing = $state<Customer | null | undefined>(undefined)\n let genId = 4\n\n const controller = createServerDataSource(source, {\n pageSize: 10, optimistic: true, getRowId: (r) => r.id,\n onChange: (s) => (view = s),\n })\n controller.refresh()\n\n async function save({ mode, id, values }) {\n if (mode === 'create') { await controller.createRow({ id: `c${genId++}`, ...values }); controller.setPage(view.pageCount - 1) }\n else if (id) { await controller.updateRow(id, values) }\n editing = undefined\n }\n</script>\n\n<style>\n :global(body) {\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n }\n</style>\n\n<button onclick={() => (editing = null)}>+ New customer</button>\n\n<SvGrid\n data={view.rows} {columns} loading={view.loading}\n fitColumns enableRowSummaries={false}\n sortable externalSort onSortingChange={(s) => controller.setSort(s)}\n filterable filterMode=\"row\" externalFilter\n onFiltersChange={(f) => controller.setFilter({\n global: f.global || undefined,\n columns: Object.fromEntries(f.columns.map((c) => [c.id, { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues }])),\n })}\n onRowClick={(e) => (editing = e.row)}\n showPagination externalPagination\n rowCount={view.total} pageIndex={view.pageIndex} pageSize={view.pageSize}\n onPaginationChange={({ pageIndex, pageSize }) => pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex)}\n/>\n\n{#if editing !== undefined}\n <SvGridEditPanel schema={customersSchema} row={editing} presentation=\"modal\"\n onSubmit={save} onCancel={() => (editing = undefined)} />\n{/if}\n```\n\nThe `<style>` block is just a plain font reset - a fresh `npx sv create` app ships no CSS\nat all, so without it the page falls back to the browser's default serif font. `<SvGrid>`\nand `<SvGridEditPanel>` already theme their own borders, backgrounds, and hover states out\nof the box (via [`--sg-*` tokens](../../help/tokens.md) with built-in fallbacks) - font is\nthe one thing they intentionally inherit from the page rather than force, so it fits\nwhatever type your app already uses. If your app already sets a body font (or a\n[`--sg-font`](./theming.md) token), skip this block.\n\n---\n\n## See also\n\n- [SvGrid Studio overview](../studio.md)\n- [Concepts](./concepts.md) - the mental model + glossary\n- [Data binding](./data-binding.md) - the `ServerDataSource` contract in depth\n- [Edit forms & validation](./edit-forms.md)\n- [Troubleshooting & FAQ](./troubleshooting.md)\n"
|
|
3727
3744
|
},
|
|
3728
3745
|
{
|
|
3729
3746
|
"slug": "enterprise/studio/i18n",
|
|
@@ -3884,7 +3901,7 @@ export const docs = [
|
|
|
3884
3901
|
"path": "docs/enterprise/studio/troubleshooting.md",
|
|
3885
3902
|
"title": "Troubleshooting & FAQ",
|
|
3886
3903
|
"section": "Studio",
|
|
3887
|
-
"markdown": "# Troubleshooting & FAQ\n\nCommon issues and answers when building with SvGrid Studio.\n\n## Troubleshooting\n\n### The watermark or the \"Unlock Pro\" card shows\n\nStudio is loaded without a license key, so it displays the watermark and the\nconsole message about the Enterprise package. Set your license key once at\napp startup to remove them:\n\n```ts\nimport { setLicenseKey } from '@svgrid/enterprise'\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\n```\n\nFor the [MCP generator](./ai-generation.md), set `SVGRID_LICENSE_KEY` in the MCP\nconfig env. See [licensing](../licensing.md).\n\n### \"Cannot find module 'pg'\" (or mysql2 / mssql / better-sqlite3)\n\nNo database driver is bundled - install the one for your database:\n\n```bash\nnpm i pg # postgres / supabase\nnpm i mysql2 # mysql / mariadb\nnpm i mssql # sql server\nnpm i better-sqlite3 # sqlite\n```\n\nIn the local designer you don't need to run these by hand - when a connect or\npreview fails on a missing driver, the SQL builder shows an **Install** button\nthat adds it and retries.\n\n### I can't connect to my SQL database in the online designer\n\nExpected. A web browser can't open a raw TCP connection to Postgres / MySQL / SQL\nServer, and the driver is a server-side package, so **live connect / test /\npreview only run in the local designer** (`npx @svgrid/studio designer`). In the\nonline designer, bind the entity (dialect + table + schema) and **Generate app** -\nthe app connects for real at runtime via `DATABASE_URL`. For a live database\ninside the online designer, use **Supabase** or the **Local database** (PGlite),\nboth reachable from the browser.\n\n### The database connection fails\n\n- Check `DATABASE_URL` is set on the **server** (not client code).\n- On serverless / edge, use a **pooler** URL (Supabase pooler on port `6543`,\n PgBouncer) - direct connections are scarce.\n- Add SSL params if your provider requires them (`?sslmode=require`, or the\n driver's SSL option).\n\n### My edits were overwritten after regenerating\n\nOnly the `svgrid:managed` regions are regenerated. Put custom code, imports, and\nlayout **outside** those markers and they survive. See\n[Code generation](./code-generation.md).\n\n### Columns don't fill the width, or a summary row appears\n\nAdd `fitColumns` to scale columns to the grid width, and\n`enableRowSummaries={false}` to remove the aggregate footer. The generated\nscreens set both, which is why they carry that second prop.\n\n### Sorting / filtering / paging does nothing\n\nServer mode needs three things wired: the `external*` switch, the matching\n`on*Change` handler routed to the controller, and (for paging) `rowCount`. See\n[Sorting, filtering & paging](./server-grid.md).\n\n### The page-size dropdown shows the wrong number\n\nThe footer offers `10 / 25 / 50 / 100`. Start with a `pageSize` that matches one\nof those so the select reflects the current size.\n\n### Validation isn't firing\n\nValidation runs on **submit**. Confirm the field has `required`, a `format` /\n`min` / `pattern` constraint, or a `validate` function - see\n[Edit forms & validation](./edit-forms.md). Read-only and primary-key fields are\nnever validated.\n\n### The edit form covers the whole page in the designer preview\n\nThe default `presentation` is `drawer` (a full-height overlay). In an inline\npreview, pass `presentation=\"inline\"`.\n\n### The MCP generator tools aren't available in my agent\n\nConfirm `@svgrid/mcp` is in your agent's `mcpServers` config and **restart the\nagent** so it reloads the server. See [AI generation](./ai-generation.md).\n\n### Generated code doesn't compile\n\nRun your project's check - `npx svelte-check`. The AI path compiles the page\nbefore returning it; the CLI relies on your project's check as the final gate.\n\n### Introspection missed a table or columns\n\n`--from` (Drizzle) is a best-effort source parse. Pick a specific table with\n`--table`, or refine the generated `EntitySchema` (types, enums, relations) in\nthe file or the [visual designer](./designer.md). For a live database, `--db`\nreads the catalog directly and is more precise.\n\n## Frequently asked questions\n\n### Do I need a database to try Studio?\n\nNo. Scaffolding from a Drizzle schema starts [in-memory](./in-memory.md), so the\nscreen runs immediately. You can also run a real Postgres **entirely in the\nbrowser** with [PGlite](https://pglite.dev) - point `createSqlDataSource`'s\n`execute` at `db.query(text, params)`.\n\n### Which databases are supported?\n\nPostgreSQL, Supabase, MySQL / MariaDB, SQL Server, and SQLite out of the box, and\nany other SQL client (or REST / GraphQL API) via a\n[custom source](./rest-api.md). See [Databases](./databases.md).\n\n### Is my data or schema sent anywhere?\n\nNo. There is no telemetry and no network call for licensing (validated locally).\nThe AI generator uses **your** model API key; your data never touches our\nservers.\n\n### Can I edit the generated code?\n\nYes - it is plain SvelteKit you own. Edit freely outside the `svgrid:managed`\nmarkers and your changes survive regeneration.\n\n### Does it work with my ORM / query builder?\n\nYes. `createSqlDataSource` takes a single `execute(text, params)`, so Drizzle,\npostgres.js, better-sqlite3, mysql2, Kysely, and others all work.\n\n### Is Studio free?\n\nIt is part of the Enterprise license
|
|
3904
|
+
"markdown": "# Troubleshooting & FAQ\n\nCommon issues and answers when building with SvGrid Studio.\n\n## Troubleshooting\n\n### The watermark or the \"Unlock Pro\" card shows\n\nStudio is loaded without a license key, so it displays the watermark and the\nconsole message about the Enterprise package. Set your license key once at\napp startup to remove them:\n\n```ts\nimport { setLicenseKey } from '@svgrid/enterprise'\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\n```\n\nFor the [MCP generator](./ai-generation.md), set `SVGRID_LICENSE_KEY` in the MCP\nconfig env. See [licensing](../licensing.md).\n\n### \"Cannot find module 'pg'\" (or mysql2 / mssql / better-sqlite3)\n\nNo database driver is bundled - install the one for your database:\n\n```bash\nnpm i pg # postgres / supabase\nnpm i mysql2 # mysql / mariadb\nnpm i mssql # sql server\nnpm i better-sqlite3 # sqlite\n```\n\nIn the local designer you don't need to run these by hand - when a connect or\npreview fails on a missing driver, the SQL builder shows an **Install** button\nthat adds it and retries.\n\n### I can't connect to my SQL database in the online designer\n\nExpected. A web browser can't open a raw TCP connection to Postgres / MySQL / SQL\nServer, and the driver is a server-side package, so **live connect / test /\npreview only run in the local designer** (`npx @svgrid/studio designer`). In the\nonline designer, bind the entity (dialect + table + schema) and **Generate app** -\nthe app connects for real at runtime via `DATABASE_URL`. For a live database\ninside the online designer, use **Supabase** or the **Local database** (PGlite),\nboth reachable from the browser.\n\n### The database connection fails\n\n- Check `DATABASE_URL` is set on the **server** (not client code).\n- On serverless / edge, use a **pooler** URL (Supabase pooler on port `6543`,\n PgBouncer) - direct connections are scarce.\n- Add SSL params if your provider requires them (`?sslmode=require`, or the\n driver's SSL option).\n\n### My edits were overwritten after regenerating\n\nOnly the `svgrid:managed` regions are regenerated. Put custom code, imports, and\nlayout **outside** those markers and they survive. See\n[Code generation](./code-generation.md).\n\n### Columns don't fill the width, or a summary row appears\n\nAdd `fitColumns` to scale columns to the grid width, and\n`enableRowSummaries={false}` to remove the aggregate footer. The generated\nscreens set both, which is why they carry that second prop.\n\n### Sorting / filtering / paging does nothing\n\nServer mode needs three things wired: the `external*` switch, the matching\n`on*Change` handler routed to the controller, and (for paging) `rowCount`. See\n[Sorting, filtering & paging](./server-grid.md).\n\n### The page-size dropdown shows the wrong number\n\nThe footer offers `10 / 25 / 50 / 100`. Start with a `pageSize` that matches one\nof those so the select reflects the current size.\n\n### Validation isn't firing\n\nValidation runs on **submit**. Confirm the field has `required`, a `format` /\n`min` / `pattern` constraint, or a `validate` function - see\n[Edit forms & validation](./edit-forms.md). Read-only and primary-key fields are\nnever validated.\n\n### The edit form covers the whole page in the designer preview\n\nThe default `presentation` is `drawer` (a full-height overlay). In an inline\npreview, pass `presentation=\"inline\"`.\n\n### The MCP generator tools aren't available in my agent\n\nConfirm `@svgrid/mcp` is in your agent's `mcpServers` config and **restart the\nagent** so it reloads the server. See [AI generation](./ai-generation.md).\n\n### Generated code doesn't compile\n\nRun your project's check - `npx svelte-check`. The AI path compiles the page\nbefore returning it; the CLI relies on your project's check as the final gate.\n\n### Introspection missed a table or columns\n\n`--from` (Drizzle) is a best-effort source parse. Pick a specific table with\n`--table`, or refine the generated `EntitySchema` (types, enums, relations) in\nthe file or the [visual designer](./designer.md). For a live database, `--db`\nreads the catalog directly and is more precise.\n\n## Frequently asked questions\n\n### Do I need a database to try Studio?\n\nNo. Scaffolding from a Drizzle schema starts [in-memory](./in-memory.md), so the\nscreen runs immediately. You can also run a real Postgres **entirely in the\nbrowser** with [PGlite](https://pglite.dev) - point `createSqlDataSource`'s\n`execute` at `db.query(text, params)`.\n\n### Which databases are supported?\n\nPostgreSQL, Supabase, MySQL / MariaDB, SQL Server, and SQLite out of the box, and\nany other SQL client (or REST / GraphQL API) via a\n[custom source](./rest-api.md). See [Databases](./databases.md).\n\n### Is my data or schema sent anywhere?\n\nNo. There is no telemetry and no network call for licensing (validated locally).\nThe AI generator uses **your** model API key; your data never touches our\nservers.\n\n### Can I edit the generated code?\n\nYes - it is plain SvelteKit you own. Edit freely outside the `svgrid:managed`\nmarkers and your changes survive regeneration.\n\n### Does it work with my ORM / query builder?\n\nYes. `createSqlDataSource` takes a single `execute(text, params)`, so Drizzle,\npostgres.js, better-sqlite3, mysql2, Kysely, and others all work.\n\n### Is Studio free?\n\nIt is part of the Enterprise license. You can evaluate it without a key: it runs with the watermark and the console message about the\nEnterprise package. See [pricing](https://svgrid.com/pricing/).\n\n## See also\n\n- [SvGrid Studio overview](../studio.md) · [Databases](./databases.md) · [Deploying a Studio app](./deployment.md)\n"
|
|
3888
3905
|
},
|
|
3889
3906
|
{
|
|
3890
3907
|
"slug": "enterprise/studio/tutorial-crm",
|
|
@@ -3912,63 +3929,70 @@ export const docs = [
|
|
|
3912
3929
|
"path": "docs/getting-started.md",
|
|
3913
3930
|
"title": "Getting started with SvGrid",
|
|
3914
3931
|
"section": "Overview",
|
|
3915
|
-
"markdown": "# Getting started with SvGrid\n\nSvGrid is a modern, production-ready data grid for Svelte 5 - a headless\ncore engine paired with a Svelte render component (`<SvGrid>`). It\nscales from a 10-row read-only table to a virtualized\n100,000-row × 100-column editing surface with grouping, multi-column\nfiltering, server-side data, and full keyboard and screen-reader support.\n\nThis guide is six short pages. Read them in order if you're new; jump\nstraight to the one you need if you're not.\n\n> In a hurry? `npm create @svgrid@latest` scaffolds a working project in\n> one command - see [Starters & scaffolding](./getting-started/starters.md).\n\n| # | Page | What it covers |\n| --- | ------------------------------------------------------------- | --------------------------------------------------------------------------- |\n| 0 | [Starters & scaffolding](./getting-started/starters.md) | `npm create @svgrid`, the minimal + SvelteKit admin templates, Deploy to Vercel. |\n| 1 | [Install](./getting-started/1-install.md) | `pnpm add @svgrid/grid`, requirements, smoke-test. |\n| 2 | [First grid](./getting-started/2-first-grid.md) | The minimum runnable example, explained line by line. |\n| 3 | [Data and columns](./getting-started/3-data-and-columns.md) | What goes in `data` and `columns`. Custom cells via `renderSnippet`. |\n| 4 | [Features](./getting-started/4-features.md) | Opt into sort, filter, pagination, grouping, selection, editing. |\n| 5 | [Theme and density](./getting-started/5-theme-and-density.md) | `--sg-*` tokens, dark mode, row height, sizing the grid in a flex layout. |\n| 6 | [Going to production](./getting-started/6-going-to-production.md) | Server-side data, virtualization, a11y, SSR, CSP, TypeScript notes. |\n\nEstimated reading time: 15 minutes across all six pages.\n\n## Companion reads\n\n- [Why headless?](./why-headless.md) - the architecture decision behind\n the `createSvGrid` core vs. the `<SvGrid>` render component.\n- [Tailwind integration](./help/tailwind.md) - how the `--sg-*` custom\n properties, Tailwind v4, and dark mode fit together.\n- [Web Components & Custom Elements](./help/web-components.md) - use SvGrid\n as a framework-agnostic `<sv-grid>` element in React, Vue, Angular, or\n plain HTML.\n- [Enterprise features](./enterprise/README.md) - the paid feature pack: data export,\n data import, AI assistant, and pivot tables.\n\n## One-page version\n\nPrefer a single 800-line file? See\n[getting-started-full.md](./getting-started-full.md). The content is\nidentical; the split exists for sidebar nav and faster mobile loads.\n\n## License\n\n`@svgrid/grid` is published under the **MIT License** - permissive\nfor commercial use, redistribution, and modification. The paid\ncompanion `@svgrid/enterprise` ships under a separate commercial license. See\n[LICENSE](../LICENSE) and\n[packages/enterprise/LICENSE](../packages/enterprise/LICENSE).\n\n## Frequently asked questions\n\n### How do I add a data grid to a Svelte 5 app?\n\nInstall `@svgrid/grid`, import `SvGrid`, and pass `data` and `columns`.\nA complete grid is about 15 lines - keyboard navigation and accessibility are\non by default, and you opt into sort/filter/edit/group/paging with one boolean\nshortcut each (`sortable`, `filterable`, ...). See\n[First grid](./getting-started/2-first-grid.md) and\n[Features](./getting-started/4-features.md#capability-shortcuts-the-quick-way).\n\n### Does SvGrid work with SvelteKit?\n\nYes. It renders meaningful HTML before hydration, so it works under SvelteKit\nSSR and static builds. Drive large datasets with controlled, server-side state.\n\n### Is SvGrid free for commercial use?\n\nYes. `@svgrid/grid` is MIT-licensed, including commercial use, with no row\ncap or license key. The optional `@svgrid/enterprise` pack (export, pivot, import, AI)\nships under a separate paid license.\n\n### What do I need to run SvGrid?\n\nSvelte 5 (runes) and any Vite-based toolchain. There are no other required\ndependencies for the Community core; Enterprise export/import features lazy-load their\nown dependencies only when used.\n"
|
|
3932
|
+
"markdown": "# Getting started with SvGrid\n\nSvGrid is a modern, production-ready data grid for Svelte 5 - a headless\ncore engine paired with a Svelte render component (`<SvGrid>`). It\nscales from a 10-row read-only table to a virtualized\n100,000-row × 100-column editing surface with grouping, multi-column\nfiltering, server-side data, and full keyboard and screen-reader support.\n\n<!-- tutorial:mk-what-is-svgrid -->\n<figure class=\"docs-tutorial docs-tutorial-player\" id=\"tutorial-mk-what-is-svgrid\" data-docs-tutorial=\"mk-what-is-svgrid\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/mk-what-is-svgrid.mp4\" poster=\"/tutorials/mk-what-is-svgrid.poster.webp\" width=\"960\" height=\"540\" controls playsinline preload=\"none\" aria-label=\"What is SvGrid, 129 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/mk-what-is-svgrid.vtt\">Your browser does not play embedded video. <a href=\"/tutorials/mk-what-is-svgrid.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>What is SvGrid</strong> (2:09, with narration). <a href=\"https://www.youtube.com/watch?v=tPL_HyVVCEU\" rel=\"noopener\">Watch on YouTube</a></figcaption>\n</figure>\n<!-- /tutorial:mk-what-is-svgrid -->\n\nThis guide is six short pages. Read them in order if you're new; jump\nstraight to the one you need if you're not.\n\n> In a hurry? `npm create @svgrid@latest` scaffolds a working project in\n> one command - see [Starters & scaffolding](./getting-started/starters.md).\n\n| # | Page | What it covers |\n| --- | ------------------------------------------------------------- | --------------------------------------------------------------------------- |\n| 0 | [Starters & scaffolding](./getting-started/starters.md) | `npm create @svgrid`, the minimal + SvelteKit admin templates, Deploy to Vercel. |\n| 1 | [Install](./getting-started/1-install.md) | `pnpm add @svgrid/grid`, requirements, smoke-test. |\n| 2 | [First grid](./getting-started/2-first-grid.md) | The minimum runnable example, explained line by line. |\n| 3 | [Data and columns](./getting-started/3-data-and-columns.md) | What goes in `data` and `columns`. Custom cells via `renderSnippet`. |\n| 4 | [Features](./getting-started/4-features.md) | Opt into sort, filter, pagination, grouping, selection, editing. |\n| 5 | [Theme and density](./getting-started/5-theme-and-density.md) | `--sg-*` tokens, dark mode, row height, sizing the grid in a flex layout. |\n| 6 | [Going to production](./getting-started/6-going-to-production.md) | Server-side data, virtualization, a11y, SSR, CSP, TypeScript notes. |\n\nEstimated reading time: 15 minutes across all six pages.\n\n## Companion reads\n\n- [Why headless?](./why-headless.md) - the architecture decision behind\n the `createSvGrid` core vs. the `<SvGrid>` render component.\n- [Tailwind integration](./help/tailwind.md) - how the `--sg-*` custom\n properties, Tailwind v4, and dark mode fit together.\n- [Web Components & Custom Elements](./help/web-components.md) - use SvGrid\n as a framework-agnostic `<sv-grid>` element in React, Vue, Angular, or\n plain HTML.\n- [Enterprise features](./enterprise/README.md) - the paid feature pack: data export,\n data import, AI assistant, and pivot tables.\n\n## One-page version\n\nPrefer a single 800-line file? See\n[getting-started-full.md](./getting-started-full.md). The content is\nidentical; the split exists for sidebar nav and faster mobile loads.\n\n## License\n\n`@svgrid/grid` is published under the **MIT License** - permissive\nfor commercial use, redistribution, and modification. The paid\ncompanion `@svgrid/enterprise` ships under a separate commercial license. See\n[LICENSE](../LICENSE) and\n[packages/enterprise/LICENSE](../packages/enterprise/LICENSE).\n\n## Frequently asked questions\n\n### How do I add a data grid to a Svelte 5 app?\n\nInstall `@svgrid/grid`, import `SvGrid`, and pass `data` and `columns`.\nA complete grid is about 15 lines - keyboard navigation and accessibility are\non by default, and you opt into sort/filter/edit/group/paging with one boolean\nshortcut each (`sortable`, `filterable`, ...). See\n[First grid](./getting-started/2-first-grid.md) and\n[Features](./getting-started/4-features.md#capability-shortcuts-the-quick-way).\n\n### Does SvGrid work with SvelteKit?\n\nYes. It renders meaningful HTML before hydration, so it works under SvelteKit\nSSR and static builds. Drive large datasets with controlled, server-side state.\n\n### Is SvGrid free for commercial use?\n\nYes. `@svgrid/grid` is MIT-licensed, including commercial use, with no row\ncap or license key. The optional `@svgrid/enterprise` pack (export, pivot, import, AI)\nships under a separate paid license.\n\n### What do I need to run SvGrid?\n\nSvelte 5 (runes) and any Vite-based toolchain. There are no other required\ndependencies for the Community core; Enterprise export/import features lazy-load their\nown dependencies only when used.\n"
|
|
3916
3933
|
},
|
|
3917
3934
|
{
|
|
3918
3935
|
"slug": "getting-started/1-install",
|
|
3919
3936
|
"path": "docs/getting-started/1-install.md",
|
|
3920
3937
|
"title": "1. Install",
|
|
3921
3938
|
"section": "Getting started",
|
|
3922
|
-
"markdown": "# 1. Install\n\n> Step 1 of 6 · [Next: First grid →](./2-first-grid.md)\n\nSvGrid is a single npm package. There is no peer dependency on a CSS\nframework - bring your own, or import one of the 20 themes that ship\nwith it. Each theme carries a light and a dark palette, so dark mode is\nan attribute, not a second stylesheet.\n\n\n\n## Fastest start: scaffold a project\n\nStarting fresh? Skip the manual wiring and scaffold a project with the\ngrid already set up:\n\n```bash\nnpm create @svgrid@latest # interactive\nnpm create @svgrid@latest my-admin -- --template admin-dashboard\n```\n\nSee [Starters & scaffolding](./starters.md) for the templates (minimal\nVite app or a full SvelteKit admin dashboard) and the Deploy-to-Vercel\nflow. Building with **SvelteKit**? [SvGrid with SvelteKit](./sveltekit.md) is the\nend-to-end path: `sv add`, server loads, URL-driven sort, form actions and SSR.\n\nTo add SvGrid to an **existing** app, install it directly:\n\n```bash\n# pnpm (recommended)\npnpm add @svgrid/grid\n\n# npm\nnpm install @svgrid/grid\n\n# yarn\nyarn add @svgrid/grid\n```\n\n<!-- tutorial:install-svgrid -->\n<figure class=\"docs-tutorial\" id=\"tutorial-install-svgrid\" data-docs-tutorial=\"install-svgrid\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/install-svgrid.mp4\" poster=\"/tutorials/install-svgrid.poster.webp\" width=\"960\" height=\"540\"
|
|
3939
|
+
"markdown": "# 1. Install\n\n> Step 1 of 6 · [Next: First grid →](./2-first-grid.md)\n\nSvGrid is a single npm package. There is no peer dependency on a CSS\nframework - bring your own, or import one of the 20 themes that ship\nwith it. Each theme carries a light and a dark palette, so dark mode is\nan attribute, not a second stylesheet.\n\n\n\n## Fastest start: scaffold a project\n\nStarting fresh? Skip the manual wiring and scaffold a project with the\ngrid already set up:\n\n```bash\nnpm create @svgrid@latest # interactive\nnpm create @svgrid@latest my-admin -- --template admin-dashboard\n```\n\nSee [Starters & scaffolding](./starters.md) for the templates (minimal\nVite app or a full SvelteKit admin dashboard) and the Deploy-to-Vercel\nflow. Building with **SvelteKit**? [SvGrid with SvelteKit](./sveltekit.md) is the\nend-to-end path: `sv add`, server loads, URL-driven sort, form actions and SSR.\n\nTo add SvGrid to an **existing** app, install it directly:\n\n```bash\n# pnpm (recommended)\npnpm add @svgrid/grid\n\n# npm\nnpm install @svgrid/grid\n\n# yarn\nyarn add @svgrid/grid\n```\n\n<!-- tutorial:install-svgrid -->\n<figure class=\"docs-tutorial docs-tutorial-player\" id=\"tutorial-install-svgrid\" data-docs-tutorial=\"install-svgrid\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/install-svgrid.mp4\" poster=\"/tutorials/install-svgrid.poster.webp\" width=\"960\" height=\"540\" controls playsinline preload=\"none\" aria-label=\"Install SvGrid, 45 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/install-svgrid.vtt\">Your browser does not play embedded video. <a href=\"/tutorials/install-svgrid.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Install SvGrid</strong> (45 s, with narration).</figcaption>\n</figure>\n<!-- /tutorial:install-svgrid -->\n\n## Requirements\n\n| Tool | Version | Why |\n| ----------- | ----------------- | ------------------------------------- |\n| Svelte | **5.x** | Uses runes: `$state`, `$derived`, `$effect`. |\n| TypeScript | **5.4+** | Optional but strongly recommended. The column-def types pay for themselves. |\n| Node | **18+** | For tooling (`vite`, `svelte-check`, the example gallery). |\n\nThe bundle is tree-shakeable: features you don't import don't ship.\nThere's no monolithic entry that pulls everything.\n\n## Verify the install\n\nA 5-line smoke test:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { name: string }\n\n const rows: Person[] = [{ name: 'Ada' }, { name: 'Linus' }]\n\n // Annotate the array. Without `GridColumns<Person>` TypeScript widens `field`\n // to `string`, and the grid - which types `field` as a key of your row - then\n // rejects it. This is the one type annotation worth writing every time.\n const columns: GridColumns<Person> = [{ field: 'name', header: 'Name' }]\n</script>\n\n<SvGrid data={rows} {columns} />\n```\n\nIf you see a styled `<table>` with two rows, you're done.\n\n## Pick a theme\n\nThe render component ships its own styles, so the grid is readable the\nmoment it mounts. It is deliberately plain, though: no preset is applied\nuntil you ask for one. Import a theme to change that.\n\n```ts\nimport '@svgrid/grid/themes/shadcn.css'\n```\n\nTwenty are available: `ember` (SvGrid's own look), `shadcn`, `tailwind`,\n`material`, `fluent`, `carbon`, `antd`, `bootstrap`, `atlassian`,\n`salesforce`, `sap`, `github`, `linear`, `notion`, `vercel`, `excel`,\n`nord`, `dracula`, `catppuccin`, `ag-alpine`.\n\nEach one declares the whole `--sg-*` token set twice: once on `:root` and\nonce under `:root[data-theme='dark']`. So dark mode is one attribute on\nthe document, and the grid follows:\n\n```ts\ndocument.documentElement.dataset.theme = 'dark'\n```\n\nTwo things worth knowing before you wire this into an existing app:\n\n- **Set `color-scheme` too.** Without it the browser keeps painting native\n scrollbars, form controls and the page canvas light, so a dark grid sits\n in a light frame. `:root { color-scheme: light }` plus\n `:root[data-theme='dark'] { color-scheme: dark }` is the whole fix.\n- **Apply the attribute before the first paint.** Setting it from a\n component means the page renders in the wrong palette for a frame. An\n inline script in `index.html` (or `app.html` in SvelteKit) that reads\n `localStorage` avoids the flash.\n\nThe scaffolded starters do both already. [Step 5](./5-theme-and-density.md)\ncovers overriding individual tokens, per-instance theming and density.\n\n## Enterprise add-on (optional)\n\nIf you need data export (Excel / PDF / CSV), data import, the AI\nassistant, or built-in pivot tables, install the paid Enterprise pack\nalongside the Community package:\n\n```bash\npnpm add @svgrid/enterprise\n```\n\nSee [Enterprise features](../enterprise/README.md) for what ships and how to license.\n\n## Where the rest of this guide goes\n\n1. **Install** ← you're here\n2. [First grid](./2-first-grid.md) - the minimum runnable example explained\n3. [Data and columns](./3-data-and-columns.md) - the two arrays the grid actually reads\n4. [Features](./4-features.md) - opt into sort, filter, pagination, grouping, etc.\n5. [Theme and density](./5-theme-and-density.md) - `--sg-*` tokens, dark mode, row height\n6. [Going to production](./6-going-to-production.md) - server-side data, virtualization, a11y, SSR\n\nThe combined \"everything in one page\" version is at\n[../getting-started-full.md](../getting-started-full.md) - useful for\nprinting or single-tab reading.\n"
|
|
3923
3940
|
},
|
|
3924
3941
|
{
|
|
3925
3942
|
"slug": "getting-started/2-first-grid",
|
|
3926
3943
|
"path": "docs/getting-started/2-first-grid.md",
|
|
3927
3944
|
"title": "2. First grid in 60 seconds",
|
|
3928
3945
|
"section": "Getting started",
|
|
3929
|
-
"markdown": "# 2. First grid in 60 seconds\n\n> Step 2 of 6 · [← Install](./1-install.md) · [Next: Data and columns →](./3-data-and-columns.md)\n\n\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { firstName: string; age: number; status: string }\n\n const rows: Person[] = [\n { firstName: 'Ada', age: 36, status: 'active' },\n { firstName: 'Linus', age: 54, status: 'active' },\n { firstName: 'Grace', age: 85, status: 'inactive' },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'firstName', header: 'First name' },\n { field: 'age', header: 'Age' },\n { field: 'status', header: 'Status' },\n ]\n</script>\n\n<SvGrid data={rows} columns={columns} />\n```\n\nThat's a complete, working grid.\n\n## What you got out of the box\n\n- A semantic `<table>` with WAI-ARIA `role=\"grid\"` / `role=\"row\"` /\n `role=\"columnheader\"` / `role=\"gridcell\"` on every node.\n- Keyboard navigation: arrow keys move the active cell, Home/End jump\n to row edges, Page Up/Down move by a page, Ctrl+Home / Ctrl+End jump\n to the grid corners.\n- A focus ring on the active cell. Selection on click. F2 / double-click\n to edit (no-op here because no column has `editorType`).\n- Auto-sized columns; explicit `width` on the column def overrides.\n- The default `--sg-*` theme: borders, header background, zebra rows,\n hover, selection - all on tokens you can swap.\n\n## What you didn't get yet\n\nSort, filter, pagination, grouping, expansion, selection are **off**\nuntil you register the matching features. That's the next step.\n\n```svelte\n<!-- after registering rowSortingFeature + columnFilteringFeature -->\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n/>\n```\n\n[Step 3 →](./3-data-and-columns.md) covers how `data` and `columns`\nwork in detail; [step 4 →](./4-features.md) lights up everything else.\n\n## See it run\n\nThe quick-start demo is a slightly fancier version (more columns,\ninline editing, range selection):\n\n<div data-docs-demo=\"01-quick-start\" data-height=\"440\"></div>\n\nSource: [examples/src/demos/01-quick-start.svelte](../../examples/src/demos/01-quick-start.svelte).\n\n## Adding the three common features\n\nEach capability is one boolean. Turn on what the screen needs and nothing\nelse ships - which is why the first grid stays small.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n city: string\n age: number\n salary: number\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', city: 'Portland', age: 54, salary: 155000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={seed} {columns} sortable filterable pageable pageSize={2} />\n```\n"
|
|
3946
|
+
"markdown": "# 2. First grid in 60 seconds\n\n> Step 2 of 6 · [← Install](./1-install.md) · [Next: Data and columns →](./3-data-and-columns.md)\n\n\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { firstName: string; age: number; status: string }\n\n const rows: Person[] = [\n { firstName: 'Ada', age: 36, status: 'active' },\n { firstName: 'Linus', age: 54, status: 'active' },\n { firstName: 'Grace', age: 85, status: 'inactive' },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'firstName', header: 'First name' },\n { field: 'age', header: 'Age' },\n { field: 'status', header: 'Status' },\n ]\n</script>\n\n<SvGrid data={rows} columns={columns} />\n```\n\nThat's a complete, working grid.\n\n<!-- tutorial:learn-1-first-grid -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-1-first-grid\" data-docs-tutorial=\"learn-1-first-grid\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-1-first-grid.mp4\" poster=\"/tutorials/learn-1-first-grid.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 1: install and your first grid, 121 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-1-first-grid.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-1-first-grid.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 1: install and your first grid</strong> (2:01, silent). <a href=\"https://www.youtube.com/watch?v=s7NHVtLQaOs\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Welcome to lesson one. By the end of it you will have a working data grid in a Svelte 5 project, and you will understand every line that put it there.</p>\n<p>SvGrid is a single npm package. There is no CLI step and nothing to copy into your project: you install it and import from it.</p>\n<p>It needs Svelte 5, because the grid is built on runes. If you are starting from nothing, the create command scaffolds a project with the grid already wired up.</p>\n<p>Now the component. Import SvGrid, and import the GridColumns type as well. That type is what makes the column list check itself against your rows.</p>\n<p>Your data is a plain array of plain objects. No store, no wrapper, no adapter. If you already have rows in your app, they are already in the right shape.</p>\n<p>Columns are the other half. Each one maps a field on your row to a header the user reads. Because the array is typed as GridColumns of Person, a typo in a field name is a compile error, not a blank column.</p>\n<p>Then render the component with both. That is the whole API for a basic grid: data in, columns in.</p>\n<p>Save, and the dev server shows this. A real table element, with a header row and your three rows in it.</p>\n<p>You did not ask for any of this, but you got it: click a cell and it takes focus, arrow keys move between cells, Home and End jump to the ends of a row. That is the ARIA grid pattern, on by default.</p>\n<p>What you do not have yet is sorting, filtering or paging. Those are switched on deliberately, so a grid you never configured stays small in your bundle. Lesson two puts your own data in, and lesson three turns those features on.</p>\n<p>The code from this lesson is on the page linked below, and it runs there too. See you in lesson two.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-1-first-grid -->\n\n## What you got out of the box\n\n- A semantic `<table>` with WAI-ARIA `role=\"grid\"` / `role=\"row\"` /\n `role=\"columnheader\"` / `role=\"gridcell\"` on every node.\n- Keyboard navigation: arrow keys move the active cell, Home/End jump\n to row edges, Page Up/Down move by a page, Ctrl+Home / Ctrl+End jump\n to the grid corners.\n- A focus ring on the active cell. Selection on click. F2 / double-click\n to edit (no-op here because no column has `editorType`).\n- Auto-sized columns; explicit `width` on the column def overrides.\n- The default `--sg-*` theme: borders, header background, zebra rows,\n hover, selection - all on tokens you can swap.\n\n## What you didn't get yet\n\nSort, filter, pagination, grouping, expansion, selection are **off**\nuntil you register the matching features. That's the next step.\n\n```svelte\n<!-- after registering rowSortingFeature + columnFilteringFeature -->\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n/>\n```\n\n[Step 3 →](./3-data-and-columns.md) covers how `data` and `columns`\nwork in detail; [step 4 →](./4-features.md) lights up everything else.\n\n## See it run\n\nThe quick-start demo is a slightly fancier version (more columns,\ninline editing, range selection):\n\n<div data-docs-demo=\"01-quick-start\" data-height=\"440\"></div>\n\nSource: [examples/src/demos/01-quick-start.svelte](../../examples/src/demos/01-quick-start.svelte).\n\n## Adding the three common features\n\nEach capability is one boolean. Turn on what the screen needs and nothing\nelse ships - which is why the first grid stays small.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n city: string\n age: number\n salary: number\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', city: 'Portland', age: 54, salary: 155000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={seed} {columns} sortable filterable pageable pageSize={2} />\n```\n"
|
|
3930
3947
|
},
|
|
3931
3948
|
{
|
|
3932
3949
|
"slug": "getting-started/3-data-and-columns",
|
|
3933
3950
|
"path": "docs/getting-started/3-data-and-columns.md",
|
|
3934
3951
|
"title": "3. Data and columns",
|
|
3935
3952
|
"section": "Getting started",
|
|
3936
|
-
"markdown": "# 3. Data and columns\n\n> Step 3 of 6 · [← First grid](./2-first-grid.md) · [Next: Features →](./4-features.md)\n\nSvGrid reads two arrays: `data` (your rows) and `columns` (the\ncolumn-definition list). Everything else is opt-in.\n\n\n\n## Row data\n\nThe `data` prop is any `ReadonlyArray<TRow>`. A Svelte 5 `$state`\narray, a derived store, an SWR/TanStack-Query cache, a `+page.ts` load\nresult, a plain literal - the grid doesn't care, as long as the array\nreference changes when the rows change.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Staff = { id: string; firstName: string; age: number }\n\n // Reactive: pushing into `rows` updates the grid automatically.\n let rows = $state<Staff[]>([\n { id: '1', firstName: 'Ada', age: 36 },\n { id: '2', firstName: 'Linus', age: 54 },\n ])\n\n const staffColumns: GridColumns<Staff> = [\n { field: 'firstName', header: 'First name', width: 180 },\n { field: 'age', header: 'Age', width: 90 },\n ]\n\n function addRow() {\n rows.push({ id: crypto.randomUUID(), firstName: 'New', age: 0 })\n }\n</script>\n\n<button onclick={addRow}>Add row</button>\n<SvGrid data={rows} columns={staffColumns} />\n```\n\n### Identity\n\nToday the wrapper uses each row's array index as its id. That's fine\nfor read-only views; if you mutate `rows`, prefer keeping the same\nobject references for rows that didn't change so selection and edit\nstate line up. A `getRowId` prop on the wrapper is tracked in\n[Missing features](../help/missing-features.md) and supported by the\nheadless `createSvGrid` core today.\n\n### Immutability\n\nSvGrid does **not** mutate your data. When the user commits an edit the\ngrid emits an `onCellValueChange` event; you decide whether to mutate\nin place or copy. See [Saving values](../help/editing/saving-values.md).\n\n## Column definitions\n\nA column definition tells SvGrid how to read a value out of a row, how\nto render it, and how to format it.\n\n```ts\nimport type { ColumnDef } from '@svgrid/grid'\n\ntype Person = {\n id: string\n firstName: string\n lastName: string\n age: number\n joinedAt: string // ISO date\n salary: number\n active: boolean\n}\n\nconst columns: GridColumns<Person> = [\n // `field`: read a key straight off the row\n { field: 'firstName', header: 'First name' },\n\n // `fieldFn`: compute the value; needs an explicit `id`\n {\n id: 'fullName',\n header: 'Full name',\n fieldFn: (row) => `${row.firstName} ${row.lastName}`,\n },\n\n // Numeric, with locale-aware formatting\n {\n field: 'age',\n header: 'Age',\n format: { type: 'number', options: { maximumFractionDigits: 0 } },\n },\n\n // Date with explicit pattern\n {\n field: 'joinedAt',\n header: 'Joined',\n format: { type: 'date', pattern: 'y-m-d' },\n },\n\n // Currency\n {\n field: 'salary',\n header: 'Salary',\n format: { type: 'currency', currency: 'USD' },\n },\n\n // Boolean rendered as a checkbox\n {\n field: 'active',\n header: 'Active',\n editorType: 'checkbox',\n },\n]\n```\n\n### Common properties\n\n| Property | Purpose |\n| ------------- | -------------------------------------------------------------------------------------------------------- |\n| `field` | Reads `row[key]`. |\n| `fieldFn` | Computes the value from the row. Required when there's no underlying field. |\n| `id` | Stable column id. Required when you use `fieldFn` (no field to derive from). |\n| `header` | String or render snippet for the header. |\n| `footer` | String or render snippet for the footer row. |\n| `cell` | Render snippet / component for the body cell. See [Cell components](../help/cells/cell-components.md). |\n| `format` | Locale-aware formatter: `number`, `currency`, `percent`, `date`, `datetime`. |\n| `formatter` | Function form for one-off custom value formatting. |\n| `editorType` | Inline editor: `text` \\| `number` \\| `checkbox` \\| `date` \\| `datetime`. |\n| `width` | Initial column width in pixels. Default comes from the `columnWidth` prop on `<SvGrid>`. |\n| `align` | Header + body alignment: `'left'` \\| `'right'` \\| `'center'`. Inferred from `editorType` when omitted. |\n| `columns` | Child column defs for column groups (multi-level header). |\n\nSorting / filtering / grouping are toggled per-grid via the registered\nfeatures. Per-column flags (`enableSorting: false`, etc.) are on the\n[Missing features](../help/missing-features.md) list.\n\n### Custom cells\n\nFor anything beyond a stringified value, render with `renderSnippet`:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, renderSnippet, type GridColumns } from '@svgrid/grid'\n\n type Named = { firstName: string; lastName: string; age: number }\n\n const named: Named[] = [\n { firstName: 'Ada', lastName: 'Lovelace', age: 36 },\n { firstName: 'Grace', lastName: 'Hopper', age: 45 },\n { firstName: 'Linus', lastName: 'Torvalds', age: 54 },\n ]\n</script>\n\n{#snippet PersonCell(props: { row: Named })}\n <span class=\"inline-flex items-center gap-2\">\n <span class=\"initials\">{props.row.firstName[0]}{props.row.lastName[0]}</span>\n <span>{props.row.firstName} {props.row.lastName}</span>\n </span>\n{/snippet}\n\n<SvGrid\n data={named}\n columns={[\n {\n id: 'person',\n header: 'Person',\n fieldFn: (r) => `${r.firstName} ${r.lastName}`,\n cell: (ctx) => renderSnippet(PersonCell, { row: ctx.row.original }),\n },\n { field: 'age', header: 'Age' },\n ] satisfies GridColumns<Named>}\n/>\n```\n\n[Cell components](../help/cells/cell-components.md) has the full\npatterns: avatars, sparklines, progress bars, status badges.\n\n## Formats on the columns you already have\n\n`format` is declarative and locale-aware, so a currency, a percentage and a\ndate all come from the same place rather than from three helper functions.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n city: string\n age: number\n salary: number\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', city: 'Portland', age: 54, salary: 155000 },\n ]\n\n type Row = { product: string; price: number; margin: number; shipped: string }\n\n const rows: Row[] = [\n { product: 'Cycling cap', price: 29, margin: 0.42, shipped: '2026-05-02' },\n { product: 'Patch kit', price: 12.8, margin: 0.31, shipped: '2026-05-11' },\n { product: 'Road bottle', price: 8.5, margin: 0.55, shipped: '2026-05-19' },\n ]\n\n const columns: GridColumns<Row> = [\n { field: 'product', header: 'Product', width: 170 },\n { field: 'price', header: 'Price', width: 120,\n format: { type: 'currency', currency: 'USD' } },\n { field: 'margin', header: 'Margin', width: 110,\n format: { type: 'percent' } },\n { field: 'shipped', header: 'Shipped', width: 150, cellDataType: 'dateString',\n format: { type: 'date', options: { dateStyle: 'medium' } } },\n ]\n</script>\n\n<SvGrid data={rows} {columns} sortable />\n```\n"
|
|
3953
|
+
"markdown": "# 3. Data and columns\n\n> Step 3 of 6 · [← First grid](./2-first-grid.md) · [Next: Features →](./4-features.md)\n\nSvGrid reads two arrays: `data` (your rows) and `columns` (the\ncolumn-definition list). Everything else is opt-in.\n\n\n\n## Row data\n\nThe `data` prop is any `ReadonlyArray<TRow>`. A Svelte 5 `$state`\narray, a derived store, an SWR/TanStack-Query cache, a `+page.ts` load\nresult, a plain literal - the grid doesn't care, as long as the array\nreference changes when the rows change.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Staff = { id: string; firstName: string; age: number }\n\n // Reactive: pushing into `rows` updates the grid automatically.\n let rows = $state<Staff[]>([\n { id: '1', firstName: 'Ada', age: 36 },\n { id: '2', firstName: 'Linus', age: 54 },\n ])\n\n const staffColumns: GridColumns<Staff> = [\n { field: 'firstName', header: 'First name', width: 180 },\n { field: 'age', header: 'Age', width: 90 },\n ]\n\n function addRow() {\n rows.push({ id: crypto.randomUUID(), firstName: 'New', age: 0 })\n }\n</script>\n\n<button onclick={addRow}>Add row</button>\n<SvGrid data={rows} columns={staffColumns} />\n```\n\n### Identity\n\nToday the wrapper uses each row's array index as its id. That's fine\nfor read-only views; if you mutate `rows`, prefer keeping the same\nobject references for rows that didn't change so selection and edit\nstate line up. A `getRowId` prop on the wrapper is tracked in\n[Missing features](../help/missing-features.md) and supported by the\nheadless `createSvGrid` core today.\n\n### Immutability\n\nSvGrid does **not** mutate your data. When the user commits an edit the\ngrid emits an `onCellValueChange` event; you decide whether to mutate\nin place or copy. See [Saving values](../help/editing/saving-values.md).\n\n## Column definitions\n\nA column definition tells SvGrid how to read a value out of a row, how\nto render it, and how to format it.\n\n```ts\nimport type { ColumnDef } from '@svgrid/grid'\n\ntype Person = {\n id: string\n firstName: string\n lastName: string\n age: number\n joinedAt: string // ISO date\n salary: number\n active: boolean\n}\n\nconst columns: GridColumns<Person> = [\n // `field`: read a key straight off the row\n { field: 'firstName', header: 'First name' },\n\n // `fieldFn`: compute the value; needs an explicit `id`\n {\n id: 'fullName',\n header: 'Full name',\n fieldFn: (row) => `${row.firstName} ${row.lastName}`,\n },\n\n // Numeric, with locale-aware formatting\n {\n field: 'age',\n header: 'Age',\n format: { type: 'number', options: { maximumFractionDigits: 0 } },\n },\n\n // Date with explicit pattern\n {\n field: 'joinedAt',\n header: 'Joined',\n format: { type: 'date', pattern: 'y-m-d' },\n },\n\n // Currency\n {\n field: 'salary',\n header: 'Salary',\n format: { type: 'currency', currency: 'USD' },\n },\n\n // Boolean rendered as a checkbox\n {\n field: 'active',\n header: 'Active',\n editorType: 'checkbox',\n },\n]\n```\n\n### Common properties\n\n| Property | Purpose |\n| ------------- | -------------------------------------------------------------------------------------------------------- |\n| `field` | Reads `row[key]`. |\n| `fieldFn` | Computes the value from the row. Required when there's no underlying field. |\n| `id` | Stable column id. Required when you use `fieldFn` (no field to derive from). |\n| `header` | String or render snippet for the header. |\n| `footer` | String or render snippet for the footer row. |\n| `cell` | Render snippet / component for the body cell. See [Cell components](../help/cells/cell-components.md). |\n| `format` | Locale-aware formatter: `number`, `currency`, `percent`, `date`, `datetime`. |\n| `formatter` | Function form for one-off custom value formatting. |\n| `editorType` | Inline editor: `text` \\| `number` \\| `checkbox` \\| `date` \\| `datetime`. |\n| `width` | Initial column width in pixels. Default comes from the `columnWidth` prop on `<SvGrid>`. |\n| `align` | Header + body alignment: `'left'` \\| `'right'` \\| `'center'`. Inferred from `editorType` when omitted. |\n| `columns` | Child column defs for column groups (multi-level header). |\n\nSorting / filtering / grouping are toggled per-grid via the registered\nfeatures. Per-column flags (`enableSorting: false`, etc.) are on the\n[Missing features](../help/missing-features.md) list.\n\n### Custom cells\n\nFor anything beyond a stringified value, render with `renderSnippet`:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, renderSnippet, type GridColumns } from '@svgrid/grid'\n\n type Named = { firstName: string; lastName: string; age: number }\n\n const named: Named[] = [\n { firstName: 'Ada', lastName: 'Lovelace', age: 36 },\n { firstName: 'Grace', lastName: 'Hopper', age: 45 },\n { firstName: 'Linus', lastName: 'Torvalds', age: 54 },\n ]\n</script>\n\n{#snippet PersonCell(props: { row: Named })}\n <span class=\"inline-flex items-center gap-2\">\n <span class=\"initials\">{props.row.firstName[0]}{props.row.lastName[0]}</span>\n <span>{props.row.firstName} {props.row.lastName}</span>\n </span>\n{/snippet}\n\n<SvGrid\n data={named}\n columns={[\n {\n id: 'person',\n header: 'Person',\n fieldFn: (r) => `${r.firstName} ${r.lastName}`,\n cell: (ctx) => renderSnippet(PersonCell, { row: ctx.row.original }),\n },\n { field: 'age', header: 'Age' },\n ] satisfies GridColumns<Named>}\n/>\n```\n\n[Cell components](../help/cells/cell-components.md) has the full\npatterns: avatars, sparklines, progress bars, status badges.\n\n## Formats on the columns you already have\n\n`format` is declarative and locale-aware, so a currency, a percentage and a\ndate all come from the same place rather than from three helper functions.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n city: string\n age: number\n salary: number\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', city: 'Portland', age: 54, salary: 155000 },\n ]\n\n type Row = { product: string; price: number; margin: number; shipped: string }\n\n const rows: Row[] = [\n { product: 'Cycling cap', price: 29, margin: 0.42, shipped: '2026-05-02' },\n { product: 'Patch kit', price: 12.8, margin: 0.31, shipped: '2026-05-11' },\n { product: 'Road bottle', price: 8.5, margin: 0.55, shipped: '2026-05-19' },\n ]\n\n const columns: GridColumns<Row> = [\n { field: 'product', header: 'Product', width: 170 },\n { field: 'price', header: 'Price', width: 120,\n format: { type: 'currency', currency: 'USD' } },\n { field: 'margin', header: 'Margin', width: 110,\n format: { type: 'percent' } },\n { field: 'shipped', header: 'Shipped', width: 150, cellDataType: 'dateString',\n format: { type: 'date', options: { dateStyle: 'medium' } } },\n ]\n</script>\n\n<SvGrid data={rows} {columns} sortable />\n```\n\n<!-- tutorial:learn-2-data-and-columns -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-2-data-and-columns\" data-docs-tutorial=\"learn-2-data-and-columns\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-2-data-and-columns.mp4\" poster=\"/tutorials/learn-2-data-and-columns.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 2: your data, your columns, 88 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-2-data-and-columns.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-2-data-and-columns.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 2: your data, your columns</strong> (1:28, silent). <a href=\"https://www.youtube.com/watch?v=HvQ4PDuY8Q4\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>In lesson one the rows were made up. In this one they are yours, they stay reactive, and the columns start looking like a real application.</p>\n<p>Start from your own row type. The grid never asks you to convert anything: whatever shape your records have, that is the shape it takes. Declare the array with the state rune and it stays reactive.</p>\n<p>Columns map a field to a header, and width fixes one that would otherwise stretch. Then a plain function that pushes a record onto the array.</p>\n<p>Watch what happens when that button runs. Pushing onto the array is all it does, and the grid follows, because the rows are a rune and not a copy the grid took at startup.</p>\n<p>Now formatting. This is the same page of the docs, a few blocks further down, and it is the thing people most often do by hand and regret.</p>\n<p>A currency format, a percent, a date. Each one renders the value for the reader and leaves your data alone, which matters because sorting and exporting both use the raw number underneath, not the string on screen.</p>\n<p>One habit worth forming: type the column array as GridColumns of your row type. The compiler then checks every field name against the row, so a typo is a build error instead of an empty column nobody notices.</p>\n<p>Both files are on the page below, and they run there too. See you in lesson three.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-2-data-and-columns -->\n"
|
|
3937
3954
|
},
|
|
3938
3955
|
{
|
|
3939
3956
|
"slug": "getting-started/4-features",
|
|
3940
3957
|
"path": "docs/getting-started/4-features.md",
|
|
3941
3958
|
"title": "4. Features",
|
|
3942
3959
|
"section": "Getting started",
|
|
3943
|
-
"markdown": "# 4. Features\n\n> Step 4 of 6 · [← Data and columns](./3-data-and-columns.md) · [Next: Theme and density →](./5-theme-and-density.md)\n\nEvery capability is **off by default** - a bare `<SvGrid>` is a plain,\nread-only table. Opt in with boolean props. No imports, no feature\nconstants:\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, tableFeatures, rowSortingFeature, columnFilteringFeature } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n department: string\n age: number\n salary: number\n city: string\n startDate: string\n active: boolean\n }\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering', age: 36, salary: 142000, city: 'London', startDate: '2021-03-01', active: true },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', department: 'Engineering', age: 45, salary: 168000, city: 'New York', startDate: '2019-07-15', active: true },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', department: 'Platform', age: 54, salary: 155000, city: 'Portland', startDate: '2020-01-20', active: false },\n { id: 4, name: 'Radia Perlman', email: 'radia@example.com', department: 'Networking', age: 49, salary: 161000, city: 'Seattle', startDate: '2022-09-05', active: true },\n { id: 5, name: 'Barbara Liskov', email: 'barbara@example.com', department: 'Platform', age: 52, salary: 172000, city: 'Boston', startDate: '2018-11-11', active: true },\n ]\n\n const data = people\n\n let rows = $state<Person[]>(people)\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<SvGrid\n data={rows}\n columns={columns}\n sortable\n filterable\n editable\n groupable\n pageable\n/>\n```\n\n`selectable` completes the set - see\n[the exception](#the-exception-selection-is-already-on) below, since selection\nis already on:\n\n```svelte {runnable}\n<SvGrid data={rows} columns={columns} selectable={false} />\n```\n\n| Shortcut | Turns on | Equivalent to |\n| ------------ | ------------------------------------------------ | ------------------------------------------ |\n| `sortable` | Click headers to sort | injects `rowSortingFeature` |\n| `filterable` | Per-column filter menu | injects `columnFilteringFeature` |\n| `editable` | Inline cell editing (text editor unless `editorType`) | `enableInlineEditing` |\n| `selectable` | Cell selection (click a cell, drag for a range) | `enableCellSelection` |\n| `groupable` | \"Group by this column\" in the column menu | `showGroupingControls` |\n| `pageable` | Pagination footer | `showPagination` |\n\nEach shortcut is an override: omit it (or set `false`) to leave the\ncapability off; set it `true` to opt in. See the live\n[Shortcut config](https://svgrid.com/demos/135-shortcut-config/) demo.\n\n### The exception: selection is already on\n\n`selectable` is the one shortcut that starts **on**. Selection is governed by\n`selectionMode`, which defaults to `'both'`, so a bare grid already has both\nsurfaces:\n\n| Surface | Controlled by | Default |\n| ------------------------ | ----------------------------------- | ------- |\n| Cell / range selection | `selectable` -> `enableCellSelection` | **on** |\n| Selection checkbox column | `showRowSelection` | **on** |\n\nNeither needs a feature import. `selectable` is listed with the shortcuts so\nthe capability has a name next to the others, and so `selectable={false}` is a\none-word way to switch cell selection off. To move both surfaces at once, use\n`selectionMode=\"none\"` (or `\"row\"` / `\"cell\"`).\n\n**For most grids this is the whole story** - skip to\n[Theme and density](./5-theme-and-density.md). The rest of this page is the\nexplicit form underneath, worth reading when you want finer control.\n\n## The explicit form: registering features\n\nThe engine is feature-gated: out of the box you get the core row model (rows\nin their original order), and each capability is a feature you register. The\nshortcut props above just inject these for you.\n\n\n\nRegister them yourself when you need the fine-grained props alongside -\n`filterMode`, `pageSize`, per-column `sortable: false`:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n type GridColumns,\n } from '@svgrid/grid'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n showPagination={true}\n pageSize={25}\n/>\n```\n\nIf you need the headless pipeline directly - say, a custom renderer - drop\ndown to `createSvGrid` from the same package. See\n[Why headless?](../why-headless.md).\n\n## The feature catalogue\n\n| Feature | What it enables | Doc |\n| ------------------------ | --------------------------------------------------------- | -------------------------------------------------- |\n| `rowSortingFeature` | Click headers to sort; Shift-click for multi-sort. | [Row sorting](../help/rows/row-sorting.md) |\n| `columnFilteringFeature` | Per-column filter menu + filter row + global search. | [Filter overview](../help/filtering/overview.md) |\n| `rowPaginationFeature` | Page slicing + footer with page-size selector. | [Row pagination](../help/rows/row-pagination.md) |\n| `rowSelectionFeature` | Checkbox column + Shift / Ctrl multi-select. | [Row selection](../help/rows/styling-rows.md) |\n| `columnGroupingFeature` | Group-by-column + aggregated footer summaries. | [Grouping & aggregation](../help/grouping-aggregation.md) |\n| `rowExpandingFeature` | Tree / master-detail expand-collapse. | [Tree rows](../help/rows/tree-rows.md) |\n\n**Rule of thumb:** only register the features you use. Each one ships\nabout 1-2 KB gzipped and adds a small per-update cost.\n\n## The three operating modes\n\nThere's one decision per dimension (sort, filter): **uncontrolled**\n(default), **observable** (callbacks), or **external** (you own the row\nordering).\n\n### Uncontrolled (the default)\n\nThe wrapper owns the state. Pass the start config via props:\n\n```svelte {runnable}\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n showPagination={true}\n pageSize={50}\n filterMode=\"menu\"\n/>\n```\n\n### Observable - callbacks fire on every change\n\nThe wrapper still owns the state, but emits callbacks. Use this when\nsomething outside the grid needs to react (a \"X rows selected\" pill,\nURL sync, a server fetch).\n\n```svelte {runnable}\n<script lang=\"ts\">\n let sorting = $state<Array<{ id: string; desc: boolean }>>([])\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n onSortingChange={(next) => (sorting = next)}\n onFiltersChange={(next) => (filters = next.columns)}\n/>\n```\n\n### External - you own the row ordering\n\nFor server-side data or tree data the wrapper records the sort + filter\nUI state but does **not** re-order the rows - you do. See\n[Going to production §1](./6-going-to-production.md#1-server-side-data).\n\n## Selection + editing\n\nBoth are off by default. Two top-level umbrella props turn them on:\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n selectionMode=\"both\"\n showRowSelection={true}\n enableInlineEditing={true}\n enableCellSelection={true}\n onRowSelectionChange={(state, selectedRows) => /* … */}\n onCellValueChange={(event) => /* event: { rowIndex, columnId, oldValue, newValue, row } */}\n/>\n```\n\n`selectionMode` choices:\n\n- `'row'` - checkbox column only\n- `'cell'` - click-and-drag range selection only\n- `'both'` - both (default)\n- `'none'` - both off\n\nCell editing falls back to a text editor, so `editable` works on plain\ncolumns; declare `editorType` on a column to get the right control for its\ntype (`number`, `date`, `checkbox`, `list`, ...).\nSee [Editing overview](../help/editing/overview.md).\n\n## Keyboard map\n\n| Action | Keys |\n| --------------------- | ------------------------------------- |\n| Move active cell | Arrow keys |\n| First / last in row | Home / End |\n| First / last in grid | Ctrl + Home / Ctrl + End |\n| Move by viewport | Page Up / Page Down |\n| Extend range | Shift + arrows / Shift + Home / End |\n| Start editing | Enter, F2, or double-click |\n| Commit edit | Enter, Tab |\n| Cancel edit | Esc |\n| Toggle row selection | Space |\n| Copy / paste range | Ctrl/Cmd + C / V (TSV) |\n\nThe full a11y model is in [Accessibility](../help/accessibility.md).\n"
|
|
3960
|
+
"markdown": "# 4. Features\n\n> Step 4 of 6 · [← Data and columns](./3-data-and-columns.md) · [Next: Theme and density →](./5-theme-and-density.md)\n\nEvery capability is **off by default** - a bare `<SvGrid>` is a plain,\nread-only table. Opt in with boolean props. No imports, no feature\nconstants:\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, tableFeatures, rowSortingFeature, columnFilteringFeature } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n department: string\n age: number\n salary: number\n city: string\n startDate: string\n active: boolean\n }\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering', age: 36, salary: 142000, city: 'London', startDate: '2021-03-01', active: true },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', department: 'Engineering', age: 45, salary: 168000, city: 'New York', startDate: '2019-07-15', active: true },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', department: 'Platform', age: 54, salary: 155000, city: 'Portland', startDate: '2020-01-20', active: false },\n { id: 4, name: 'Radia Perlman', email: 'radia@example.com', department: 'Networking', age: 49, salary: 161000, city: 'Seattle', startDate: '2022-09-05', active: true },\n { id: 5, name: 'Barbara Liskov', email: 'barbara@example.com', department: 'Platform', age: 52, salary: 172000, city: 'Boston', startDate: '2018-11-11', active: true },\n ]\n\n const data = people\n\n let rows = $state<Person[]>(people)\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<SvGrid\n data={rows}\n columns={columns}\n sortable\n filterable\n editable\n groupable\n pageable\n/>\n```\n\n`selectable` completes the set - see\n[the exception](#the-exception-selection-is-already-on) below, since selection\nis already on:\n\n```svelte {runnable}\n<SvGrid data={rows} columns={columns} selectable={false} />\n```\n\n| Shortcut | Turns on | Equivalent to |\n| ------------ | ------------------------------------------------ | ------------------------------------------ |\n| `sortable` | Click headers to sort | injects `rowSortingFeature` |\n| `filterable` | Per-column filter menu | injects `columnFilteringFeature` |\n| `editable` | Inline cell editing (text editor unless `editorType`) | `enableInlineEditing` |\n| `selectable` | Cell selection (click a cell, drag for a range) | `enableCellSelection` |\n| `groupable` | \"Group by this column\" in the column menu | `showGroupingControls` |\n| `pageable` | Pagination footer | `showPagination` |\n\nEach shortcut is an override: omit it (or set `false`) to leave the\ncapability off; set it `true` to opt in. See the live\n[Shortcut config](https://svgrid.com/demos/135-shortcut-config/) demo.\n\n### The exception: selection is already on\n\n`selectable` is the one shortcut that starts **on**. Selection is governed by\n`selectionMode`, which defaults to `'both'`, so a bare grid already has both\nsurfaces:\n\n| Surface | Controlled by | Default |\n| ------------------------ | ----------------------------------- | ------- |\n| Cell / range selection | `selectable` -> `enableCellSelection` | **on** |\n| Selection checkbox column | `showRowSelection` | **on** |\n\nNeither needs a feature import. `selectable` is listed with the shortcuts so\nthe capability has a name next to the others, and so `selectable={false}` is a\none-word way to switch cell selection off. To move both surfaces at once, use\n`selectionMode=\"none\"` (or `\"row\"` / `\"cell\"`).\n\n**For most grids this is the whole story** - skip to\n[Theme and density](./5-theme-and-density.md). The rest of this page is the\nexplicit form underneath, worth reading when you want finer control.\n\n## The explicit form: registering features\n\nThe engine is feature-gated: out of the box you get the core row model (rows\nin their original order), and each capability is a feature you register. The\nshortcut props above just inject these for you.\n\n\n\nRegister them yourself when you need the fine-grained props alongside -\n`filterMode`, `pageSize`, per-column `sortable: false`:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n type GridColumns,\n } from '@svgrid/grid'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n showPagination={true}\n pageSize={25}\n/>\n```\n\nIf you need the headless pipeline directly - say, a custom renderer - drop\ndown to `createSvGrid` from the same package. See\n[Why headless?](../why-headless.md).\n\n## The feature catalogue\n\n| Feature | What it enables | Doc |\n| ------------------------ | --------------------------------------------------------- | -------------------------------------------------- |\n| `rowSortingFeature` | Click headers to sort; Shift-click for multi-sort. | [Row sorting](../help/rows/row-sorting.md) |\n| `columnFilteringFeature` | Per-column filter menu + filter row + global search. | [Filter overview](../help/filtering/overview.md) |\n| `rowPaginationFeature` | Page slicing + footer with page-size selector. | [Row pagination](../help/rows/row-pagination.md) |\n| `rowSelectionFeature` | Checkbox column + Shift / Ctrl multi-select. | [Row selection](../help/rows/styling-rows.md) |\n| `columnGroupingFeature` | Group-by-column + aggregated footer summaries. | [Grouping & aggregation](../help/grouping-aggregation.md) |\n| `rowExpandingFeature` | Tree / master-detail expand-collapse. | [Tree rows](../help/rows/tree-rows.md) |\n\n**Rule of thumb:** only register the features you use. Each one ships\nabout 1-2 KB gzipped and adds a small per-update cost.\n\n## The three operating modes\n\nThere's one decision per dimension (sort, filter): **uncontrolled**\n(default), **observable** (callbacks), or **external** (you own the row\nordering).\n\n### Uncontrolled (the default)\n\nThe wrapper owns the state. Pass the start config via props:\n\n```svelte {runnable}\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n showPagination={true}\n pageSize={50}\n filterMode=\"menu\"\n/>\n```\n\n### Observable - callbacks fire on every change\n\nThe wrapper still owns the state, but emits callbacks. Use this when\nsomething outside the grid needs to react (a \"X rows selected\" pill,\nURL sync, a server fetch).\n\n```svelte {runnable}\n<script lang=\"ts\">\n let sorting = $state<Array<{ id: string; desc: boolean }>>([])\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n onSortingChange={(next) => (sorting = next)}\n onFiltersChange={(next) => (filters = next.columns)}\n/>\n```\n\n### External - you own the row ordering\n\nFor server-side data or tree data the wrapper records the sort + filter\nUI state but does **not** re-order the rows - you do. See\n[Going to production §1](./6-going-to-production.md#1-server-side-data).\n\n## Selection + editing\n\nBoth are off by default. Two top-level umbrella props turn them on:\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n selectionMode=\"both\"\n showRowSelection={true}\n enableInlineEditing={true}\n enableCellSelection={true}\n onRowSelectionChange={(state, selectedRows) => /* … */}\n onCellValueChange={(event) => /* event: { rowIndex, columnId, oldValue, newValue, row } */}\n/>\n```\n\n`selectionMode` choices:\n\n- `'row'` - checkbox column only\n- `'cell'` - click-and-drag range selection only\n- `'both'` - both (default)\n- `'none'` - both off\n\nCell editing falls back to a text editor, so `editable` works on plain\ncolumns; declare `editorType` on a column to get the right control for its\ntype (`number`, `date`, `checkbox`, `list`, ...).\nSee [Editing overview](../help/editing/overview.md).\n\n## Keyboard map\n\n| Action | Keys |\n| --------------------- | ------------------------------------- |\n| Move active cell | Arrow keys |\n| First / last in row | Home / End |\n| First / last in grid | Ctrl + Home / Ctrl + End |\n| Move by viewport | Page Up / Page Down |\n| Extend range | Shift + arrows / Shift + Home / End |\n| Start editing | Enter, F2, or double-click |\n| Commit edit | Enter, Tab |\n| Cancel edit | Esc |\n| Toggle row selection | Space |\n| Copy / paste range | Ctrl/Cmd + C / V (TSV) |\n\nThe full a11y model is in [Accessibility](../help/accessibility.md).\n\n<!-- tutorial:learn-3-sort-filter-page -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-3-sort-filter-page\" data-docs-tutorial=\"learn-3-sort-filter-page\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-3-sort-filter-page.mp4\" poster=\"/tutorials/learn-3-sort-filter-page.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 3: sorting, filtering and paging, 143 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-3-sort-filter-page.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-3-sort-filter-page.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 3: sorting, filtering and paging</strong> (2:23, silent). <a href=\"https://www.youtube.com/watch?v=iBInYQ1kmDg\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Your grid renders. It does not sort, filter or page yet, and that is deliberate. This lesson turns those on and explains the trade the library is making for you.</p>\n<p>Here is a wider row type and a realistic set of records, the kind of table you would actually ship. Nothing new yet.</p>\n<p>Columns, with a currency format on salary from the last lesson.</p>\n<p>And now the part that matters. Sortable, filterable, editable, groupable, pageable. Each is one boolean, and each one pulls in only the code that feature needs, which is why a grid you never configured stays small.</p>\n<p>That is the whole file: the row type, the records, the columns, and the component with those five words on it. Nothing else is configured, and nothing is hidden above the fold.</p>\n<p>Click a header and it sorts. Click again for descending, and a third time to clear it and get your original order back.</p>\n<p>Shift-click a second header to sort by two columns at once: department first, then name inside each department.</p>\n<p>The funnel next to each header is filtering, switched on by that same one word. How it looks is a choice, and the next screen has the version most people want.</p>\n<p>Five rows will not show you paging, so here is the same set of props over five thousand. Filterable on its own gives you a menu per header; filter mode row puts an input under each header instead, and it narrows the table as you type.</p>\n<p>And the pager cuts what is left into pages, with the range and the count kept honest as the filter changes them. None of this needed a callback from you.</p>\n<p>Those five booleans are a shorthand. When you need finer control, register the features explicitly with tableFeatures and pass them in, which is the same machinery with the wiring exposed; the docs page below shows both forms side by side. Lesson four lets people change the table.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-3-sort-filter-page -->\n"
|
|
3944
3961
|
},
|
|
3945
3962
|
{
|
|
3946
3963
|
"slug": "getting-started/5-theme-and-density",
|
|
3947
3964
|
"path": "docs/getting-started/5-theme-and-density.md",
|
|
3948
3965
|
"title": "5. Theme and density",
|
|
3949
3966
|
"section": "Getting started",
|
|
3950
|
-
"markdown": "# 5. Theme and density\n\n> Step 5 of 6 · [← Features](./4-features.md) · [Next: Going to production →](./6-going-to-production.md)\n\n## Start with a preset\n\nBefore hand-writing any tokens: 20 design-system presets ship with the package,\neach a single stylesheet with a full light + dark palette. One import re-themes\nthe whole grid.\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```ts\nimport '@svgrid/grid/themes/shadcn.css'\n```\n\nAvailable: `ember` (SvGrid's own look), `shadcn`, `tailwind`, `material`,\n`fluent`, `carbon`, `antd`, `bootstrap`, `atlassian`, `salesforce`, `sap`,\n`github`, `linear`, `notion`, `vercel`, `excel`, `nord`, `dracula`,\n`catppuccin`, `ag-alpine`.\n\nEach preset defines every token the grid and the UI components read - including\nthe semantic status colors (`--sg-danger`, `--sg-success`, `--sg-warning`,\n`--sg-info`) and the focus ring, which follows the preset's accent. Presets\nflip with `data-theme=\"dark\"` automatically (see below).\n\nOverride individual tokens after the import to adjust a preset, or skip presets\nentirely and declare the tokens yourself - that is the rest of this page.\n\n## Declaring tokens yourself\n\nThe render component (`<SvGrid>`) ships its own scoped styles. You\nre-theme it by declaring `--sg-*` CSS custom properties at any level\nabove the grid - `:root` for the whole app, a wrapper `<div>` for one\ninstance, or directly on the `<SvGrid>` element itself.\n\n\n\n## Token surface\n\nThe 20-odd tokens the renderer reads:\n\n| Token | What it paints |\n| -------------------------------- | ------------------------------------------- |\n| `--sg-bg` | Cell background |\n| `--sg-fg` | Cell text |\n| `--sg-muted` | Secondary text (footers, subtitles) |\n| `--sg-border` | Cell + header borders |\n| `--sg-header-bg` / `--sg-header-fg` | Header row |\n| `--sg-row-alt-bg` | Zebra rows |\n| `--sg-row-hover-bg` | Row + cell hover |\n| `--sg-selection-bg` | Selected cell / row tint |\n| `--sg-accent` | Sort indicator, focus ring, primary buttons |\n| `--sg-focus-ring` | Keyboard focus outline |\n| `--sg-input-bg` / `--sg-input-border` | Inline editor + filter inputs |\n| `--sg-pill-active` / `-fg` | \"Active\" status pills |\n| `--sg-pill-pending` / `-fg` | \"Pending\" status pills |\n| `--sg-pill-inactive` / `-fg` | \"Inactive\" status pills |\n| `--sg-scrollbar-*` (10 tokens) | Custom-painted scrollbars |\n\n## Light + dark via `data-theme`\n\nThe gallery flips themes by writing `dark` or `light` to\n`html[data-theme]`. Every token redeclares under that selector:\n\n```css\n:root {\n --sg-bg: #ffffff;\n --sg-fg: #0f172a;\n --sg-border: #e2e8f0;\n --sg-header-bg: #f1f5f9;\n --sg-row-alt-bg: #f8fafc;\n --sg-row-hover-bg: #eef2ff;\n --sg-accent: #2563eb;\n}\n\nhtml[data-theme='dark'] {\n --sg-bg: #0f172a;\n --sg-fg: #f1f5f9;\n --sg-border: #334155;\n --sg-header-bg: #1e2433;\n --sg-row-alt-bg: #1b2230;\n --sg-row-hover-bg: #232b3c;\n --sg-accent: #3b82f6;\n color-scheme: dark;\n}\n```\n\nToggling is one line in the app shell:\n\n```svelte {runnable}\n<script lang=\"ts\">\n let theme = $state<'light' | 'dark'>('dark')\n $effect(() => document.documentElement.setAttribute('data-theme', theme))\n</script>\n\n<button onclick={() => (theme = theme === 'dark' ? 'light' : 'dark')}>\n Toggle theme\n</button>\n```\n\n## Per-instance theming\n\nBecause the tokens are plain custom properties they cascade. To style a\nsingle grid, wrap it in a `<div>` that sets its own values:\n\n```svelte\n<div style=\"--sg-bg: #fff8f0; --sg-accent: #db2777;\">\n <SvGrid {data} {columns} features={features} />\n</div>\n```\n\nThe [`10-custom-cells-and-themes`](../../examples/src/demos/10-custom-cells-and-themes.svelte)\ndemo applies three full palettes (light / dark / high-contrast) this way.\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"520\"></div>\n\n## Density\n\nDensity is a **prop, not a token**. The virtualizer has to know each\nrow's height as a number before it can position rows, so it cannot be\nresolved from CSS - the grid writes the height as an inline style on\nevery row, which would override a stylesheet rule anyway.\n\nSet `rowHeight` on `<SvGrid>`. Numeric, in pixels; drives the row\nheight and the active-cell hit box. The default is `30`.\n\n```svelte\n<SvGrid {data} {columns} features={features} rowHeight={28} />\n```\n\nCommon steps: `28` compact, `30` normal, `46`-`48` comfortable. For\nrows whose height depends on their content, set `autoRowHeight`\ninstead and the grid measures each row.\n\nA user-facing \"density selector\" is half a dozen lines:\n\n```svelte\n<script lang=\"ts\">\n let density = $state<'compact' | 'normal' | 'comfortable'>('normal')\n const height = $derived(\n density === 'compact' ? 28 : density === 'comfortable' ? 48 : 30,\n )\n</script>\n\n<select bind:value={density}>\n <option value=\"compact\">Compact</option>\n <option value=\"normal\">Normal</option>\n <option value=\"comfortable\">Comfortable</option>\n</select>\n\n<SvGrid {data} {columns} features={features} rowHeight={height} />\n```\n\n## Sizing the grid\n\nThe wrapper renders inside whatever container you give it. The\n`containerHeight` prop sets the scrollable shell height:\n\n```svelte\n<!-- Numeric: px -->\n<SvGrid {data} {columns} features={features} containerHeight={520} />\n\n<!-- String: passed through to CSS -->\n<SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\n<SvGrid {data} {columns} features={features} containerHeight=\"auto\" />\n```\n\nFor a flex-grow layout the canonical recipe is:\n\n```svelte\n<div class=\"flex flex-col h-screen\">\n <header>…</header>\n <div class=\"flex-1 min-h-0\">\n <SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\n </div>\n</div>\n```\n\nThe `min-h-0` is the bit that bites. Flex children default to\n`min-height: auto`, which prevents the inner scroll container from\nshrinking, which makes the whole page scroll instead of the grid.\n\n## Full Tailwind integration\n\nIf your app uses Tailwind, see [Tailwind integration](../help/tailwind.md)\nfor: install + PostCSS config, `@custom-variant` so the `dark:`\nmodifier follows `data-theme`, the override hooks for the stable\n`.sv-grid-*` class names, and the anti-patterns (don't `@apply` inside\ngrid selectors, don't put utility classes on grid children, don't\nfight column widths in CSS).\n\n## Try it\n\nThere is no `density` prop - row height is the density control, and it is one\nnumber. Compact for a screen someone scans all day, comfortable for one they\nread.\n\n```svelte {runnable}\n<SvGrid data={people} {columns} rowHeight={28} showRowNumbers />\n\n<SvGrid data={people} {columns} rowHeight={48} showRowNumbers />\n```\n\n## Re-theming with tokens\n\nThe grid reads `--sg-*` custom properties, so a theme is a block of variables on\nany ancestor - no theme file, no build step.\n\n```svelte {runnable}\n<div class=\"midnight\">\n <SvGrid data={people} {columns} sortable />\n</div>\n\n<style>\n .midnight {\n --sg-bg: #0f172a;\n --sg-fg: #e2e8f0;\n --sg-border: #1e293b;\n --sg-header-bg: #111c33;\n --sg-header-fg: #93c5fd;\n --sg-accent: #38bdf8;\n --sg-row-hover: #16233d;\n }\n</style>\n```\n"
|
|
3967
|
+
"markdown": "# 5. Theme and density\n\n> Step 5 of 6 · [← Features](./4-features.md) · [Next: Going to production →](./6-going-to-production.md)\n\n## Start with a preset\n\nBefore hand-writing any tokens: 20 design-system presets ship with the package,\neach a single stylesheet with a full light + dark palette. One import re-themes\nthe whole grid.\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```ts\nimport '@svgrid/grid/themes/shadcn.css'\n```\n\nAvailable: `ember` (SvGrid's own look), `shadcn`, `tailwind`, `material`,\n`fluent`, `carbon`, `antd`, `bootstrap`, `atlassian`, `salesforce`, `sap`,\n`github`, `linear`, `notion`, `vercel`, `excel`, `nord`, `dracula`,\n`catppuccin`, `ag-alpine`.\n\nEach preset defines every token the grid and the UI components read - including\nthe semantic status colors (`--sg-danger`, `--sg-success`, `--sg-warning`,\n`--sg-info`) and the focus ring, which follows the preset's accent. Presets\nflip with `data-theme=\"dark\"` automatically (see below).\n\nOverride individual tokens after the import to adjust a preset, or skip presets\nentirely and declare the tokens yourself - that is the rest of this page.\n\n## Declaring tokens yourself\n\nThe render component (`<SvGrid>`) ships its own scoped styles. You\nre-theme it by declaring `--sg-*` CSS custom properties at any level\nabove the grid - `:root` for the whole app, a wrapper `<div>` for one\ninstance, or directly on the `<SvGrid>` element itself.\n\n\n\n## Token surface\n\nThe 20-odd tokens the renderer reads:\n\n| Token | What it paints |\n| -------------------------------- | ------------------------------------------- |\n| `--sg-bg` | Cell background |\n| `--sg-fg` | Cell text |\n| `--sg-muted` | Secondary text (footers, subtitles) |\n| `--sg-border` | Cell + header borders |\n| `--sg-header-bg` / `--sg-header-fg` | Header row |\n| `--sg-row-alt-bg` | Zebra rows |\n| `--sg-row-hover-bg` | Row + cell hover |\n| `--sg-selection-bg` | Selected cell / row tint |\n| `--sg-accent` | Sort indicator, focus ring, primary buttons |\n| `--sg-focus-ring` | Keyboard focus outline |\n| `--sg-input-bg` / `--sg-input-border` | Inline editor + filter inputs |\n| `--sg-pill-active` / `-fg` | \"Active\" status pills |\n| `--sg-pill-pending` / `-fg` | \"Pending\" status pills |\n| `--sg-pill-inactive` / `-fg` | \"Inactive\" status pills |\n| `--sg-scrollbar-*` (10 tokens) | Custom-painted scrollbars |\n\n## Light + dark via `data-theme`\n\nThe gallery flips themes by writing `dark` or `light` to\n`html[data-theme]`. Every token redeclares under that selector:\n\n```css\n:root {\n --sg-bg: #ffffff;\n --sg-fg: #0f172a;\n --sg-border: #e2e8f0;\n --sg-header-bg: #f1f5f9;\n --sg-row-alt-bg: #f8fafc;\n --sg-row-hover-bg: #eef2ff;\n --sg-accent: #2563eb;\n}\n\nhtml[data-theme='dark'] {\n --sg-bg: #0f172a;\n --sg-fg: #f1f5f9;\n --sg-border: #334155;\n --sg-header-bg: #1e2433;\n --sg-row-alt-bg: #1b2230;\n --sg-row-hover-bg: #232b3c;\n --sg-accent: #3b82f6;\n color-scheme: dark;\n}\n```\n\nToggling is one line in the app shell:\n\n```svelte {runnable}\n<script lang=\"ts\">\n let theme = $state<'light' | 'dark'>('dark')\n $effect(() => document.documentElement.setAttribute('data-theme', theme))\n</script>\n\n<button onclick={() => (theme = theme === 'dark' ? 'light' : 'dark')}>\n Toggle theme\n</button>\n```\n\n## Per-instance theming\n\nBecause the tokens are plain custom properties they cascade. To style a\nsingle grid, wrap it in a `<div>` that sets its own values:\n\n```svelte\n<div style=\"--sg-bg: #fff8f0; --sg-accent: #db2777;\">\n <SvGrid {data} {columns} features={features} />\n</div>\n```\n\nThe [`10-custom-cells-and-themes`](../../examples/src/demos/10-custom-cells-and-themes.svelte)\ndemo applies three full palettes (light / dark / high-contrast) this way.\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"520\"></div>\n\n## Density\n\nDensity is a **prop, not a token**. The virtualizer has to know each\nrow's height as a number before it can position rows, so it cannot be\nresolved from CSS - the grid writes the height as an inline style on\nevery row, which would override a stylesheet rule anyway.\n\nSet `rowHeight` on `<SvGrid>`. Numeric, in pixels; drives the row\nheight and the active-cell hit box. The default is `30`.\n\n```svelte\n<SvGrid {data} {columns} features={features} rowHeight={28} />\n```\n\nCommon steps: `28` compact, `30` normal, `46`-`48` comfortable. For\nrows whose height depends on their content, set `autoRowHeight`\ninstead and the grid measures each row.\n\nA user-facing \"density selector\" is half a dozen lines:\n\n```svelte\n<script lang=\"ts\">\n let density = $state<'compact' | 'normal' | 'comfortable'>('normal')\n const height = $derived(\n density === 'compact' ? 28 : density === 'comfortable' ? 48 : 30,\n )\n</script>\n\n<select bind:value={density}>\n <option value=\"compact\">Compact</option>\n <option value=\"normal\">Normal</option>\n <option value=\"comfortable\">Comfortable</option>\n</select>\n\n<SvGrid {data} {columns} features={features} rowHeight={height} />\n```\n\n## Sizing the grid\n\nThe wrapper renders inside whatever container you give it. The\n`containerHeight` prop sets the scrollable shell height:\n\n```svelte\n<!-- Numeric: px -->\n<SvGrid {data} {columns} features={features} containerHeight={520} />\n\n<!-- String: passed through to CSS -->\n<SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\n<SvGrid {data} {columns} features={features} containerHeight=\"auto\" />\n```\n\nFor a flex-grow layout the canonical recipe is:\n\n```svelte\n<div class=\"flex flex-col h-screen\">\n <header>…</header>\n <div class=\"flex-1 min-h-0\">\n <SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\n </div>\n</div>\n```\n\nThe `min-h-0` is the bit that bites. Flex children default to\n`min-height: auto`, which prevents the inner scroll container from\nshrinking, which makes the whole page scroll instead of the grid.\n\n## Full Tailwind integration\n\nIf your app uses Tailwind, see [Tailwind integration](../help/tailwind.md)\nfor: install + PostCSS config, `@custom-variant` so the `dark:`\nmodifier follows `data-theme`, the override hooks for the stable\n`.sv-grid-*` class names, and the anti-patterns (don't `@apply` inside\ngrid selectors, don't put utility classes on grid children, don't\nfight column widths in CSS).\n\n## Try it\n\nThere is no `density` prop - row height is the density control, and it is one\nnumber. Compact for a screen someone scans all day, comfortable for one they\nread.\n\n```svelte {runnable}\n<SvGrid data={people} {columns} rowHeight={28} showRowNumbers />\n\n<SvGrid data={people} {columns} rowHeight={48} showRowNumbers />\n```\n\n## Re-theming with tokens\n\nThe grid reads `--sg-*` custom properties, so a theme is a block of variables on\nany ancestor - no theme file, no build step.\n\n```svelte {runnable}\n<div class=\"midnight\">\n <SvGrid data={people} {columns} sortable />\n</div>\n\n<style>\n .midnight {\n --sg-bg: #0f172a;\n --sg-fg: #e2e8f0;\n --sg-border: #1e293b;\n --sg-header-bg: #111c33;\n --sg-header-fg: #93c5fd;\n --sg-accent: #38bdf8;\n --sg-row-hover: #16233d;\n }\n</style>\n```\n\n<!-- tutorial:learn-7-theme -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-7-theme\" data-docs-tutorial=\"learn-7-theme\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-7-theme.mp4\" poster=\"/tutorials/learn-7-theme.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 7: themes, dark mode and density, 113 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-7-theme.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-7-theme.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 7: themes, dark mode and density</strong> (1:53, silent). <a href=\"https://www.youtube.com/watch?v=yhJMpkQ0Kww\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>A grid that looks like a grid library is a grid that never ships. Theming here is CSS custom properties, not utility classes and not a config object.</p>\n<p>Twenty presets ship with the package, and importing one stylesheet is the whole setup. What a preset sets is a list of custom properties, which means you can set them yourself when one is close but not right. Same five people as before.</p>\n<p>A wrapper, and a handful of properties on it. Background, text, border, the header colours, the accent, the hover. They cascade like any other custom property, so this theme is scoped to this one grid rather than the page.</p>\n<p>The whole file, so nothing is hidden: the rows, the columns, a wrapper around the grid, and the properties on that wrapper.</p>\n<p>Here is that file running. Nine declarations, and nothing about the grid was configured to accept them.</p>\n<p>Density is separate from colour, and it is the setting people actually argue about. Row height is a prop, so the compact table and the comfortable one are the same component with one number changed.</p>\n<p>Dark mode is not a second stylesheet you maintain. Every preset declares a light palette and a dark one, and an attribute on the document element picks which is live. Every surface follows, including the parts you never styled.</p>\n<p>The density control here is the same row height prop behind a dropdown, which is what it usually becomes: a preference you save per user rather than a decision you make once for everybody.</p>\n<p>The theme builder on the site edits these tokens live and hands you the CSS at the end. Every property is listed on the page below. See you in lesson eight.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-7-theme -->\n"
|
|
3951
3968
|
},
|
|
3952
3969
|
{
|
|
3953
3970
|
"slug": "getting-started/6-going-to-production",
|
|
3954
3971
|
"path": "docs/getting-started/6-going-to-production.md",
|
|
3955
3972
|
"title": "6. Going to production",
|
|
3956
3973
|
"section": "Getting started",
|
|
3957
|
-
"markdown": "# 6. Going to production\n\n> Step 6 of 6 · [← Theme and density](./5-theme-and-density.md)\n\nYou have a working grid. This page is the checklist that turns it into\nsomething you'd ship.\n\n## 1. Server-side data\n\nFor datasets that don't fit in memory, drive the grid from the server.\nPair `externalSort` + `externalFilter` with the corresponding callbacks\nso the grid records the user's intent but doesn't try to re-order rows\nit didn't fetch.\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n department: string\n age: number\n salary: number\n city: string\n startDate: string\n active: boolean\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering', age: 36, salary: 142000, city: 'London', startDate: '2021-03-01', active: true },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', department: 'Engineering', age: 45, salary: 168000, city: 'New York', startDate: '2019-07-15', active: true },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', department: 'Platform', age: 54, salary: 155000, city: 'Portland', startDate: '2020-01-20', active: false },\n { id: 4, name: 'Radia Perlman', email: 'radia@example.com', department: 'Networking', age: 49, salary: 161000, city: 'Seattle', startDate: '2022-09-05', active: true },\n { id: 5, name: 'Barbara Liskov', email: 'barbara@example.com', department: 'Platform', age: 52, salary: 172000, city: 'Boston', startDate: '2018-11-11', active: true },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature,\n columnFilteringFeature } from '@svgrid/grid'\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n let sort = $state<Array<{ id: string; desc: boolean }>>([])\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\n let page = $state(0)\n const pageSize = 50\n\n let rows = $state<Person[]>([])\n let total = $state(0)\n let loading = $state(false)\n let controller: AbortController | null = null\n\n async function load() {\n controller?.abort()\n controller = new AbortController()\n loading = true\n try {\n const res = await fetch('/api/people?' + new URLSearchParams({\n sort: JSON.stringify(sort),\n filters: JSON.stringify(filters),\n page: String(page),\n size: String(pageSize),\n }), { signal: controller.signal })\n const body = await res.json()\n rows = body.rows\n total = body.total\n } catch (err) {\n if ((err as Error).name !== 'AbortError') throw err\n } finally {\n loading = false\n }\n }\n\n $effect(() => { sort; filters; page; load() })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n externalSort={true}\n externalFilter={true}\n showPagination={false}\n onSortingChange={(next) => { sort = next; page = 0 }}\n onFiltersChange={(next) => { filters = next.columns; page = 0 }}\n/>\n```\n\nThe [`09-server-side` demo](../../examples/src/demos/09-server-side.svelte)\nhas the full runnable version with debounce + abort + a 60 ms mock\nlatency. The [server-side guide](../help/server-side-data.md) covers\nsparse infinite scroll, velocity-aware chunk loading, and\nbackpressure.\n\n<div data-docs-demo=\"09-server-side\" data-height=\"520\"></div>\n\n## 2. Virtualization for large datasets\n\nFor more than ~2k rows, enable row virtualization. For very wide grids\n(50+ columns) also enable column virtualization. Both are opt-in so\nsmall grids don't pay the cost.\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n virtualization={true}\n columnVirtualization={true}\n overscan={8}\n columnOverscan={3}\n rowHeight={32}\n containerHeight={600}\n/>\n```\n\nThe wrapper's row + column virtualizers handle variable row heights via\nthe headless `createSvelteVirtualizer` / `createColumnVirtualizer`. See\n[demo 06](../../examples/src/demos/06-large-dataset.svelte) for 100k\nrows × 100 columns with smooth scroll.\n\n<div data-docs-demo=\"06-large-dataset\" data-height=\"520\"></div>\n\n## 3. Accessibility\n\nThe grid implements the WAI-ARIA 1.2 grid pattern out of the box. Every\nnode has the right role, the active cell carries focus, the keyboard\nmap matches what assistive tech expects.\n\nYou don't need to add anything for a baseline accessible grid. To go\nfurther:\n\n- Add `aria-live` announcements for changes outside the grid (filter\n applied, X rows selected) - see [demo 17](../../examples/src/demos/17-accessibility.svelte).\n- Toggle a high-contrast focus outline for users who need it -\n also in demo 17.\n- Read [Accessibility](../help/accessibility.md) for the WCAG 2.1 AA\n mapping, forced-colors-mode behaviour, and reduced-motion handling.\n\n<div data-docs-demo=\"17-accessibility\" data-height=\"500\"></div>\n\n## 4. SSR-friendly markup\n\nThe render component produces meaningful HTML before hydration. In a\nSvelteKit `+page.server.ts` load, the grid's markup hits the browser\nalready filled with data - first paint shows the table, hydration only\nattaches event listeners.\n\n```svelte {runnable}\n<!-- +page.svelte -->\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature } from '@svgrid/grid'\n let { data } = $props()\n</script>\n\n<SvGrid\n data={data.rows}\n columns={columns}\n features={tableFeatures({ rowSortingFeature })}\n/>\n```\n\nWhat is and is not in the server HTML:\n\n- **In it:** the header, and a viewport-sized window of rows with their real\n cell values. Enough for a crawler to index the content and for a no-JS client\n to read the table.\n- **Not in it:** rows below that first window. Virtualization survives SSR, so a\n 5,000-row grid does not serialise 5,000 rows into the page; the rest arrive\n when the client measures the viewport and takes over.\n\n`pnpm ssr:check` asserts this against a real `generate: 'server'` build and runs\nin CI. It exists because the grid silently stopped server-rendering rows for a\nwhile: both virtualizers learn their row and column count from an `$effect`, and\neffects never run during SSR, so the server emitted an empty `<tbody>` while\nthis page claimed otherwise.\n\n[Demo 19 - SSR](../../examples/src/demos/19-ssr.svelte) illustrates the *shape*\nof the pre-hydration markup by snapshotting the rendered grid into a\nscript-blocked iframe. Note it snapshots the client-rendered DOM, so it shows\nwhat the markup looks like without JS - it does not measure the server output.\n`pnpm ssr:check` is what actually verifies that.\n\n## 5. Content Security Policy\n\nSvGrid runs cleanly under a strict CSP - no `eval`, no `new Function`,\nno inline scripts, no inline event handlers. The recommended header:\n\n```\nContent-Security-Policy:\n default-src 'self';\n script-src 'self';\n style-src 'self' 'unsafe-inline';\n img-src 'self' data:;\n font-src 'self' data:;\n connect-src 'self';\n frame-ancestors 'none';\n base-uri 'self';\n form-action 'self';\n```\n\nNote: no `'unsafe-eval'` and no `'unsafe-inline'` on `script-src`.\n[Demo 16 - CSP-compliant](../../examples/src/demos/16-csp-compliant.svelte)\nruns a live runtime self-check + a violation listener inside a working\ngrid.\n\n## 6. TypeScript notes\n\n```ts\nimport type {\n ColumnDef,\n SvGridApi,\n SortingState,\n TableFeatures,\n} from '@svgrid/grid'\n\n// 1. Constrain ColumnDef to your row type so editors and accessors stay typed.\ntype Row = { id: string; firstName: string; age: number }\nconst columns: GridColumns<Row> = [\n { field: 'firstName', header: 'First' }, // OK\n { field: 'middleName', header: 'Mid' }, // ✗ \"middleName\" not on Row\n]\n\n// 2. The api type matches your features + row type.\nlet api = $state<SvGridApi<typeof features, Row> | null>(null)\n```\n\nThe features generic on `ColumnDef` is the type of the `features`\nobject - `tableFeatures({ rowSortingFeature })` produces a different\ntype than `tableFeatures({})`. Pass `typeof features` so column-level\ninference picks up which capabilities your grid has.\n\n## 7. What's next\n\n- [Why headless?](../why-headless.md) - the layered architecture and\n when to drop down to the headless core.\n- [Enterprise features](../enterprise/README.md) - export, import, AI, pivot.\n- [Help index](../help/index.md) - the topic-page catalogue.\n- [Recipes](../help/recipes.md) - 20+ copy-paste patterns.\n- [How SvGrid compares](../help/comparison.md) - what\n evaluators read.\n- [Missing features](../help/missing-features.md) - the honest gap list.\n"
|
|
3974
|
+
"markdown": "# 6. Going to production\n\n> Step 6 of 6 · [← Theme and density](./5-theme-and-density.md)\n\nYou have a working grid. This page is the checklist that turns it into\nsomething you'd ship.\n\n## 1. Server-side data\n\nFor datasets that don't fit in memory, drive the grid from the server.\nPair `externalSort` + `externalFilter` with the corresponding callbacks\nso the grid records the user's intent but doesn't try to re-order rows\nit didn't fetch.\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n department: string\n age: number\n salary: number\n city: string\n startDate: string\n active: boolean\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering', age: 36, salary: 142000, city: 'London', startDate: '2021-03-01', active: true },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', department: 'Engineering', age: 45, salary: 168000, city: 'New York', startDate: '2019-07-15', active: true },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', department: 'Platform', age: 54, salary: 155000, city: 'Portland', startDate: '2020-01-20', active: false },\n { id: 4, name: 'Radia Perlman', email: 'radia@example.com', department: 'Networking', age: 49, salary: 161000, city: 'Seattle', startDate: '2022-09-05', active: true },\n { id: 5, name: 'Barbara Liskov', email: 'barbara@example.com', department: 'Platform', age: 52, salary: 172000, city: 'Boston', startDate: '2018-11-11', active: true },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature,\n columnFilteringFeature } from '@svgrid/grid'\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n let sort = $state<Array<{ id: string; desc: boolean }>>([])\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\n let page = $state(0)\n const pageSize = 50\n\n let rows = $state<Person[]>([])\n let total = $state(0)\n let loading = $state(false)\n let controller: AbortController | null = null\n\n async function load() {\n controller?.abort()\n controller = new AbortController()\n loading = true\n try {\n const res = await fetch('/api/people?' + new URLSearchParams({\n sort: JSON.stringify(sort),\n filters: JSON.stringify(filters),\n page: String(page),\n size: String(pageSize),\n }), { signal: controller.signal })\n const body = await res.json()\n rows = body.rows\n total = body.total\n } catch (err) {\n if ((err as Error).name !== 'AbortError') throw err\n } finally {\n loading = false\n }\n }\n\n $effect(() => { sort; filters; page; load() })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n externalSort={true}\n externalFilter={true}\n showPagination={false}\n onSortingChange={(next) => { sort = next; page = 0 }}\n onFiltersChange={(next) => { filters = next.columns; page = 0 }}\n/>\n```\n\nThe [`09-server-side` demo](../../examples/src/demos/09-server-side.svelte)\nhas the full runnable version with debounce + abort + a 60 ms mock\nlatency. The [server-side guide](../help/server-side-data.md) covers\nsparse infinite scroll, velocity-aware chunk loading, and\nbackpressure.\n\n<div data-docs-demo=\"09-server-side\" data-height=\"520\"></div>\n\n## 2. Virtualization for large datasets\n\nFor more than ~2k rows, enable row virtualization. For very wide grids\n(50+ columns) also enable column virtualization. Both are opt-in so\nsmall grids don't pay the cost.\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n virtualization={true}\n columnVirtualization={true}\n overscan={8}\n columnOverscan={3}\n rowHeight={32}\n containerHeight={600}\n/>\n```\n\nThe wrapper's row + column virtualizers handle variable row heights via\nthe headless `createSvelteVirtualizer` / `createColumnVirtualizer`. See\n[demo 06](../../examples/src/demos/06-large-dataset.svelte) for 100k\nrows × 100 columns with smooth scroll.\n\n<div data-docs-demo=\"06-large-dataset\" data-height=\"520\"></div>\n\n## 3. Accessibility\n\nThe grid implements the WAI-ARIA 1.2 grid pattern out of the box. Every\nnode has the right role, the active cell carries focus, the keyboard\nmap matches what assistive tech expects.\n\nYou don't need to add anything for a baseline accessible grid. To go\nfurther:\n\n- Add `aria-live` announcements for changes outside the grid (filter\n applied, X rows selected) - see [demo 17](../../examples/src/demos/17-accessibility.svelte).\n- Toggle a high-contrast focus outline for users who need it -\n also in demo 17.\n- Read [Accessibility](../help/accessibility.md) for the WCAG 2.1 AA\n mapping, forced-colors-mode behaviour, and reduced-motion handling.\n\n<div data-docs-demo=\"17-accessibility\" data-height=\"500\"></div>\n\n## 4. SSR-friendly markup\n\nThe render component produces meaningful HTML before hydration. In a\nSvelteKit `+page.server.ts` load, the grid's markup hits the browser\nalready filled with data - first paint shows the table, hydration only\nattaches event listeners.\n\n```svelte {runnable}\n<!-- +page.svelte -->\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature } from '@svgrid/grid'\n let { data } = $props()\n</script>\n\n<SvGrid\n data={data.rows}\n columns={columns}\n features={tableFeatures({ rowSortingFeature })}\n/>\n```\n\nWhat is and is not in the server HTML:\n\n- **In it:** the header, and a viewport-sized window of rows with their real\n cell values. Enough for a crawler to index the content and for a no-JS client\n to read the table.\n- **Not in it:** rows below that first window. Virtualization survives SSR, so a\n 5,000-row grid does not serialise 5,000 rows into the page; the rest arrive\n when the client measures the viewport and takes over.\n\n`pnpm ssr:check` asserts this against a real `generate: 'server'` build and runs\nin CI. It exists because the grid silently stopped server-rendering rows for a\nwhile: both virtualizers learn their row and column count from an `$effect`, and\neffects never run during SSR, so the server emitted an empty `<tbody>` while\nthis page claimed otherwise.\n\n[Demo 19 - SSR](../../examples/src/demos/19-ssr.svelte) illustrates the *shape*\nof the pre-hydration markup by snapshotting the rendered grid into a\nscript-blocked iframe. Note it snapshots the client-rendered DOM, so it shows\nwhat the markup looks like without JS - it does not measure the server output.\n`pnpm ssr:check` is what actually verifies that.\n\n## 5. Content Security Policy\n\nSvGrid runs cleanly under a strict CSP - no `eval`, no `new Function`,\nno inline scripts, no inline event handlers. The recommended header:\n\n```\nContent-Security-Policy:\n default-src 'self';\n script-src 'self';\n style-src 'self' 'unsafe-inline';\n img-src 'self' data:;\n font-src 'self' data:;\n connect-src 'self';\n frame-ancestors 'none';\n base-uri 'self';\n form-action 'self';\n```\n\nNote: no `'unsafe-eval'` and no `'unsafe-inline'` on `script-src`.\n[Demo 16 - CSP-compliant](../../examples/src/demos/16-csp-compliant.svelte)\nruns a live runtime self-check + a violation listener inside a working\ngrid.\n\n## 6. TypeScript notes\n\n```ts\nimport type {\n ColumnDef,\n SvGridApi,\n SortingState,\n TableFeatures,\n} from '@svgrid/grid'\n\n// 1. Constrain ColumnDef to your row type so editors and accessors stay typed.\ntype Row = { id: string; firstName: string; age: number }\nconst columns: GridColumns<Row> = [\n { field: 'firstName', header: 'First' }, // OK\n { field: 'middleName', header: 'Mid' }, // ✗ \"middleName\" not on Row\n]\n\n// 2. The api type matches your features + row type.\nlet api = $state<SvGridApi<typeof features, Row> | null>(null)\n```\n\nThe features generic on `ColumnDef` is the type of the `features`\nobject - `tableFeatures({ rowSortingFeature })` produces a different\ntype than `tableFeatures({})`. Pass `typeof features` so column-level\ninference picks up which capabilities your grid has.\n\n## 7. What's next\n\n- [Why headless?](../why-headless.md) - the layered architecture and\n when to drop down to the headless core.\n- [Enterprise features](../enterprise/README.md) - export, import, AI, pivot.\n- [Help index](../help/index.md) - the topic-page catalogue.\n- [Recipes](../help/recipes.md) - 20+ copy-paste patterns.\n- [How SvGrid compares](../help/comparison.md) - what\n evaluators read.\n- [Missing features](../help/missing-features.md) - the honest gap list.\n\n<!-- tutorial:learn-10-production -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-10-production\" data-docs-tutorial=\"learn-10-production\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-10-production.mp4\" poster=\"/tutorials/learn-10-production.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 10: going to production, 108 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-10-production.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-10-production.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 10: going to production</strong> (1:48, silent). <a href=\"https://www.youtube.com/watch?v=VfZuJIwldwY\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Your grid works. This last lesson is the review a colleague would give it: the four things that decide whether it survives contact with real data and real users.</p>\n<p>First, scale. Virtualization is on by default, so only the rows in view have DOM nodes. Fifty thousand rows by seventy-seven columns scroll in both directions, and the count of nodes stays roughly the same as it was with ten rows.</p>\n<p>What you do have to give it is a height. A grid inside a container with no height cannot know what is on screen, and that is the single most common reason a large table feels slow.</p>\n<p>Second, accessibility, which you mostly already have. It is a real table element carrying the ARIA grid pattern, so a screen reader announces rows and columns, and every cell is reachable from the keyboard.</p>\n<p>What is on you is the rest of the page: a label on the grid, contrast that passes, and not removing the focus ring. There is a high-contrast preset in the box if procurement asks.</p>\n<p>Third, the markup server-renders, so a viewport of real rows reaches the browser before hydration. And fourth, the habit that costs nothing: type the column array as GridColumns of your row. A bare GridColumns widens the row and stops checking field names, and a typo becomes a blank column nobody notices until a user does.</p>\n<p>That is the course. You can build a grid, shape its columns, sort and filter it, edit it, group it, theme it, feed it from a server and ship it. Everything past this point is a feature you turn on when you need it, and the demos on the site show each one running.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-10-production -->\n\n<!-- tutorial:learn-8-server-data -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-8-server-data\" data-docs-tutorial=\"learn-8-server-data\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-8-server-data.mp4\" poster=\"/tutorials/learn-8-server-data.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 8: data from a server, 106 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-8-server-data.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-8-server-data.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 8: data from a server</strong> (1:46, silent). <a href=\"https://www.youtube.com/watch?v=JPkoLN20sPQ\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Everything so far held every row in memory. That works to tens of thousands of rows. Past that, the sorting and filtering have to move to the server, and the grid has to ask for what it needs.</p>\n<p>The state the grid hands you is small: which column is sorted, which filters are set, which page you are on. Keep those in runes.</p>\n<p>Then one function that turns that state into a request. Notice the abort controller: when the user types another letter, the request already in flight is cancelled, so a slow earlier response cannot overwrite a newer one. That race is the most common bug in a server-backed table.</p>\n<p>An effect reruns it whenever sort, filters or page change, and the component passes the rows down with the total, so the pager knows how many pages there are without counting rows it never received.</p>\n<p>Here is that pattern running against a real endpoint. Sorting goes to the server and comes back sorted; typing in the search box is debounced, so one request goes out rather than one per keystroke.</p>\n<p>The loading state lives on the rows rather than as a spinner over the page, so the header and the controls stay usable while the next page arrives.</p>\n<p>Past a million rows there is a dedicated row model that lazily loads groups and blocks; it is linked from the page below. Lesson nine puts all of this in a SvelteKit app.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-8-server-data -->\n"
|
|
3975
|
+
},
|
|
3976
|
+
{
|
|
3977
|
+
"slug": "getting-started/course",
|
|
3978
|
+
"path": "docs/getting-started/course.md",
|
|
3979
|
+
"title": "Learn SvGrid: the video course",
|
|
3980
|
+
"section": "Getting started",
|
|
3981
|
+
"markdown": "# Learn SvGrid: the video course\n\nTen short lessons that take you from an empty project to a grid you would put\nin front of users. Each one is a few minutes, each ends where the next begins,\nand every line of code on screen is a file you can open: the lessons are\nrecorded against the runnable examples on these pages, so what you watch is\nwhat runs.\n\nWatch them in order the first time. After that they work as reference, since\neach lesson sits on the docs page for its subject and the page carries the\nsame code in text.\n\n## What you need\n\nA project with Svelte 5 and the package installed. [Install](./1-install.md)\ncovers both, and takes about a minute. Everything else is in the lessons.\n\nThe course assumes you know Svelte 5 runes well enough to read `$state`, and\nnothing about data grids.\n\nAll ten also play back to back as a\n[YouTube playlist](https://www.youtube.com/playlist?list=PLODWzOnOLhEk), with\nnarration.\n\n## The lessons\n\n<!-- course:learn -->\n\n| # | Lesson | What it covers | Length |\n| --- | --- | --- | --- |\n| 1 | [Install and your first grid](./2-first-grid.md#tutorial-learn-1-first-grid) | Install the package, write a twenty-line component, and get a styled, keyboard-navigable table. | 2 min 01 s |\n| 2 | [Your data, your columns](./3-data-and-columns.md#tutorial-learn-2-data-and-columns) | Put your own row type in, keep it reactive, then set headers, widths and currency, percent and date formats. | 1 min 28 s |\n| 3 | [Sorting, filtering and paging](./4-features.md#tutorial-learn-3-sort-filter-page) | Switch on sorting, filtering, grouping and pagination with boolean props, and see what each one does to the same table. | 2 min 23 s |\n| 4 | [Editing rows, and validating them](../help/editing/validation.md#tutorial-learn-4-editing) | Make cells editable, pick an editor per column, and use validate with rejectInvalid so bad input never reaches your data. | 1 min 31 s |\n| 5 | [Selection and bulk actions](../help/rows/selection-bar.md#tutorial-learn-5-selection) | Add row checkboxes, give the grid a stable row id, and float a bulk-action bar over the rows the user picked. | 1 min 26 s |\n| 6 | [Grouping and totals](../help/grouping-aggregation.md#tutorial-learn-6-grouping) | Group rows by a column, average and sum per group with the aggregate option, and add a summary row for the whole table. | 1 min 34 s |\n| 7 | [Themes, dark mode and density](./5-theme-and-density.md#tutorial-learn-7-theme) | Set the CSS custom properties yourself, flip dark mode with one attribute, and change row density with a prop. | 1 min 53 s |\n| 8 | [Data from a server](./6-going-to-production.md#tutorial-learn-8-server-data) | Move sorting, filtering and paging to the backend, send one request per change, and cancel the one that is now stale. | 1 min 46 s |\n| 9 | [SvelteKit end to end](./sveltekit.md#tutorial-learn-9-sveltekit) | Query in a load function, hand the rows to the page, and get real server-rendered HTML instead of an empty shell. | 1 min 21 s |\n| 10 | [Going to production](./6-going-to-production.md#tutorial-learn-10-production) | Virtualization, the accessibility you already have, server-rendered markup, and the TypeScript habit that prevents blank columns. | 1 min 48 s |\n\nTen lessons, 17 min 10 s in all.\n\n<!-- /course:learn -->\n\n## How the lessons are made\n\nThe code you see typed in the editor is not a screenshot or a re-typed copy.\nEach lesson reads a runnable block straight out of these docs, types that\nfile's own source, and mounts that same file in the browser pane beside it. So\nthe code shown and the result shown cannot drift apart, and when a doc changes\nthe lesson is re-recorded from it rather than edited to match.\n\nEvery claim the narration makes is also checked while recording. When a lesson\nsays a button adds a row, the recorder counts the rows before and after and\nfails the take if the count did not move. That check exists because a take\nonce shipped where it had not.\n\n## See also\n\n- [Install](./1-install.md) - the one-minute setup the course assumes.\n- [Starters](./starters.md) - a scaffolded project if you would rather not\n wire one up by hand.\n- [SvelteKit](./sveltekit.md) - the same grid in a SvelteKit app, end to end.\n- [Going to production](./6-going-to-production.md) - the checks before you\n ship, which is where lesson ten ends.\n"
|
|
3958
3982
|
},
|
|
3959
3983
|
{
|
|
3960
3984
|
"slug": "getting-started/starters",
|
|
3961
3985
|
"path": "docs/getting-started/starters.md",
|
|
3962
3986
|
"title": "Starters & scaffolding",
|
|
3963
3987
|
"section": "Getting started",
|
|
3964
|
-
"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\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# Pivot cube + linked chart + drill-through (needs @svgrid/enterprise)\nnpm create @svgrid@latest my-cube -- --template pivot-dashboard\n\n# The engine only - your own markup and CSS\nnpm create @svgrid@latest my-table -- --template headless\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| `sveltekit` | SvelteKit + `@svgrid/grid`: server `load`, URL-driven sort, form-action edits, cookie sessions with role gating | Server-rendered pages; the app from the [SvelteKit guide](./sveltekit.md) |\n| `admin-dashboard` | SvelteKit + Tailwind + `@svgrid/grid`, deploy to Vercel | A real dashboard / internal tool |\n| `pivot-dashboard` | SvelteKit + `@svgrid/enterprise`: a pivot cube, a linked chart and a drill-through rail over one fact table | Reporting screens where a number has to be traceable to its rows |\n| `headless` | Vite + Svelte 5 + `@svgrid/grid/core`: the engine driving a hand-written `<table>` | Your own renderer and your own CSS |\n\nThe four `<SvGrid>` templates start on the Ember theme, the same one the demos\nand svgrid.com use, unless you pass `--theme <id>`. `headless` loads no grid\nstylesheet at all, so it has no theme to pick.\n\n### Options\n\n| Flag | Alias | Description |\n| --- | --- | --- |\n| `--template <name>` | `-t` | `minimal`, `sveltekit`, `pivot-dashboard`, `admin-dashboard`, or `headless` |\n| `--theme <id>` | | One of the 20 presets (default `ember`). Ignored by `headless` |\n| `--dark` / `--light` | | Pin the starting mode. Left out, `minimal` and `sveltekit` follow the visitor's OS and `admin-dashboard` starts dark |\n| `--force` | `-f` | Scaffold into a non-empty directory |\n| `--help` | `-h` | Show usage |\n\nEvery template uses the free MIT `@svgrid/grid` core. Add\n[`@svgrid/enterprise`](../enterprise/README.md) for export, import, print, and pivot.\nThe AI helpers (natural-language filter, smart fill, summarize, classify) are part of\nthe free core - you register your own model provider.\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## The pivot dashboard starter\n\n`pivot-dashboard` is the one template built on `@svgrid/enterprise` rather than the\nfree package, because the pivot designer is a paid feature.
|
|
3988
|
+
"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\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# Pivot cube + linked chart + drill-through (needs @svgrid/enterprise)\nnpm create @svgrid@latest my-cube -- --template pivot-dashboard\n\n# The engine only - your own markup and CSS\nnpm create @svgrid@latest my-table -- --template headless\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| `sveltekit` | SvelteKit + `@svgrid/grid`: server `load`, URL-driven sort, form-action edits, cookie sessions with role gating | Server-rendered pages; the app from the [SvelteKit guide](./sveltekit.md) |\n| `admin-dashboard` | SvelteKit + Tailwind + `@svgrid/grid`, deploy to Vercel | A real dashboard / internal tool |\n| `pivot-dashboard` | SvelteKit + `@svgrid/enterprise`: a pivot cube, a linked chart and a drill-through rail over one fact table | Reporting screens where a number has to be traceable to its rows |\n| `headless` | Vite + Svelte 5 + `@svgrid/grid/core`: the engine driving a hand-written `<table>` | Your own renderer and your own CSS |\n\nThe four `<SvGrid>` templates start on the Ember theme, the same one the demos\nand svgrid.com use, unless you pass `--theme <id>`. `headless` loads no grid\nstylesheet at all, so it has no theme to pick.\n\n### Options\n\n| Flag | Alias | Description |\n| --- | --- | --- |\n| `--template <name>` | `-t` | `minimal`, `sveltekit`, `pivot-dashboard`, `admin-dashboard`, or `headless` |\n| `--theme <id>` | | One of the 20 presets (default `ember`). Ignored by `headless` |\n| `--dark` / `--light` | | Pin the starting mode. Left out, `minimal` and `sveltekit` follow the visitor's OS and `admin-dashboard` starts dark |\n| `--force` | `-f` | Scaffold into a non-empty directory |\n| `--help` | `-h` | Show usage |\n\nEvery template uses the free MIT `@svgrid/grid` core. Add\n[`@svgrid/enterprise`](../enterprise/README.md) for export, import, print, and pivot.\nThe AI helpers (natural-language filter, smart fill, summarize, classify) are part of\nthe free core - you register your own model provider.\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## The pivot dashboard starter\n\n`pivot-dashboard` is the one template built on `@svgrid/enterprise` rather than the\nfree package, because the pivot designer is a paid feature. No key is needed to try it: without one it runs and displays the\nwatermark and the console message about the Enterprise package, so you\ncan evaluate it before buying.\n\nIt puts three panes over a single fact table:\n\n- a **pivot cube** you can re-shape by dragging fields between wells,\n- a **chart** that re-aggregates the same facts along the first row dimension,\n- a **drill-through rail** that opens when you click any aggregated cell and\n lists the exact rows behind that number.\n\nThe point is that they cannot disagree. The rail recomputes its total from the\nrows it lists rather than reading the grid, so if the two ever diverged the\ndrill filter and the pivot aggregation would have drifted apart.\n\nThe drill maths lives in `src/lib/drill.ts`, deliberately free of Svelte and of the\ngrid packages, so you can unit-test your own reporting rules against it.\n\n## The headless starter\n\n`headless` is the one template that does not render `<SvGrid>`. It imports\n`createSvGrid` from `@svgrid/grid/core` - the engine, with no DOM code, no ARIA,\nand no CSS - and feeds a `<table>` written by hand in `src/App.svelte`:\n\n- **The row pipeline is explicit** - `coreRowModel` -> `filteredRowModel` ->\n `sortedRowModel`, listed in `App.svelte`. Add\n [grouping, pagination, or tree data](../help/headless/row-models.md) by adding\n their row models to the same object.\n- **Features are registered, not inferred** -\n `tableFeatures({ rowSortingFeature, columnFilteringFeature })`. What you leave\n out never enters the bundle; the built starter is about 17 KB gzipped,\n including the Svelte runtime.\n- **State is controlled** - `sorting` and `columnFilters` are plain Svelte 5\n `$state`, typed as the engine's own `SortingState` and `ColumnFiltersState`.\n See [Controlled state](../help/headless/controlled-state.md).\n- **The stylesheet is yours** - `src/app.css` has no preset import and no\n `--sg-*` token. `--theme` is accepted but ignored here, because there is\n nothing for it to write to.\n\nStart there when you need a renderer the component does not give you: a plain\ntable for print or email, a card list on mobile, an SVG, or a custom virtualized\nview. For the common case, `minimal` and `<SvGrid>` are less work. Background:\n[Headless overview](../help/headless/overview.md) and\n[Build a table from scratch](../help/headless/build-a-table.md).\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### Can I scaffold a headless project, without the grid's CSS?\n\nYes. `npm create @svgrid@latest my-table -- --template headless` scaffolds a\nVite + Svelte 5 app built on `createSvGrid`, the engine behind `<SvGrid>`. It\nships a hand-written `<table>` and a stylesheet with no grid tokens in it, so\nsorting and filtering come from SvGrid and every pixel is yours.\n\n### Do the starters require a Enterprise license?\n\nNo. Every template uses 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"
|
|
3965
3989
|
},
|
|
3966
3990
|
{
|
|
3967
3991
|
"slug": "getting-started/sveltekit",
|
|
3968
3992
|
"path": "docs/getting-started/sveltekit.md",
|
|
3969
3993
|
"title": "SvGrid with SvelteKit",
|
|
3970
3994
|
"section": "Getting started",
|
|
3971
|
-
"markdown": "# SvGrid with SvelteKit\n\nA grid in a SvelteKit app is not the same job as a grid in a Vite SPA. The data\nusually comes from a `+page.server.ts` load, the page has to server-render for\ncrawlers, sorting should survive a refresh and a shared link, and edits go back\nthrough a form action. This page walks all four.\n\nEverything here was run against a real SvelteKit app before it was written -\n`sv create`, `sv add`, `svelte-check`, `vite build`, and the server HTML checked\nover HTTP.\n\n## Run it before you read it\n\nEverything this page builds is also a template, so you can have it on your\nmachine in three commands:\n\n```bash\nnpm create @svgrid@latest people -- --template sveltekit\ncd people && npm install && npm run dev\n```\n\nThat is the app from [the end-to-end section](#the-whole-thing-end-to-end)\nbelow, plus a theme picker in the header - all 20 presets, light and dark,\nswitched live while the grid is running. Pick the starting theme up front if you\nprefer:\n\n```bash\nnpm create @svgrid@latest people -- --template sveltekit --theme dracula --dark\n```\n\n`--theme` writes the preset into `src/app.css`, so the first paint and the\nserver-rendered HTML are already themed rather than flashing a frame of the\ndefault. Leave `--dark`/`--light` off and the app follows the visitor's OS\nsetting instead. `npm create @svgrid@latest -- --help` lists every preset id.\n\nThe rest of this page builds the same thing by hand, which is the better way to\nunderstand it.\n\n## 1. Scaffold\n\nSvGrid ships an add-on for the [Svelte CLI](https://svelte.dev/docs/cli), so a\nnew app is two commands:\n\n```bash\nnpx sv create myapp # pick SvelteKit, TypeScript\ncd myapp\nnpx sv add @svgrid\n```\n\n`sv add` resolves `@svgrid` to `@svgrid/sv`, adds `@svgrid/grid` to your\ndependencies, and - if you say yes to the demo - writes a working grid to\n`src/routes/svgrid-demo/+page.svelte`. To skip the prompts:\n\n```bash\nnpx sv add \"@svgrid=demo:yes+enterprise:no\" --no-download-check\n```\n\nTwo things worth knowing. `sv add` must run **inside** a project - in an empty\ndirectory it fails with *\"Invalid workspace\"*, which is the CLI's own guard, not\nthe add-on. And community add-ons are still marked experimental upstream, so if\n`sv` changes under you, `npm install @svgrid/grid` does the same job.\n\nAdding to an existing app is just the install:\n\n```bash\nnpm install @svgrid/grid\n```\n\n## 2. Load data on the server\n\nPut the query in `+page.server.ts` and hand the rows to the page. Nothing about\nSvGrid is special here - it takes a plain array.\n\n```ts\n// src/routes/people/+page.server.ts\nimport type { PageServerLoad } from './$types'\nimport { listPeople } from '$lib/db'\n\nexport const load: PageServerLoad = () => {\n return { rows: listPeople() }\n}\n```\n\n```svelte\n<!-- src/routes/people/+page.svelte -->\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import type { Person } from '$lib/db'\n\n let { data } = $props()\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name' },\n { field: 'role', header: 'Role' },\n { field: 'year', header: 'Year' },\n ]\n</script>\n\n<SvGrid data={data.rows} {columns} sortable containerHeight={320} />\n```\n\nType the column array against your row type. `GridColumns<Person>` checks every\n`field` against real keys; a bare `GridColumns` widens the row to\n`Record<string, unknown>` and stops checking them.\n\n## 3. What actually server-renders\n\nThe server HTML contains the header and a **viewport-sized window of rows** with\ntheir real cell values. That is what a crawler indexes and what a reader with JS\ndisabled sees.\n\nIt does *not* contain every row. Virtualization survives SSR, so a 5,000-row\ngrid does not serialise 5,000 rows into the page - the rest arrive when the\nclient measures the viewport and takes over. Hydration is clean because the\nserver and the first client render produce the same markup.\n\nYou can check it yourself on any page:\n\n```bash\nnpm run build && npm run preview\ncurl -s http://localhost:4173/people | grep -c 'data-svgrid-row'\n```\n\nA non-zero count means rows are in the HTML.\n\n> This genuinely did not work before `@svgrid/grid@2.6.8`. Both virtualizers\n> learned their row count from an `$effect`, and effects never run during SSR,\n> so the server emitted an empty `<tbody>`. If you are on an older version and\n> SEO matters, upgrade.\n\n## 4. Sorting that survives a refresh\n\nThe idiomatic SvelteKit move is to keep sort state in the URL. The link is then\nshareable and bookmarkable, the back button works, and the sorted page is\nserver-rendered - so it works with JS disabled too.\n\nSort on the server from the query string:\n\n```ts\n// src/routes/people/+page.server.ts\nexport const load: PageServerLoad = ({ url }) => {\n const sortBy = (url.searchParams.get('sort') ?? 'name') as keyof Person\n const desc = url.searchParams.get('dir') === 'desc'\n return { rows: listPeople(sortBy, desc), sortBy, desc }\n}\n```\n\nThen tell the grid the server owns the ordering, and push header clicks into the\nURL:\n\n```svelte\n<script lang=\"ts\">\n import { goto } from '$app/navigation'\n import { page } from '$app/state'\n\n function onSortingChange(sorting: Array<{ id: string; desc: boolean }>) {\n const next = new URL(page.url)\n if (sorting.length === 0) {\n next.searchParams.delete('sort')\n next.searchParams.delete('dir')\n } else {\n next.searchParams.set('sort', sorting[0]!.id)\n next.searchParams.set('dir', sorting[0]!.desc ? 'desc' : 'asc')\n }\n goto(next, { keepFocus: true, noScroll: true })\n }\n</script>\n\n<SvGrid\n data={data.rows}\n {columns}\n sortable\n externalSort\n initialSorting={[{ id: data.sortBy, desc: data.desc }]}\n {onSortingChange}\n/>\n```\n\n`externalSort` is the important prop: the grid keeps rendering sort indicators\nand cycling on header click, but stops reordering rows itself - because the rows\narriving from `load` are already in the right order. Without it the grid sorts\nthe page a second time, which is wasted work and goes wrong the moment the\nserver is paginating.\n\n`keepFocus` and `noScroll` stop the navigation stealing focus from the header\nyou just clicked or jumping the page to the top.\n\n## 5. Saving an edit through a form action\n\nTurn on editing, then push each committed change at an action:\n\n```ts\n// +page.server.ts\nexport const actions: Actions = {\n rename: async ({ request }) => {\n const form = await request.formData()\n renamePerson(Number(form.get('id')), String(form.get('name')))\n return { success: true }\n },\n}\n```\n\n```svelte\n<script lang=\"ts\">\n async function onCellValueChange(e: { row: Person; columnId: string; newValue: unknown }) {\n if (e.columnId !== 'name') return\n const body = new FormData()\n body.set('id', String(e.row.id))\n body.set('name', String(e.newValue))\n await fetch('?/rename', { method: 'POST', body })\n }\n</script>\n\n<SvGrid data={data.rows} {columns} editable {onCellValueChange} />\n```\n\nThe grid updates its own copy of the row immediately, so the cell shows the new\nvalue while the request is in flight. Handle a rejected save by reloading\n(`invalidateAll()`) or by writing the old value back.\n\nSvelteKit's CSRF protection rejects a cross-origin POST to an action. Browsers\nsend the `Origin` header themselves so this is invisible in an app - but if you\nscript the endpoint from Node or curl, set `Origin` or you will get\n`403 Cross-site POST form submissions are forbidden`.\n\n## The whole thing, end to end\n\nFour commands and three files. Every line below was run from a clean scaffold\nbefore it was published here: `svelte-check` reports 0 errors, the page\nserver-renders its rows, clicking a header rewrites the URL, and an edit\nsurvives a reload.\n\nIf you would rather run it than type it,\n`npm create @svgrid@latest people -- --template sveltekit` gives you this app\nwith a theme picker already wired in. The files below are what it contains.\n\n```bash\nnpx sv create people --template minimal --types ts\ncd people\nnpx sv add \"@svgrid=demo:no+enterprise:no\" --no-download-check\nnpm install\n```\n\n**`src/lib/people.ts`** - stands in for your database.\n\n```ts\nexport type Person = { id: number; name: string; role: string; year: number }\n\nconst people: Person[] = [\n { id: 1, name: 'Ada Lovelace', role: 'Mathematician', year: 1843 },\n { id: 2, name: 'Grace Hopper', role: 'Rear Admiral', year: 1952 },\n { id: 3, name: 'Karen Sparck Jones', role: 'Computer Scientist', year: 1972 },\n { id: 4, name: 'Barbara Liskov', role: 'Computer Scientist', year: 1968 },\n { id: 5, name: 'Margaret Hamilton', role: 'Software Engineer', year: 1969 },\n]\n\nexport function listPeople(sortBy: keyof Person = 'name', desc = false): Person[] {\n const rows = [...people]\n rows.sort((a, b) => (a[sortBy] > b[sortBy] ? 1 : a[sortBy] < b[sortBy] ? -1 : 0))\n return desc ? rows.reverse() : rows\n}\n\nexport function renamePerson(id: number, name: string): void {\n const row = people.find((p) => p.id === id)\n if (row) row.name = name\n}\n```\n\n**`src/routes/people/+page.server.ts`** - sorts from the query string, and takes\nthe edit.\n\n```ts\nimport type { Actions, PageServerLoad } from './$types'\nimport { listPeople, renamePerson, type Person } from '$lib/people'\n\nexport const load: PageServerLoad = ({ url }) => {\n const sortBy = (url.searchParams.get('sort') ?? 'name') as keyof Person\n const desc = url.searchParams.get('dir') === 'desc'\n return { rows: listPeople(sortBy, desc), sortBy, desc }\n}\n\nexport const actions: Actions = {\n rename: async ({ request }) => {\n const data = await request.formData()\n renamePerson(Number(data.get('id')), String(data.get('name')))\n return { success: true }\n },\n}\n```\n\n**`src/routes/people/+page.svelte`**\n\n```svelte\n<script lang=\"ts\">\n import { goto } from '$app/navigation'\n import { page } from '$app/state'\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import type { Person } from '$lib/people'\n\n let { data } = $props()\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', editable: true },\n { field: 'role', header: 'Role' },\n { field: 'year', header: 'Year' },\n ]\n\n // Header click -> URL -> server sorts -> load returns ordered rows.\n function onSortingChange(sorting: Array<{ id: string; desc: boolean }>) {\n const next = new URL(page.url)\n if (sorting.length === 0) {\n next.searchParams.delete('sort')\n next.searchParams.delete('dir')\n } else {\n next.searchParams.set('sort', sorting[0]!.id)\n next.searchParams.set('dir', sorting[0]!.desc ? 'desc' : 'asc')\n }\n goto(next, { keepFocus: true, noScroll: true })\n }\n\n // Committed edit -> form action -> database.\n async function onCellValueChange(e: { row: Person; columnId: string; newValue: unknown }) {\n if (e.columnId !== 'name') return\n const body = new FormData()\n body.set('id', String(e.row.id))\n body.set('name', String(e.newValue))\n await fetch('?/rename', { method: 'POST', body })\n }\n</script>\n\n<h1>People</h1>\n<p>Click a header to sort - the order lives in the URL. Double-click a name to edit it.</p>\n\n<SvGrid\n data={data.rows}\n {columns}\n sortable\n editable\n externalSort\n initialSorting={[{ id: data.sortBy, desc: data.desc }]}\n {onSortingChange}\n {onCellValueChange}\n containerHeight={320}\n/>\n```\n\nThen:\n\n```bash\nnpm run dev # http://localhost:5173/people\n```\n\nThree things to try, in this order:\n\n1. **Click the `Year` header.** The URL becomes `?sort=year&dir=asc`. Copy that\n link into a new tab - it opens already sorted, because the server did it.\n2. **Double-click a name, change it, press Enter, then reload.** The edit went\n through the form action and survived.\n3. **View source, or `curl localhost:5173/people`.** The rows are in the HTML,\n not just injected by JS. That is what a crawler sees.\n\n## 6. Going further\n\n- **Server-side paging and filtering** for large tables: `externalFilter` and\n `externalPagination` are the equivalents of `externalSort` above. See\n [server paging](../help/server/server-paging.md),\n [server filtering](../help/server/server-filtering.md) and the\n [server row model](../help/server/server-row-model.md), which wraps the whole\n request shape rather than wiring each prop by hand.\n- **Owning the markup.** If you need a table only you could write, the headless\n engine is importable on its own from `@svgrid/grid/core` and runs anywhere,\n including inside a load function. See [Why headless?](../why-headless.md).\n- **Themes and dark mode.** Set the theme attribute before first paint from an\n inline script in `app.html`, or the page renders in the wrong palette for a\n frame. The `sveltekit` template above ships that script plus a runtime picker\n built on `resolveThemeTokens` from `@svgrid/grid/themes`, which is the short\n way to see the pattern. [Theme and density](./5-theme-and-density.md) covers\n it properly.\n- **Gating it behind a login.** The `sveltekit` template ships an auth scaffold:\n a cookie session, PBKDF2 password hashing over Web Crypto (so it still runs on\n an edge adapter), and one `PROTECTED` list in `hooks.server.ts` that gates routes\n before any load runs. The role check sits in the form action, not only in the\n UI - hiding an edit button stops nobody from posting the action by hand.\n- **Deployment.** SvGrid is a normal client dependency with no build step or\n server runtime of its own, so any SvelteKit adapter works unchanged.\n\n## See also\n\n- [Going to production](./6-going-to-production.md) - CSP, SSR, accessibility\n- [First grid](./2-first-grid.md) - the framework-agnostic walkthrough\n- [Why headless?](../why-headless.md) - when to skip the render component\n"
|
|
3995
|
+
"markdown": "# SvGrid with SvelteKit\n\nA grid in a SvelteKit app is not the same job as a grid in a Vite SPA. The data\nusually comes from a `+page.server.ts` load, the page has to server-render for\ncrawlers, sorting should survive a refresh and a shared link, and edits go back\nthrough a form action. This page walks all four.\n\nEverything here was run against a real SvelteKit app before it was written -\n`sv create`, `sv add`, `svelte-check`, `vite build`, and the server HTML checked\nover HTTP.\n\n## Run it before you read it\n\nEverything this page builds is also a template, so you can have it on your\nmachine in three commands:\n\n```bash\nnpm create @svgrid@latest people -- --template sveltekit\ncd people && npm install && npm run dev\n```\n\nThat is the app from [the end-to-end section](#the-whole-thing-end-to-end)\nbelow, plus a theme picker in the header - all 20 presets, light and dark,\nswitched live while the grid is running. Pick the starting theme up front if you\nprefer:\n\n```bash\nnpm create @svgrid@latest people -- --template sveltekit --theme dracula --dark\n```\n\n`--theme` writes the preset into `src/app.css`, so the first paint and the\nserver-rendered HTML are already themed rather than flashing a frame of the\ndefault. Leave `--dark`/`--light` off and the app follows the visitor's OS\nsetting instead. `npm create @svgrid@latest -- --help` lists every preset id.\n\nThe rest of this page builds the same thing by hand, which is the better way to\nunderstand it.\n\n## 1. Scaffold\n\nSvGrid ships an add-on for the [Svelte CLI](https://svelte.dev/docs/cli), so a\nnew app is two commands:\n\n```bash\nnpx sv create myapp # pick SvelteKit, TypeScript\ncd myapp\nnpx sv add @svgrid\n```\n\n`sv add` resolves `@svgrid` to `@svgrid/sv`, adds `@svgrid/grid` to your\ndependencies, and - if you say yes to the demo - writes a working grid to\n`src/routes/svgrid-demo/+page.svelte`. To skip the prompts:\n\n```bash\nnpx sv add \"@svgrid=demo:yes+enterprise:no\" --no-download-check\n```\n\nTwo things worth knowing. `sv add` must run **inside** a project - in an empty\ndirectory it fails with *\"Invalid workspace\"*, which is the CLI's own guard, not\nthe add-on. And community add-ons are still marked experimental upstream, so if\n`sv` changes under you, `npm install @svgrid/grid` does the same job.\n\nAdding to an existing app is just the install:\n\n```bash\nnpm install @svgrid/grid\n```\n\n## 2. Load data on the server\n\nPut the query in `+page.server.ts` and hand the rows to the page. Nothing about\nSvGrid is special here - it takes a plain array.\n\n```ts\n// src/routes/people/+page.server.ts\nimport type { PageServerLoad } from './$types'\nimport { listPeople } from '$lib/db'\n\nexport const load: PageServerLoad = () => {\n return { rows: listPeople() }\n}\n```\n\n```svelte\n<!-- src/routes/people/+page.svelte -->\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import type { Person } from '$lib/db'\n\n let { data } = $props()\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name' },\n { field: 'role', header: 'Role' },\n { field: 'year', header: 'Year' },\n ]\n</script>\n\n<SvGrid data={data.rows} {columns} sortable containerHeight={320} />\n```\n\nType the column array against your row type. `GridColumns<Person>` checks every\n`field` against real keys; a bare `GridColumns` widens the row to\n`Record<string, unknown>` and stops checking them.\n\n## 3. What actually server-renders\n\nThe server HTML contains the header and a **viewport-sized window of rows** with\ntheir real cell values. That is what a crawler indexes and what a reader with JS\ndisabled sees.\n\nIt does *not* contain every row. Virtualization survives SSR, so a 5,000-row\ngrid does not serialise 5,000 rows into the page - the rest arrive when the\nclient measures the viewport and takes over. Hydration is clean because the\nserver and the first client render produce the same markup.\n\nYou can check it yourself on any page:\n\n```bash\nnpm run build && npm run preview\ncurl -s http://localhost:4173/people | grep -c 'data-svgrid-row'\n```\n\nA non-zero count means rows are in the HTML.\n\n> This genuinely did not work before `@svgrid/grid@2.6.8`. Both virtualizers\n> learned their row count from an `$effect`, and effects never run during SSR,\n> so the server emitted an empty `<tbody>`. If you are on an older version and\n> SEO matters, upgrade.\n\n## 4. Sorting that survives a refresh\n\nThe idiomatic SvelteKit move is to keep sort state in the URL. The link is then\nshareable and bookmarkable, the back button works, and the sorted page is\nserver-rendered - so it works with JS disabled too.\n\nSort on the server from the query string:\n\n```ts\n// src/routes/people/+page.server.ts\nexport const load: PageServerLoad = ({ url }) => {\n const sortBy = (url.searchParams.get('sort') ?? 'name') as keyof Person\n const desc = url.searchParams.get('dir') === 'desc'\n return { rows: listPeople(sortBy, desc), sortBy, desc }\n}\n```\n\nThen tell the grid the server owns the ordering, and push header clicks into the\nURL:\n\n```svelte\n<script lang=\"ts\">\n import { goto } from '$app/navigation'\n import { page } from '$app/state'\n\n function onSortingChange(sorting: Array<{ id: string; desc: boolean }>) {\n const next = new URL(page.url)\n if (sorting.length === 0) {\n next.searchParams.delete('sort')\n next.searchParams.delete('dir')\n } else {\n next.searchParams.set('sort', sorting[0]!.id)\n next.searchParams.set('dir', sorting[0]!.desc ? 'desc' : 'asc')\n }\n goto(next, { keepFocus: true, noScroll: true })\n }\n</script>\n\n<SvGrid\n data={data.rows}\n {columns}\n sortable\n externalSort\n initialSorting={[{ id: data.sortBy, desc: data.desc }]}\n {onSortingChange}\n/>\n```\n\n`externalSort` is the important prop: the grid keeps rendering sort indicators\nand cycling on header click, but stops reordering rows itself - because the rows\narriving from `load` are already in the right order. Without it the grid sorts\nthe page a second time, which is wasted work and goes wrong the moment the\nserver is paginating.\n\n`keepFocus` and `noScroll` stop the navigation stealing focus from the header\nyou just clicked or jumping the page to the top.\n\n## 5. Saving an edit through a form action\n\nTurn on editing, then push each committed change at an action:\n\n```ts\n// +page.server.ts\nexport const actions: Actions = {\n rename: async ({ request }) => {\n const form = await request.formData()\n renamePerson(Number(form.get('id')), String(form.get('name')))\n return { success: true }\n },\n}\n```\n\n```svelte\n<script lang=\"ts\">\n async function onCellValueChange(e: { row: Person; columnId: string; newValue: unknown }) {\n if (e.columnId !== 'name') return\n const body = new FormData()\n body.set('id', String(e.row.id))\n body.set('name', String(e.newValue))\n await fetch('?/rename', { method: 'POST', body })\n }\n</script>\n\n<SvGrid data={data.rows} {columns} editable {onCellValueChange} />\n```\n\nThe grid updates its own copy of the row immediately, so the cell shows the new\nvalue while the request is in flight. Handle a rejected save by reloading\n(`invalidateAll()`) or by writing the old value back.\n\nSvelteKit's CSRF protection rejects a cross-origin POST to an action. Browsers\nsend the `Origin` header themselves so this is invisible in an app - but if you\nscript the endpoint from Node or curl, set `Origin` or you will get\n`403 Cross-site POST form submissions are forbidden`.\n\n## The whole thing, end to end\n\nFour commands and three files. Every line below was run from a clean scaffold\nbefore it was published here: `svelte-check` reports 0 errors, the page\nserver-renders its rows, clicking a header rewrites the URL, and an edit\nsurvives a reload.\n\nIf you would rather run it than type it,\n`npm create @svgrid@latest people -- --template sveltekit` gives you this app\nwith a theme picker already wired in. The files below are what it contains.\n\n```bash\nnpx sv create people --template minimal --types ts\ncd people\nnpx sv add \"@svgrid=demo:no+enterprise:no\" --no-download-check\nnpm install\n```\n\n**`src/lib/people.ts`** - stands in for your database.\n\n```ts\nexport type Person = { id: number; name: string; role: string; year: number }\n\nconst people: Person[] = [\n { id: 1, name: 'Ada Lovelace', role: 'Mathematician', year: 1843 },\n { id: 2, name: 'Grace Hopper', role: 'Rear Admiral', year: 1952 },\n { id: 3, name: 'Karen Sparck Jones', role: 'Computer Scientist', year: 1972 },\n { id: 4, name: 'Barbara Liskov', role: 'Computer Scientist', year: 1968 },\n { id: 5, name: 'Margaret Hamilton', role: 'Software Engineer', year: 1969 },\n]\n\nexport function listPeople(sortBy: keyof Person = 'name', desc = false): Person[] {\n const rows = [...people]\n rows.sort((a, b) => (a[sortBy] > b[sortBy] ? 1 : a[sortBy] < b[sortBy] ? -1 : 0))\n return desc ? rows.reverse() : rows\n}\n\nexport function renamePerson(id: number, name: string): void {\n const row = people.find((p) => p.id === id)\n if (row) row.name = name\n}\n```\n\n**`src/routes/people/+page.server.ts`** - sorts from the query string, and takes\nthe edit.\n\n```ts\nimport type { Actions, PageServerLoad } from './$types'\nimport { listPeople, renamePerson, type Person } from '$lib/people'\n\nexport const load: PageServerLoad = ({ url }) => {\n const sortBy = (url.searchParams.get('sort') ?? 'name') as keyof Person\n const desc = url.searchParams.get('dir') === 'desc'\n return { rows: listPeople(sortBy, desc), sortBy, desc }\n}\n\nexport const actions: Actions = {\n rename: async ({ request }) => {\n const data = await request.formData()\n renamePerson(Number(data.get('id')), String(data.get('name')))\n return { success: true }\n },\n}\n```\n\n**`src/routes/people/+page.svelte`**\n\n```svelte\n<script lang=\"ts\">\n import { goto } from '$app/navigation'\n import { page } from '$app/state'\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import type { Person } from '$lib/people'\n\n let { data } = $props()\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', editable: true },\n { field: 'role', header: 'Role' },\n { field: 'year', header: 'Year' },\n ]\n\n // Header click -> URL -> server sorts -> load returns ordered rows.\n function onSortingChange(sorting: Array<{ id: string; desc: boolean }>) {\n const next = new URL(page.url)\n if (sorting.length === 0) {\n next.searchParams.delete('sort')\n next.searchParams.delete('dir')\n } else {\n next.searchParams.set('sort', sorting[0]!.id)\n next.searchParams.set('dir', sorting[0]!.desc ? 'desc' : 'asc')\n }\n goto(next, { keepFocus: true, noScroll: true })\n }\n\n // Committed edit -> form action -> database.\n async function onCellValueChange(e: { row: Person; columnId: string; newValue: unknown }) {\n if (e.columnId !== 'name') return\n const body = new FormData()\n body.set('id', String(e.row.id))\n body.set('name', String(e.newValue))\n await fetch('?/rename', { method: 'POST', body })\n }\n</script>\n\n<h1>People</h1>\n<p>Click a header to sort - the order lives in the URL. Double-click a name to edit it.</p>\n\n<SvGrid\n data={data.rows}\n {columns}\n sortable\n editable\n externalSort\n initialSorting={[{ id: data.sortBy, desc: data.desc }]}\n {onSortingChange}\n {onCellValueChange}\n containerHeight={320}\n/>\n```\n\nThen:\n\n```bash\nnpm run dev # http://localhost:5173/people\n```\n\nThree things to try, in this order:\n\n1. **Click the `Year` header.** The URL becomes `?sort=year&dir=asc`. Copy that\n link into a new tab - it opens already sorted, because the server did it.\n2. **Double-click a name, change it, press Enter, then reload.** The edit went\n through the form action and survived.\n3. **View source, or `curl localhost:5173/people`.** The rows are in the HTML,\n not just injected by JS. That is what a crawler sees.\n\n## 6. Going further\n\n- **Server-side paging and filtering** for large tables: `externalFilter` and\n `externalPagination` are the equivalents of `externalSort` above. See\n [server paging](../help/server/server-paging.md),\n [server filtering](../help/server/server-filtering.md) and the\n [server row model](../help/server/server-row-model.md), which wraps the whole\n request shape rather than wiring each prop by hand.\n- **Owning the markup.** If you need a table only you could write, the headless\n engine is importable on its own from `@svgrid/grid/core` and runs anywhere,\n including inside a load function. See [Why headless?](../why-headless.md).\n- **Themes and dark mode.** Set the theme attribute before first paint from an\n inline script in `app.html`, or the page renders in the wrong palette for a\n frame. The `sveltekit` template above ships that script plus a runtime picker\n built on `resolveThemeTokens` from `@svgrid/grid/themes`, which is the short\n way to see the pattern. [Theme and density](./5-theme-and-density.md) covers\n it properly.\n- **Gating it behind a login.** The `sveltekit` template ships an auth scaffold:\n a cookie session, PBKDF2 password hashing over Web Crypto (so it still runs on\n an edge adapter), and one `PROTECTED` list in `hooks.server.ts` that gates routes\n before any load runs. The role check sits in the form action, not only in the\n UI - hiding an edit button stops nobody from posting the action by hand.\n- **Deployment.** SvGrid is a normal client dependency with no build step or\n server runtime of its own, so any SvelteKit adapter works unchanged.\n\n<!-- tutorial:learn-9-sveltekit -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-9-sveltekit\" data-docs-tutorial=\"learn-9-sveltekit\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-9-sveltekit.mp4\" poster=\"/tutorials/learn-9-sveltekit.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 9: SvelteKit end to end, 81 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-9-sveltekit.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-9-sveltekit.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 9: SvelteKit end to end</strong> (1:21, silent). <a href=\"https://www.youtube.com/watch?v=SQTeHpA7Bys\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>SvelteKit gives you a place to put the query that is already on the server. The grid does not need to know about any of it: it takes an array.</p>\n<p>There is a SvelteKit template, so the whole app is two commands. If you have a project already, installing the package is enough.</p>\n<p>The load function runs on the server. Your database credentials are already there, so the query belongs there too, and the browser never sees it.</p>\n<p>The page takes what that returned and hands it straight to the grid. Type the columns against your row type, as in lesson two, and the compiler checks the field names for you.</p>\n<p>Here is a grid over a real endpoint, which is what your page becomes once the load function points at a database instead of a demo API.</p>\n<p>The part worth knowing: the server HTML contains a viewport of real rows, not an empty container waiting for hydration. A crawler sees data, and a reader sees the table before the JavaScript arrives.</p>\n<p>URL-driven sorting and form actions for edits are on the page below. One lesson to go.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-9-sveltekit -->\n\n## See also\n\n- [Going to production](./6-going-to-production.md) - CSP, SSR, accessibility\n- [First grid](./2-first-grid.md) - the framework-agnostic walkthrough\n- [Why headless?](../why-headless.md) - when to skip the render component\n"
|
|
3972
3996
|
},
|
|
3973
3997
|
{
|
|
3974
3998
|
"slug": "help/accessibility",
|
|
@@ -4066,7 +4090,7 @@ export const docs = [
|
|
|
4066
4090
|
"path": "docs/help/cells/cell-data-types.md",
|
|
4067
4091
|
"title": "Cell data types",
|
|
4068
4092
|
"section": "Cells",
|
|
4069
|
-
"markdown": "# Cell data types\n\nThe `editorType` field on a column tags the column with a type. This drives\nthree different behaviours:\n<div data-docs-demo=\"01-quick-start\" data-height=\"540\"></div>\n\n| Effect | Driven by `editorType` |\n| ------ | --------------------- |\n| Which inline editor opens on `F2` / double-click | yes - `text` / `number` / `date` / `datetime` / `checkbox` |\n| Which sort comparator is used | yes - `number` and `date`/`datetime` pick non-default `sortFns` |\n| Which filter operators the column menu offers | yes - text / number / date / checkbox sets differ |\n\n## Setting it\n\n```ts\nconst columns: GridColumns<Person> = [\n { field: 'firstName', header: 'First', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n { field: 'joinedAt', header: 'Joined', editorType: 'date' },\n { field: 'startTime', header: 'Start', editorType: 'datetime' },\n { field: 'active', header: 'Active', editorType: 'checkbox' },\n]\n```\n\nEven if you do not enable inline editing, set `editorType` so sort and\nfilter behave correctly for the column's data type.
|
|
4093
|
+
"markdown": "# Cell data types\n\nThe `editorType` field on a column tags the column with a type. This drives\nthree different behaviours:\n<div data-docs-demo=\"01-quick-start\" data-height=\"540\"></div>\n\n| Effect | Driven by `editorType` |\n| ------ | --------------------- |\n| Which inline editor opens on `F2` / double-click | yes - `text` / `number` / `date` / `datetime` / `checkbox` |\n| Which sort comparator is used | yes - `number` and `date`/`datetime` pick non-default `sortFns` |\n| Which filter operators the column menu offers | yes - text / number / date / checkbox sets differ |\n\n## Setting it\n\n```ts\nconst columns: GridColumns<Person> = [\n { field: 'firstName', header: 'First', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n { field: 'joinedAt', header: 'Joined', editorType: 'date' },\n { field: 'startTime', header: 'Start', editorType: 'datetime' },\n { field: 'active', header: 'Active', editorType: 'checkbox' },\n]\n```\n\nEven if you do not enable inline editing, set `editorType` so sort and\nfilter behave correctly for the column's data type. Without it, sorting\nfalls back to the `auto` comparator: a column whose values are all numbers\nstill sorts numerically, but numbers stored as strings (`'10'`, `'9'`) sort\nas text, and a column mixing numbers and text puts the numbers first.\n\nEvery built-in comparator sorts blanks (`null`, `undefined`, `''`) first in\nascending order and last in descending. The `number` and `date` comparators\ntreat values they cannot parse (`'n/a'`, an invalid date) the same way.\n\n## `cellDataType` shorthand\n\n`cellDataType` is a higher-level alias that resolves to the right `editorType`,\nalignment, date `format`, and filter operators in one word - so you don't hand-set\neach:\n\n```ts\nconst columns = [\n { field: 'name', cellDataType: 'text' },\n { field: 'age', cellDataType: 'number' }, // number editor, right-aligned, numeric filters\n { field: 'active', cellDataType: 'boolean' }, // checkbox editor, centered\n { field: 'joined', cellDataType: 'date' }, // Date values, `{ type: 'date' }` format\n { field: 'due', cellDataType: 'dateString' }, // ISO date STRINGS ('2026-06-27')\n]\n```\n\n| `cellDataType` | resolves to `editorType` | + format |\n| --- | --- | --- |\n| `text` | `text` | - |\n| `number` | `number` | - |\n| `boolean` | `checkbox` | - |\n| `date` | `date` | `{ type: 'date' }` |\n| `dateString` | `date` | - |\n\nAnything you set explicitly (`editorType`, `align`, `format`) always wins -\n`cellDataType` only fills the gaps.\n\n## Inferring types from the data\n\nSet `inferColumnTypes` on the grid and any column that declares **neither**\n`editorType` **nor** `cellDataType` has its type inferred from the first data\nrow (number / boolean / `Date` / ISO date-string / text):\n\n```svelte\n<SvGrid {data} {columns} inferColumnTypes />\n```\n\n## Built-in types\n\n| `editorType` | accepted value space |\n| ------------ | -------------------- |\n| `'text'` | strings |\n| `'number'` | numbers (or numeric strings) |\n| `'date'` | ISO date strings (`YYYY-MM-DD`) or `Date` |\n| `'datetime'` | ISO datetime strings or `Date` |\n| `'checkbox'` | booleans |\n\n## Custom types\n\nThere is no plug-in \"register a new cell data type\" API. To use a custom\ntype:\n\n- Render with a custom `cell` (see [Cell components](./cell-components.md))\n- Sort with a custom value through `fieldFn` that normalises to a\n comparable primitive\n- Filter with a custom operator UI in your own header component\n\n## Editor value parsing\n\nThe editor receives a string from the DOM and converts to the canonical\nvalue before commit. The implementation lives in\n[`parseEditorValue`](../../../packages/grid/src/editors/cell-editors.ts).\n\n```ts\nimport { parseEditorValue } from '@svgrid/grid'\n\nparseEditorValue('number', '42') // 42\nparseEditorValue('number', 'abc') // NaN - caller should reject\nparseEditorValue('checkbox', 'true') // true\nparseEditorValue('date', '2026-05-27') // '2026-05-27'\n```\n\n## Telling the grid what a string holds\n\nA date that arrives as a string sorts alphabetically unless you say otherwise.\n`cellDataType: 'dateString'` is that declaration, and it changes sorting and the\nfilter operators together.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n joined: string\n active: boolean\n trend: number[]\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000, joined: '2021-03-01', active: true, trend: [4, 6, 5, 9, 12, 11, 15] },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000, joined: '2019-07-15', active: true, trend: [9, 8, 11, 10, 14, 16, 15] },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000, joined: '2020-01-20', active: false, trend: [12, 10, 9, 7, 6, 6, 4] },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000, joined: '2022-09-05', active: true, trend: [3, 5, 4, 8, 7, 11, 13] },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180 },\n // Without cellDataType this sorts as text, which puts 2019 after 2021-03.\n { field: 'joined', header: 'Joined', width: 150, cellDataType: 'dateString',\n format: { type: 'date', options: { dateStyle: 'medium' } } },\n ]\n</script>\n\n<SvGrid data={people} {columns} sortable filterable filterMode=\"menu\" />\n```\n\n\n## Booleans\n\n`cellDataType: 'boolean'` gives the column the true/false filter rather than a\ntext box, and pairs with the checkbox editor so reading and writing agree.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n joined: string\n active: boolean\n trend: number[]\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000, joined: '2021-03-01', active: true, trend: [4, 6, 5, 9, 12, 11, 15] },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000, joined: '2019-07-15', active: true, trend: [9, 8, 11, 10, 14, 16, 15] },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000, joined: '2020-01-20', active: false, trend: [12, 10, 9, 7, 6, 6, 4] },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000, joined: '2022-09-05', active: true, trend: [3, 5, 4, 8, 7, 11, 13] },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'active', header: 'Active', width: 120,\n cellDataType: 'boolean', editorType: 'checkbox' },\n ]\n</script>\n\n<SvGrid data={people} {columns} filterable filterMode=\"menu\" editable />\n```\n\n## See also\n\n- [Provided cell editors](../editing/provided-editors.md)\n- [Filter conditions](../filtering/filter-conditions.md)\n"
|
|
4070
4094
|
},
|
|
4071
4095
|
{
|
|
4072
4096
|
"slug": "help/cells/cell-text-selection",
|
|
@@ -4143,7 +4167,7 @@ export const docs = [
|
|
|
4143
4167
|
"path": "docs/help/cells/spreadsheet-shell.md",
|
|
4144
4168
|
"title": "The spreadsheet shell",
|
|
4145
4169
|
"section": "Spreadsheet",
|
|
4146
|
-
"markdown": "# The spreadsheet shell\n\nEverything between the ribbon and the sheet tabs, as one component.\n\n```svelte\n<script>\n import { SvSheet, createWorkbook } from '@svgrid/enterprise'\n\n const wb = createWorkbook([\n { name: 'Budget', cells: [['Line', 'Jan'], ['Rent', '2400']] },\n ])\n</script>\n\n<SvSheet workbook={wb} />\n```\n\n<div data-docs-demo=\"27-spreadsheet-ribbon\" data-height=\"640\"></div>\n\nThat is the whole required API. `<SvSheet />` with no props at all opens an\nempty single-sheet workbook, which is what \"open a spreadsheet\" means.\n\nThis page is the reference: every prop, every dialog, the file formats\nand the limits. The tutorials walk through the same ground a task at a\ntime, with a live sheet under each step: [Getting started](../sheet/start.md),\n[Formulas](../sheet/formulas.md), [Formatting](../sheet/formatting.md),\n[Data validation](../sheet/validation.md), [Files](../sheet/files.md),\n[Data tools](../sheet/data-tools.md),\n[Charts, sparklines and pictures](../sheet/charts-and-objects.md) and\n[Review and share](../sheet/review.md).\n\n<!-- tutorial:install-spreadsheet -->\n<figure class=\"docs-tutorial\" id=\"tutorial-install-spreadsheet\" data-docs-tutorial=\"install-spreadsheet\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/install-spreadsheet.mp4\" poster=\"/tutorials/install-spreadsheet.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Install the SvGrid spreadsheet, 56 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/install-spreadsheet.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/install-spreadsheet.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Install the SvGrid spreadsheet</strong> (56 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>The spreadsheet ships in the enterprise package. Install it next to the grid; it runs unlicensed for evaluation.</p>\n<p>A workbook is plain data: sheets with a name and a cells matrix. Formulas are strings that start with an equals sign.</p>\n<p>Hand it to SvSheet. That is the whole API.</p>\n<p>The result is the full surface: a ribbon, the name box and formula bar, lettered columns, sheet tabs and a status bar, with the totals already computed.</p>\n<p>It behaves like a spreadsheet. Type a formula into a cell, press Enter, and the value appears while the formula bar keeps the source.</p>\n<p>Every Excel shortcut, fill handle, number formats, conditional formatting and xlsx import and export come with it. The docs list the whole surface.</p>\n</details>\n</figure>\n<!-- /tutorial:install-spreadsheet -->\n\n## What you get\n\n| Part | Comes from |\n| --- | --- |\n| Ribbon: File, Home, Insert, Formulas, Data, Review, View | `SvSheetRibbon` |\n| Name Box and fx bar, showing the RAW text of the active cell | `SvFormulaBar` |\n| A..Z headers over the built-in 1..N row gutter | `SvGrid` |\n| Sheet tabs: switch, rename, reorder, add, duplicate, hide and unhide | `SvSheetTabs` |\n| Sum / Average / Count of the selection | the shell |\n| Every Excel shortcut | `enableSheet()` |\n\nThis is composition, not a second grid. All of it already existed and was\nbeing re-assembled by hand in four separate demos. What the component adds is\nthe wiring BETWEEN the parts, done once:\n\n- The formula bar shows the raw text while the grid shows the computed value.\n Editing in the bar edits the formula, not its result.\n- The format store is keyed by row id, so sorting cannot strand formatting on\n whatever row took that index.\n- Insert and delete go through the workbook, so references are rewritten on\n every sheet rather than only the active one.\n- The fill handle translates references, so `=B2` filled down becomes `=B3`.\n- The tab strip is told when a shortcut moved the active sheet, so\n `Ctrl+PageUp` and the tabs cannot disagree.\n- AutoSum measures its run against evaluated values, so a column of subtotals\n counts as numbers rather than as `\"=SUM(...)\"` strings. With one cell\n selected it proposes the `=SUM(...)` in the cell's editor, as Excel's\n does, so a guess that stopped a row short is corrected before Enter takes\n it; a wider selection has the sum written outright.\n- Ctrl+Z and Ctrl+Y, and the ribbon's Undo and Redo, go to the sheet the\n change was made on: a step made on Orders and undone from Summary brings\n Orders up and changes the cell back there, as Excel does, rather than\n landing on Summary's cell of the same address. Every step the grid\n records is marked with its sheet for this (`api.setHistoryTag` and\n `api.peekUndo` on the grid), the mark follows a rename, and Delete\n Sheet empties the undo list, as Excel's does. A click that selects a\n chart or a picture is not a step; a drag that moves one is.\n- A click on a sheet tab, on the strip's add button or on an entry of the\n tab menu leaves the keyboard on the cells, so the arrow keys, Ctrl+Z and\n Shift+F11 work on the sheet that just came up. The arrow keys inside the\n strip keep the focus on the tabs, as a tablist's do.\n- Formats are kept per sheet, as they are in Excel. Bold on Summary!C5 says\n nothing about Orders!C5, and the store follows a sheet through a rename.\n- The Name Box lists the workbook's defined names. Pick one and the shell\n selects what it refers to, switching sheets first when it lives elsewhere;\n type an address and press Enter to go there; type a name that is not one\n yet and it is defined for the selection, sheet-qualified and absolute as\n the Name Manager writes one, one undo. Either way focus comes back\n to the sheet, so the next keystroke edits the cell just reached. Beside it\n sit Excel's three: Cancel and Enter while a formula is being typed, and\n fx, which raises `insert-function`.\n- A formula-bar entry lands in the cell the edit started in, however the\n edit ends: Enter, or a click on another cell. It is one step in the\n grid's history, so Ctrl+Z takes it back.\n- Enter after an in-cell edit commits and moves down, Shift+Enter up, Tab\n right, as in Excel. Alt+Enter is a line break: the cell turns on Wrap\n Text and its row grows to show every line, as a wrapped row does after\n Wrap Text on the ribbon or in Format Cells; a row the fitting grew is\n back to its height once the wrap goes or the text gets shorter, while a\n row sized by hand, dragged or typed into Row Height..., stays as the\n user left it, which is Excel's auto-height rule. F4 while editing turns the\n reference at the caret through `$A$1`, `A$1`, `$A1` and back.\n- AutoComplete, as Excel's: while a cell is typed into, the text entries in\n the same column that run without a blank above and below it are matched\n against what was typed, and when exactly one fits, the rest of it appears\n selected in the cell. Typing on overwrites it, Backspace takes it away,\n and Enter or Tab accepts it in the entry's own case, so \"jo\" under \"John\n Smith\" commits \"John Smith\". Numbers, formulas and booleans are never\n offered, and two entries that both fit (\"Apple\", \"Apricot\" for \"Ap\")\n offer nothing until the typing tells them apart. `completeEntry(typed,\n entries)` is the rule on its own.\n- Formulas are typed the way Excel's are. Pointing: a click on a cell while\n a formula is being typed, in the cell or in the bar, puts that cell's\n address in at the caret rather than ending the edit, a drag puts a range,\n and a second click replaces what the last one put there; typing goes on\n from there. Formula AutoComplete: the function names a typed prefix could\n be are listed under the cell, Up and Down walk them, Tab or Enter takes\n one with the caret inside its brackets, Escape closes the list. Backspace\n on a cell opens the editor empty, so Escape gives the value back where\n Delete would have blanked it.\n- Data entry steps the way Excel's does: the Enter that ends a run of Tabs\n goes down from the column the run began in, and inside a selected block\n Enter and Tab walk the block and wrap at its edges while the block stays\n selected. Ctrl+Enter puts the entry into every cell of the block at once,\n each taking its value and format as if typed there, as one undo. A drag, Shift+click or Shift+Arrow grows the selection from\n its far corner and leaves the active cell at the anchor, white inside the\n tinted range, and the formula bar shows that cell.\n- The formula bar follows an in-cell edit as it is typed, and the cell\n follows the bar; both go back when the edit is cancelled.\n- The formula bar keeps a cell's line breaks. It shows one line until the\n chevron at its end expands it to every line the text takes at the bar's\n width, whether from a line break or from wrapping, up to six, with a\n scrollbar past that; Alt+Enter typed in the bar breaks the line there and\n expands the bar.\n- A typed `12%` is the number 0.12 shown as a percentage, `$1,200` is 1200\n shown as currency, `1,234.5` keeps its separator: the entry names a value\n and a format, and the cell takes both unless it already has a number\n format of the same kind (a time typed into a currency cell makes it a\n time cell, as it does in Excel; `5%` typed into a `0.0%` cell keeps the\n cell's decimals). `=A1*2` over a `12%` cell is 0.24. A plain number\n typed into a cell that already shows percentages is that percentage, so\n `5` in a `0%` cell is 5% rather than 500%, as Excel's automatic percent\n entry has it; `(5)` is -5, the way a statement writes a negative. A mixed\n fraction with a whole part, `3 1/2`, is 3.5 shown back as a fraction, and\n `0 1/2` is how you enter a bare half without `1/2` becoming the second of\n January; `=A1*2` over `3 1/2` is 7.\n- A date typed the American way, `3/4/2026`, `3/4/26` or `3/4`, or with\n the month spelled out, `4-Mar-2026`, `4 Mar 26`, `March 4, 2026` or\n `Mar 4` (English month names, in either order), is the sheet's\n `2026-03-04` under a date format, and a clock time, `10:30`,\n `6:00:00` or `10:30 PM`, is the fraction of a day it is under a time\n format, so the times add up. In arithmetic a date written as text is its\n day number, as a date cell is in Excel: `=A1+1` under a date is the next\n day rather than `#VALUE!`, and `=B1-A1` over two dates is the days\n between them.\n- A formula typed into a cell with no format takes the number format of\n the first cell it reads, as Excel's does: `=A1*2` under `$5.00` is\n `$10.00`, `=SUM(B2:B9)` over currency is currency, `=A1+1` under a date\n is a date and `=TODAY()+7` is a day. A formula whose function returns a\n count or a part (COUNT, LEN, YEAR and the rest) stays General, and so\n does one date taken from another, which is a number of days, the one\n exception Excel makes too.\n- File > Open reads what the other spreadsheets write, not only what this\n one wrote: a sheet's own `<cols>` or a table's filter, a row height\n LibreOffice left unflagged, `General` as the absence of a format, a row\n or a cell that carries no position of its own, an error or a boolean\n saved as a formula. Save As writes a package Excel, Google Sheets and\n LibreOffice all open; a rule it cannot spell is left out rather than\n written badly, since a file with one bad element in it is a file Excel\n offers to repair.\n- A sort reorders the rows you can see and leaves the hidden ones where\n they are, as Excel's does, so sorting a filtered list cannot drag a\n filtered-out row into view or overwrite what one holds. The status bar\n says how many stayed put. A row takes its formulas along the way a copy\n would: the relative references shift to the row's new place and the\n absolute ones stay, so `=SUM(B5:E5)` on a row that lands in row 2 reads\n `B2:E2`, and an `=IMAGE(C5)` thumbnail still shows its own row's picture.\n- `SUBTOTAL` follows the filter: `=SUBTOTAL(9, C2:C99)` under a filtered\n list totals the rows that matched, and the 101-111 codes leave out rows\n hidden by hand too. That is what a table's totals row should be written\n with; `SUM` counts everything, as it does in Excel.\n- The status bar's Average, Count and Sum are of the cells you can see: a\n filtered-out row is not in the total, which is what makes the bar worth\n reading over a filtered block. Delete and Clear Contents leave such a row\n as it is, so clearing a filtered selection cannot wipe what the filter\n hid.\n- A copy leaves out the rows a filter folded away, and the block closes up\n around them, so filtering a log and copying the block gives the rows that\n matched with nothing between them. A row hidden by hand with Hide Rows\n IS copied, as Excel copies one (the grid's `includeCollapsedRows` prop is\n how the shell tells the two apart); a hidden column is left out. The\n merges inside the block travel with a paste of everything or of the\n formats, laid over the landing from its corner, and a merged cell copied\n on its own is the whole merge, as it is in Excel.\n- Ctrl+X marks the block rather than emptying it, as Excel's cut does: the\n cells stay where they are until the paste lands, Escape leaves the sheet\n as it was, and one Ctrl+Z puts a whole move back. The ribbon's Cut and\n the menu's mean the same thing.\n- Cut and paste MOVES the cells, so every formula that read them follows:\n moving A1 to D1 rewrites `=A1*2` as `=D1*2`, across every sheet and the\n defined names, the way Excel does. A `$` makes no difference, since the\n cell itself moved; a range follows only when all of it moved, so\n `=SUM(A1:A2)` stays as it is when A1 alone goes. A copy translates\n relative references by the distance pasted, as before.\n- A typed error code is that error, as it is in Excel: a cell holding\n `#N/A` answers TRUE to `=ISNA(A1)` and carries the error into anything\n that reads it, so a placeholder row reads as missing rather than as the\n text that spells it. Case does not matter, and `'#N/A` is the text.\n- A leading apostrophe says the rest is text, as it does in Excel: `'007`\n is the text `007`, leading zero kept, and `'=A1+1` shows the formula\n rather than working it out. The apostrophe is not part of the value, so\n it is never displayed, printed, exported or compared; the formula bar\n shows it, since it is what was typed. A cell that comes back from an\n .xlsx as text keeps its prefix, so a part number survives the round trip.\n- The fill handle fills the way Excel's does: a lone value repeats, `1, 2`\n continues, `Jan` runs on to `Feb`, and a formula moves every relative\n reference by the distance dragged. Ctrl held at the release turns it the\n other way, a series into a copy and one number into a series. Double-click\n it to fill down as far as the column beside the selection has data. A number too wide for its\n column reads as `####` until the column fits it; `TRUE` and `FALSE`\n typed into a cell are booleans, centred.\n- Freeze Panes freezes everything above and left of the active cell, both\n halves, per sheet: the rows stay under the column letters and the columns\n beside the row numbers while the rest scrolls, and every frozen cell is\n still an ordinary cell. Unfreeze clears both.\n- A dragged column width or row height is one Ctrl+Z, and double-clicking\n a row number's lower edge fits the row to its wrapped text, as a\n double-click on a column letter's edge fits the column to its text.\n- Row heights are the sheet's: a row dragged taller stays that way through\n every edit, moves with its row when rows are inserted or deleted above it,\n comes back with an undo, and belongs to one sheet. The fill handle's drag\n is one undo, like Ctrl+D. Column widths move with their columns the same\n way.\n- Hide and Unhide, on the column letter and row number menus and on Ctrl+9\n (rows), Ctrl+0 (columns), Ctrl+Shift+9 and Ctrl+Shift+0: a hidden line\n keeps its letter or number and every reference to it, and takes no room,\n so the headers read A, B, D. Unhide reveals what the selection spans, or\n the hidden lines right next to it when it spans none. The cursor steps\n over hidden lines, Ctrl+Arrow lands on the last cell of a run that shows\n rather than its hidden end, and the Name Box can still reach a hidden\n cell; a hide is one Ctrl+Z, moves with an insert or delete, and belongs\n to its sheet.\n- Text spills over empty neighbours and is clipped by the first cell that\n holds something, so a title in A1 reads in full and a label beside a\n number does not run under it. Numbers never spill. The text runs only\n over neighbours with the same background as its own cell: a title on a\n banded row reads across the band, and a filled cell beside an unfilled\n one (or the other way round) keeps its text in its cell, since the run\n would drag one colour over the other.\n- General alignment: numbers right, text left, errors and booleans centred,\n unless the cell says otherwise.\n- The headers of every column and row the selection touches are shaded and\n marked with a line in the accent, the Select All corner sits top-left,\n and the status bar reads Ready on the left and Average / Count / Sum on\n the right.\n- Sheet tabs delete, insert, rename, move, duplicate, hide and unhide from\n a right-click menu, as in Excel; nothing on the tab face can remove a\n sheet, and deleting one that holds data asks first. A hidden sheet keeps\n its cells and every reference to it, Ctrl+PageUp and PageDown step over\n it, and the flag rides in the document. Duplicate copies the cells and\n everything the document keeps beside them, and the copy's table, charts,\n pictures, sparklines and PivotTables are its own: each gets a fresh name\n or identity, so working on the copy leaves the sheet it came from alone.\n- Click a column letter to select the column, a row number to select the\n row, the corner to select the sheet; Shift+click extends. Drag the edge of\n a row number to resize the row, as with a column letter.\n- Copy puts two things on the system clipboard: the text each cell shows,\n which is what a text editor expects, and an HTML table with the formats,\n the numbers behind the formatted text and the formulas, written the way\n Excel writes its own (`mso-number-format`, `x:num`), so a paste into\n Excel or Google Sheets keeps the number format, the bold and the fill. A\n paste back into the sheet gets the formula, moved by the distance it\n travelled (`=A1*2` copied from B1 to B3 reads `=A3*2`), and the cell's\n format, as Excel pastes. A cut block is moved, not translated. The block\n Ctrl+C or Ctrl+X took is outlined by Excel's marching ants until Escape,\n a typed entry, a structural edit, the paste of a cut, or the next copy; a\n Ctrl+V paste of a copy keeps them, so the block can be pasted again, and\n Enter pastes it once and drops them, as Excel's Enter does. The Copy and\n Cut buttons on the ribbon and in the cell menu are the same commands as\n the keys.\n- Paste reads what the clipboard carries. A block copied from Excel arrives\n with its values (the number, not the text `1,234.50`), its number\n formats, fonts, colours and fills, and its formulas: Excel's HTML says\n nothing about where a block was copied from, so a formula whose\n references all fall inside the block is kept and moved with the paste as\n if the block came from A1 (a totals column keeps totalling), and one that\n reaches outside the block lands as its value. A block from Google Sheets\n the same, with its R1C1 formulas turned into A1 for the cells they land\n in. Plain text lands as typed. One copied cell fills the\n selected range, and a block that fits the selection a whole number of\n times is repeated over it, as in Excel. One paste is one Ctrl+Z, formats\n included.\n- Selecting works as it does in Excel. A click on a column letter or a\n row number selects the whole line and puts the active cell on its first\n visible row or column; a drag along the letters or the numbers selects a\n run; Shift+click extends the run from the active cell's line; Ctrl+click\n adds a line beside the selection; the corner selects everything and\n leaves the active cell where it was. Shift+Arrow grows a run of whole\n lines, an arrow collapses to the active cell, and typing lands in the\n active cell, so the keyboard never leaves the sheet. Shift+Space and\n Ctrl+Space take the whole rows or columns the selection touches, Ctrl+A\n the current region then the sheet, Ctrl+Shift+8 the region alone. A\n column or row selected whole paints its letter or number in the accent,\n white on green as Excel's; a line the selection merely touches is\n tinted. While a range is dragged out the Name Box reads \"3R x 2C\".\n- The pointer reads as it does in Excel: the white cross over the cells,\n a black arrow down a column letter or along a row number, the\n double-headed arrows on the borders, the crosshair on the fill handle.\n Dragging a border shows Excel's tip beside the pointer, \"Width: 8.43 (64\n pixels)\" or \"Height: 15.00 (20 pixels)\", with a dotted guide across the\n sheet at the new edge; dragging the fill handle shows what the cell\n under the pointer will get (Feb, 7, 2026-09-18), in the source cell's\n number format.\n- A General cell shrinks a number to its column before it hashes, as\n Excel's does: the decimals go first (5.333333 reads 5.3333, then 5.33),\n then a whole part too wide turns scientific (1.76E+04). A formatted\n number hashes as soon as its format does not fit, since the format said\n what to show.\n- Right-click a column letter or a row number for its menu: the column or\n row is selected first, unless it was already inside the selection, and the\n menu offers Insert, Delete and Column Width... or Row Height... for that\n axis alone, as Excel's does.\n- Right-click a cell for Excel's menu: Cut, Copy, Paste, Paste Special,\n Insert / Delete Rows and Columns, Clear Contents, Clear Formats, Merge &\n Center or Unmerge, the comment entries, Format Cells, each with the\n ribbon's icon for it so the two read as one set. Structure goes through\n the sheet's own insert and delete, which rewrite every formula the\n change reaches, move the formats with their cells, and undo as one step.\n- The in-cell editor opens with the caret after the text and widens over\n the cells to its right as a long formula is typed. While a formula is\n being written, in the cell or in the formula bar, every range it refers\n to is outlined in its own colour, one rectangle per range, and the\n reference in the text takes the same colour, in the cell and in the bar:\n Excel's range finder. Colours go by distinct range in order of first\n appearance, so `B5:B8` twice is one colour and `b5`, `$B$5` and `B5` are\n one range. `referenceSpans(text)` is the rule, exported.\n\n## The ribbon is Excel's ribbon\n\nThe layout is Excel's, down to the geometry: a group is three rows of 24px,\na **large** button (icon over label) fills a column of that height and\n**small** buttons stack three to a column, so Cut / Copy / Format Painter\nline up beside a tall Paste, as icons alone the way Excel draws them. The\nHome tab runs Clipboard, Font, Alignment, Number, Cells, Styles, Editing in\nExcel's order, with Excel's names on the buttons (Undo and Redo open the\nEditing group as a column of small icons), a dialog-box launcher in the corner of the groups that have a\ndialog, and the group label underneath, which is what makes a ribbon read\nas a ribbon rather than a toolbar.\n\nSome things worth knowing are in there because Excel users reach for them\nwithout looking:\n\n- **Paste is Excel's split button.** The icon pastes; \"Paste\" with its\n chevron under it opens the kinds: Paste, Formulas, Values, Formatting,\n Transpose, and Paste Special... at the end, each raised as an action\n (`paste-formulas`, `paste-values`, `paste-formats`, `paste-transpose`,\n `paste-special`) that the shell answers through its Paste Special engine.\n- **Format Painter is the brush.** Select the cells whose look you want,\n press the brush, and the next click or drag takes it, as one undo: a\n single cell takes the whole block from that corner, a dragged range has\n the block tiled over it, and a cell whose source had no format loses its\n own, as in Excel. The button reads pressed while the brush is up, the\n pointer is a brush over the cells, Enter paints onto the current\n selection, and Escape or the button again puts it down. Raised as\n `format-painter`.\n- **Fill Colour, Font Colour and Borders are split buttons.** The face\n re-applies the last pick (yellow and red to start, as in Excel); the arrow\n opens the menu. The colour menus are Excel's picker: the theme row, five\n tints and shades under each colour, then the standard colours. Borders\n applies to the *selection* the way Excel does: Bottom Border lines the\n bottom edge of the block, All Borders lines every cell, Outside frames it.\n- **The combos show the cell.** Font, size and number format read the\n active cell, so a 17px title says 17 even though 17 is not on the list.\n- **Every format change is one Ctrl+Z**, through the grid's own history,\n so the ribbon's Undo lights up after Bold and undoing it puts the cell's\n whole entry back. A command that writes cells and formats together is\n still one press.\n- **Paste pastes.** It reads the system clipboard the way Ctrl+V does, so\n it needs a secure context; elsewhere it stays quiet, as Ctrl+V does.\n\n**It is responsive the way Excel's is, in Excel's two steps.** When the\nband is narrower than its groups, the groups go compact from the right\nfirst: their small buttons keep the icon and drop the label, so Fill Down\nbecomes an arrow and Wrap Text a glyph, with the title still in the\ntooltip. Only when every group is compact and the band is still too\nnarrow do groups fold, again from the right, into one large button each\n(the group's own icon over its name and a chevron) that opens the group in\na dropdown. Nothing is hidden, only folded; widen the window and it all\ncomes back in the same order. Every width is measured off an invisible\ncopy of the band, never estimated, so a group folds exactly when it would\nnot fit. On a phone the folded band scrolls sideways.\n\n**It collapses the way Excel's does.** Double-click a tab, press the\nchevron at the band's end, or Ctrl+F1, and the band goes away leaving the\ntab row. A click on a tab then shows that tab's band over the sheet until\na command runs, Escape, or a click elsewhere; the pin at its end, a\ndouble-click or Ctrl+F1 brings the band back for good. The state is the\n`collapsed` prop of `SvSheetRibbon`, bindable, and the action is raised\nas `toggle-ribbon` so `onAction` can watch or take it over.\n\n## The ribbon and the keyboard are the same thing\n\nClick a bold cell and the Bold button lights up. Press `Ctrl+B` and it goes\nout. Neither is reacting to the other: both call one function in\n`sheet/shortcuts.ts`, and the button's pressed state is read from the format\nstore rather than tracked separately.\n\nThat is why `sheet/ribbon.ts` is a table of data rather than markup. An item\neither carries a `run` that calls a real action, or an `emits` naming\nsomething the ribbon cannot do alone:\n\n```ts\n{ id: 'bold', label: 'B', title: 'Bold', keys: 'Ctrl+B',\n kind: 'toggle',\n run: (cmd) => toggleFormat(cmd, 'bold'),\n isOn: (cmd) => everyCellHas(cmd, (e) => e?.bold === true) }\n```\n\nButtons that open a dialog set `emits` and raise an action. Every action\nreaches your `onAction` handler first; return `true` to take it over,\notherwise the shell's own dialog opens:\n\n```svelte\n<SvSheet\n {wb}\n onAction={(action, cmd) => {\n if (action === 'goal-seek') { openMyGoalSeekDialog(cmd); return true }\n }}\n/>\n```\n\nThe shell answers everything on the ribbon itself: `recalculate`,\n`toggle-formulas` (Excel's `Ctrl+` `` ` ``), `toggle-filter`, `sort-asc` /\n`sort-desc` / `sort-custom`, and the dialogs listed below. One button has\nnothing behind it in the library, Insert > Table, and is left off the\nribbon unless `extras={['insert-table']}` says the application answers it.\n\n## Props\n\n| Prop | Default | What it does |\n| --- | --- | --- |\n| `document` | one built from `workbook` or `data` | The sheet document: the workbook plus every sheet's formats, sizes, hidden lines, frozen panes, comments, protection, validation and conditional formatting. Build it with `createSheetDocument` to save, restore or listen to it outside the component. |\n| `workbook` | a new empty one | The workbook to edit, when not supplying a document. |\n| `data` | | Seed sheets, when not supplying a workbook. |\n| `rows` / `columns` | 50 / 12 | Minimum size, so a sparse sheet still looks like a sheet. |\n| `height` | 420 | Grid viewport height. |\n| `columnWidth` | 96 | Default column width. |\n| `rowHeight` | 22 | Row height. Excel's is 20 at 100%; 22 fits the 13px cell font. |\n| `look` | `'theme'` | `theme` paints the shell with the host's `--sg-*` tokens, so it follows the app's theme; `excel` pins Excel's own palette and geometry, light or dark with the page. |\n| `columnWidths` | | Per-column overrides keyed by letter: `{ A: 150 }`. |\n| `formats` | | Cell formats keyed by address: `{ B2: { bold: true } }` for the sheet active at mount, `{ 'Orders!F2': ... }` for another sheet. |\n| `showRibbon` / `showFormulaBar` / `showTabs` / `showStatusBar` | `true` | Hide any part of the chrome. |\n| `onAction` | | Every ribbon action, first; return `true` to take one over. |\n| `extras` | `[]` | Whether to show Insert > Table, because the application answers it. |\n| `commentAuthor` | | Who signs a new comment and a reply, with the time; unset, a new comment is a plain note. |\n| `localization` | | `{ locale, text }`: the language of the chrome. `text` overrides any of the shell's strings (see [Localisation](#localisation)); `locale` formats the status bar's numbers and reaches the grid underneath. |\n| `onReady` | | The `SvGridApi` and the document, once the grid has mounted. |\n| `onChange` | | Every change the user lands, once per tick: `cells`, `formats`, `sizes`, `hidden`, `freeze`, `sheets`, `comments`, `protection`, `page-setup`, `objects`, `validation`, `conditional-formats`, `structure` (with the insert or delete), `restore`. Undo and redo report too. |\n\n`formats` matters more than it looks: without it a sheet can only be\nformatted by hand after it loads, which makes it impossible to ship a\ndocument that opens the way it was saved.\n\nA bare address formats the sheet that is active when the shell mounts.\nQualify it to reach another sheet, the way a formula would - `Orders!F2`,\nor `'Price list'!C2` when the name has a space:\n\n```svelte\n<SvSheet\n workbook={wb}\n columnWidths={{ A: 150 }}\n formats={{\n A1: { bold: true, fill: '#e2e8f0', color: '#0f172a' },\n E2: { numFmt: '#,##0' },\n B13: { numFmt: '0.0%' },\n 'Orders!F2': { numFmt: '$#,##0.00' },\n }}\n/>\n```\n\n<div data-docs-demo=\"456-sales-report-workbook\" data-height=\"600\"></div>\n\n## Files\n\nThe File tab is the part of Excel's a document in a page can do: New,\nOpen, Save As, Save As ODS, Save As XLS, Export CSV and Print. Open takes an\n.xlsx, an .ods, an .xls or a .csv from disk - the kind is read from the\nbytes, not from the name - and replaces\nthe document with everything the file holds that the document keeps\n(cells with their formulas, formats, widths and heights, hidden lines and\nsheets, frozen panes, merges, the filter with its criteria and the rows it\nfolds, validation, conditional\nformatting, protection, comments, names, the active sheet); Save As\ndownloads the document as an .xlsx that Excel, Google Sheets and LibreOffice\nopen with the same parts; Save As ODS writes the same document in\nOpenDocument, the format LibreOffice Calc saves by default and one of the\nfiles Google Sheets hands back, with the cells, their formulas in ODF's own\nreference grammar, the number formats, the looks, widths and heights, merges,\nhidden lines, links and notes; Save As XLS writes an Excel 97-2003 workbook,\nthe binary .xls that every Excel since 1997 opens and that a bank or an ERP\nexport still often is; Export CSV downloads the active sheet as its cells\nshow, and Open reads such a file back: the separator is guessed, so a comma,\na semicolon or a tab file all read as themselves, a quoted field keeps its\ncommas and line breaks, and a field that reads as a percentage, a currency\namount or a grouped number lands as the number with the format it implies.\nA CSV field is never read as a formula, as it is not in Excel;\nNew starts over with one empty sheet, asking first when the sheets hold\nanything; Print (Ctrl+P) opens the active sheet in the browser's print\ndialog as its Page Layout says (below). `documentToXlsx` and\n`documentFromXlsx` are the two halves, in `@svgrid/enterprise/sheet`, and\nneed the `jszip` peer; `documentToOds` and `documentFromOds` are the same\npair for OpenDocument; `documentToXls` and `documentFromXls` are the pair for\nthe old binary format, and need nothing, since an .xls is not a zip; and\n`documentFromFile` takes any of the four and decides by the bytes. `csvText`\nand `sheetStateFromCsv` are the CSV pair, and need nothing either.\n\nWhat an .ods carries is the document this package keeps, not every corner of\neither format: cells and their formulas (translated both ways, so\n`=SUM(A1:A3)` here is `of:=SUM([.A1:.A3])` there, and a structured reference\nbecomes the rectangle it names, since ODF has none), number formats, the cell\nlooks, column widths and row heights, merges, hidden rows and columns, the\nfilter with its values and conditions (a date period or a colour has no ODF\nspelling, so its rows go out hidden instead), hyperlinks, notes, defined\nnames and sheet protection. Charts,\nimages, sparklines, validation, conditional formatting and frozen\npanes travel in the .xlsx and not yet in the .ods. A PivotTable travels\nin every format as the cells it wrote, never as a definition: the .xlsx\nhas no pivot part, so Excel sees the block, not a pivot to refresh.\n\nThe .xls is the oldest of the three and carries the least. Cells and their\nformulas travel, with the formulas as the RPN tokens Excel 97 stored rather\nthan as text: a function that format never had (XLOOKUP, LET, TEXTJOIN, a\nstructured reference) has no token, so such a cell goes out as the value it\nworked out, and a workbook opened from an .xls reads those the same way\nround - Excel writes `_xlfn.XLOOKUP` as a defined name and calls it, and\nthat name turns back into the function here. Number formats, fonts, colours,\nfills, borders, alignment, column widths, row heights, hidden rows, columns\nand sheets, merges, frozen panes, sheet protection and defined names travel;\ncharts, pictures, sparklines, validation, conditional formatting and\ncomments do not, since the format kept them somewhere this reader does not\ngo. A filter goes out as the rows it folds, hidden, and nothing else: the\nfile shows the same rows, and the arrows do not come back from it. The sheet is 65,536 rows by 256 columns, and anything past that edge is\nleft out rather than written wrong.\n\nAn app that keeps its workbooks somewhere other than the user's disk takes\nthe actions over through `onAction` (`file-open`, `file-save-xlsx`,\n`file-save-ods`, `file-save-xls`, `file-new`, `file-export-csv`,\n`file-print`) and calls the component's\nown methods: `open(file)` replaces the document with an .xlsx, .ods, .xls or\n.csv Blob, `toXlsx()` returns the document as a Blob, `toOds()` the same\ndocument in OpenDocument, `toXls()` it as an Excel 97-2003 workbook,\n`toCsv()` the active sheet as text, `newWorkbook()` empties it, `print()` opens the print dialog and\n`printHtml()` returns the page it would print, for an app that prints\nits own way.\n\n```svelte\n<SvSheet bind:this={sheet} onAction={(action) => {\n if (action === 'file-save-xlsx') { sheet.toXlsx().then((blob) => upload(blob)); return true }\n if (action === 'file-open') { pickFromServer().then((blob) => sheet.open(blob)); return true }\n}} />\n```\n\nDates are `yyyy-mm-dd` text in the sheet and serial numbers in the file:\na text date goes out as a serial under a date format, and a serial under a\ndate format comes back as text. Tables, charts and images are not carried\neither way.\n\n## Page Layout and Print\n\nThe Page Layout tab is Excel's Page Setup group and Sheet Options, per\nsheet and part of the document: Margins (Normal, Narrow, Wide),\nOrientation, Size (Letter, Legal, Tabloid, A3, A4, A5), Print Area (Set\ntakes the selection, Clear puts the whole sheet back), Print Titles (the\nrows that repeat at the top of every page), the group's launcher opening\nthe Page Setup dialog with all of it and the scale, and Print Gridlines\nand Print Headings. Each change is one undo and reports\n`{ kind: 'page-setup' }`.\n\nFile > Print (Ctrl+P) lays the sheet out as one HTML document and hands it\nto the browser's own print engine, so fonts, CJK and RTL come out right\nand nothing is bundled: the print area or the used range, column widths\nand row heights as the sheet shows them, hidden lines and the rows a filter\nfolds away left out, merges as\nspans, every cell as it shows with its format and its conditional style,\nthe title rows repeated on each page, the sparklines drawn in their cells\nand the charts and pictures hung from theirs, a table's header and banding\nin the colours it wears on the sheet, gridlines and headings when\nasked, and the orientation, paper and margins in `@page`. \"Save as PDF\" in that\ndialog is the PDF. The setup rides in `getState()` as `pageSetup` and in\nthe xlsx as `pageSetup`, `pageMargins`, `printOptions` and Excel's own\n`Print_Area` and `Print_Titles` names, both ways.\n\n## Saving and restoring\n\nEverything the user does lands in the sheet's document: the workbook (raw\ntext, formulas as typed, names, the active sheet), and per sheet the\nformats (locked flags included), column widths, row heights, hidden lines,\nfrozen panes, comments, the protection flag with its allow list and edit\nranges, the page setup, the charts and pictures on the sheet, the\nvalidation rules and the conditional formatting rules.\n`getState()` returns it as plain JSON and\n`setState()` puts it back into the same workbook object, so an autosave\nis an `onChange` handler and a reload is one call after mount:\n\n```svelte\n<script lang=\"ts\">\n import { SvSheet, type SheetState } from '@svgrid/enterprise'\n\n let sheet = $state<SvSheet>()\n let timer: ReturnType<typeof setTimeout> | undefined\n\n function autosave() {\n clearTimeout(timer)\n timer = setTimeout(() => {\n if (sheet) localStorage.setItem('budget', JSON.stringify(sheet.getState()))\n }, 400)\n }\n\n const saved = localStorage.getItem('budget')\n</script>\n\n<SvSheet\n bind:this={sheet}\n onChange={autosave}\n onReady={() => { if (saved) sheet?.setState(JSON.parse(saved) as SheetState) }}\n/>\n```\n\n### Autosave to a server\n\nThe same handler with a `fetch` in it, plus the three things a network\nmakes you think about:\n\n```ts\nlet timer: ReturnType<typeof setTimeout> | undefined\nlet saving: Promise<unknown> = Promise.resolve()\nlet revision = 0\n\nfunction autosave() {\n clearTimeout(timer)\n // Coalesce a burst of edits into one request. onChange already coalesces\n // per tick; this coalesces per pause in the typing.\n timer = setTimeout(() => {\n const state = sheet!.getState()\n // One request at a time, in order: a save that overtakes its\n // predecessor writes an older document over a newer one.\n saving = saving.then(async () => {\n const response = await fetch(`/api/books/${id}`, {\n method: 'PUT',\n headers: { 'content-type': 'application/json', 'if-match': String(revision) },\n body: JSON.stringify(state),\n })\n if (response.status === 412) return onSomeoneElseSaved()\n revision = Number(response.headers.get('etag') ?? revision + 1)\n })\n }, 800)\n}\n```\n\n- **Debounce, then serialise.** `getState()` is a full snapshot, so the\n cost is in the transfer, not the call. One request per pause, and never\n two in flight, or a slow request lands after a fast one and the older\n document wins.\n- **Carry a revision.** Send what you last saw and let the server refuse a\n save built on a stale document, rather than discovering the overwrite\n later. `412` is the moment to reload, or to ask.\n- **Send changes instead, when there are two people.** A whole state per\n keystroke is fine for one user and wrong for two. `createDeltaStream`\n (see \"Two people on one sheet\") gives a delta per change, small enough to\n send as it happens, and applies one from someone else without a reload.\n\nA save that runs on a timer rather than on `onChange` is worse, not\nbetter: it saves when nothing changed and misses the tab closing. If the\ntab closing matters, save on `visibilitychange` as well, with the same\nsnapshot.\n\n`onChange` is called once per tick with every reason since the last call,\nso a paste of forty cells is one call, and it says what changed\n(`reasons.some((r) => r.kind === 'structure')` for an insert or delete, with\nthe edit on the reason). A change to the workbook's own parts rather than a\nsheet's, its names or its calculation settings, reports `workbook`; a table\nreports `tables`. A host writing those straight to the workbook raises the\nsame reason with `document.changed({ kind: 'workbook' })`, which is what\nputs it on a delta stream. `setState` clears the grid's undo history, since\nnone of it describes the restored sheet, and reports a single `restore`.\n\nThe same document is available outside the component: build it with\n`createSheetDocument({ sheets })` or `createSheetDocument({ state })`, pass\nit as `document`, and call `document.getState()`, `document.setState()` and\n`document.subscribe()` on it directly. A document built this way can be\nsaved and restored in Node, with no component at all.\n\nThe shape, `SheetState` (exported with `SheetStateEntry`, `ValidationRule`\nand `CfRule`):\n\n```ts\n{\n version: 1,\n workbook: {\n sheets: [{ name, cells }], active, names,\n tables: [{ name: 'Orders', sheet: 'Budget', headerRow: 0, firstCol: 0, lastCol: 4, lastRow: 12, hasTotals: false }],\n iteration: { enabled: true, maxIterations: 100, maxChange: 0.001 },\n },\n sheets: {\n Budget: {\n formats: { 'r0 A': { bold: true, numFmt: '#,##0', locked: false } },\n columnWidths: { A: 150 },\n rowHeights: [[4, 44]],\n hidden: { rows: [7], cols: [] },\n freeze: { rows: 1, cols: 0 },\n comments: { r1: { B: 'Check with finance' }, r4: { D: { text: 'Over budget?', author: 'Ana', at: '2026-03-04T10:00:00.000Z', replies: [{ text: 'By 3%', author: 'Ben', at: '2026-03-04T11:00:00.000Z' }] } } },\n protected: false,\n protection: { allow: { formatRows: true }, ranges: [{ id, title: 'Inputs', rects: [[1, 1, 9, 1]] }] },\n pageSetup: { orientation: 'landscape', paper: 'A4', margins: { top: 0.75, bottom: 0.75, left: 0.7, right: 0.7, header: 0.3, footer: 0.3 }, printArea: null, printTitleRows: [0, 0], gridlines: false, headings: false, scale: 100 },\n objects: [{ id, kind: 'chart', anchor: { row: 12, col: 1, dx: 8, dy: 8, width: 420, height: 260 }, range: [0, 0, 11, 2], type: 'bar', headers: true, series: 'columns' }],\n sparklines: [{ id, location: [1, 6, 9, 6], data: [1, 1, 9, 5], type: 'line', color: '#2563eb' }],\n pivots: [{ id, source: [0, 0, 200, 3], target: { row: 0, col: 8 }, rows: ['Region'], cols: [], values: [{ field: 'Amount', agg: 'sum' }] }],\n links: { r1: { B: { target: 'https://example.com', tip: 'The spec' } } },\n merges: [],\n validation: [{ id, rects, allow: 'whole', operator: 'between', value1: '1', value2: '10', ignoreBlank: true, inCellDropdown: false, alert: { style: 'stop' } }],\n conditionalFormats: [{ id, rects, kind: 'cellIs', operator: 'greater', value1: '50000', style: { fill: '#FFC7CE', color: '#9C0006' } }],\n },\n },\n}\n```\n\nRows are `r` plus the 0-based index and columns are letters, a format key is the two with a\nspace between; rectangles are `[minRow, minCol, maxRow, maxCol]`,\n0-based. A hidden row keeps the height it had before it was hidden, not\nthe 0 it shows while hidden, so an Unhide after a restore brings it back\nat its size.\n\n## A component, not a mode\n\nKanban, the scheduler and charts are modes of `<SvGrid>` (`board`,\n`scheduler`, `chart`) because they are different pictures of the same\nrows: the grid keeps the data, the row model, sorting and filtering, and the\nmode draws cards, a calendar or a chart from them. The spreadsheet is the\nother way round: the same picture, a table, over a different data model. It\nowns a document (formulas, address-keyed formats, comments, rules) and uses\nthe grid as its rendering primitive, which is why it is a component that\ncomposes `<SvGrid>` rather than a prop on it. `installEnterprise()` enables\nit along with everything else; there is nothing extra to register.\n\n## Rows into a sheet\n\n`sheetCellsFromRows(rows, fields, { totals })` turns an array of records\ninto the cells `data` or `createWorkbook` take: a header row of labels, a\nrow per record with each value as the text the engine reads (a number as\nits digits, a boolean as `TRUE` or `FALSE`, a `Date` as `yyyy-mm-dd`,\nnothing for null), and with `totals` a last row with `=SUM(...)` under\nevery column that held a number. It is how a grid's rows, or an API's,\nbecome a sheet the user can add formulas to; Studio's Spreadsheet block\nis this over the screen's dataset.\n\n```svelte\n<script lang=\"ts\">\n import { SvSheet, sheetCellsFromRows } from '@svgrid/enterprise'\n\n const cells = sheetCellsFromRows(orders, [{ field: 'item', label: 'Item' }, 'qty', { field: 'price', label: 'Price' }], { totals: true })\n</script>\n\n<SvSheet data={[{ name: 'Orders', cells }]} />\n```\n\n## Writing to the workbook from outside\n\nEvery edit made through the shell repaints it: typing, the ribbon, a\nshortcut, the fill handle, and `cmd.setCellValue` from an `onAction`\nhandler, which is also how a dialog should write a cell - the write lands in\nthe grid's undo history and the shell sees it.\n\nA write the shell cannot see does not repaint: a Name Manager redefining a\nname, an import replacing a sheet, a solver applying its answer with\n`wb.setRaw`. Call `refresh()` on the component afterwards:\n\n```svelte\n<script>\n let sheet\n</script>\n\n<SvSheet bind:this={sheet} workbook={wb} onAction={(action, cmd) => {\n if (action === 'name-manager') { openNameManager(wb.names, () => sheet.refresh()); return true }\n}} />\n```\n\n`act(action)` runs a ribbon action as if its button had been clicked\n(`sheet.act('sort-asc')`, `sheet.act('circle-invalid')`), for a host with\nchrome of its own; what the ribbon would raise for `onAction` is raised\nhere too.\n\nThe shell carries Goal Seek, Text to Columns, Remove Duplicates and the\nName Manager itself. The demos below each replace one with a dialog of\ntheir own, returning `true` from `onAction`, and show the write paths a\nreplacement should use: Goal Seek applies through `cmd.setCellValue`, Text\nto Columns and Remove Duplicates write inside `cmd.batch` so each is one\nCtrl+Z, and the Name Manager calls `refresh()`.\n\n<div data-docs-demo=\"457-goal-seek\" data-height=\"600\"></div>\n\n<div data-docs-demo=\"458-data-cleanup\" data-height=\"560\"></div>\n\n## Theming\n\nEvery part of the shell is painted with the grid's `--sg-*` tokens, so\nby default it follows the app's theme the way the grid does: the accent\non the selection and the active tab, the header and ribbon grounds, the\nfont and the corner radius are the host's. The icons are one set of line\nglyphs drawn in the text colour, black on a light ribbon and white on a\ndark one, as Excel's monochrome set is; the only colour on the ribbon is\nthe bar under Fill Colour and Font Colour, which is the colour the button\nwill apply.\n\n`look=\"excel\"` pins Excel's palette instead: the shell sets the tokens on\nits own root to Office's light theme, or its dark theme when the page is\ndark, so it reads as Excel whatever theme the app around it runs, and the\ngrid inside picks the same tokens up. Nothing else changes: the geometry,\nthe icons and the layout are the same either way.\n\n## Using the parts separately\n\n`SvSheet` is a composition of exported components, so anything it does you\ncan do by hand when you want a different arrangement:\n\n```svelte\n<SvSheetRibbon cmd={() => api?.getCommandContext() ?? null} onChange={refresh} />\n<SvFormulaBar {active} value={raw} onCommit={commit} />\n<SvGrid ... />\n<SvSheetTabs workbook={wb} {version} onChange={refresh} />\n```\n\n`api.getCommandContext()` is the seam that makes this work. A keyboard\ncommand is handed a context on every keystroke; a ribbon button has no\nkeystroke to ride in on, and without it every action would have to be\nre-implemented against the public api and kept in step by hand.\n\nFormulas > Formula Auditing has Excel's arrows: Trace Precedents draws\na blue arrow from each cell the active formula reads to the formula, a\ndot on the cell read and an arrowhead on the formula, and pressing it\nagain traces one more level out; Trace Dependents draws the arrows the\nother way, to the formulas that read the active cell, every formula in\nthe workbook counted whether or not it has been on screen; Remove Arrows\nclears them, as does a switch to another sheet. Cells on other sheets are\nleft out, since there is nothing on this sheet to draw to. The arrows\nare measured from the rendered cells and follow every scroll and\nrepaint; they are not part of the document. `wb.precedents(sheet, row,\ncol)` and `wb.dependents(sheet, row, col)` are the two readings on the\nworkbook. Raised as `trace-precedents`, `trace-dependents` and\n`remove-arrows`.\n\nThe same group carries the two dialogs that answer \"why does this cell say\nthat?\". **Evaluate Formula** shows the active cell's formula with one part\nunderlined, and each click on Evaluate replaces that part with what it is\nworth, until the formula has collapsed into the cell's answer; Step Back and\nRestart walk it again. A branch of an `IF` that is not taken is never\nevaluated, so it never reports an error the cell does not have, and a range\nsuch as `A1:A9` stays where it is because a block is not a value.\n\n**Error Checking** walks the cells on the sheet worth a second look, one at\na time, with the selection following: every formula whose value is an error,\nsaid in a sentence rather than a code, and every formula that breaks the\npattern of the ones above and below it. That second check is deliberately\nquiet. A formula is only called the odd one out when the cells above and\nbelow it both hold formulas, those two agree with each other once\ntranslated, and this one does not, which is the shape of a column filled\ndown and then broken in the middle. Show Calculation Steps hands the cell\nstraight to Evaluate Formula.\n\nBoth are engine functions first, so a save-time check or a report can use\nthem with no browser:\n\n```ts\nimport { evaluationSteps, checkSheet } from '@svgrid/enterprise'\n\nconst steps = evaluationSteps(parseFormula(text), (part) => wb.evaluateText(sheet, part))\nconst problems = checkSheet(sheet, {\n rowCount: () => wb.rowCount(sheet), colCount: () => wb.colCount(sheet),\n getRaw: (r, c) => wb.getRaw(sheet, r, c), getValue: (r, c) => wb.getValue(sheet, r, c),\n})\n```\n\nRaised as `evaluate-formula` and `error-checking`.\n\n## The dialogs\n\nFour of Excel's dialogs are the shell's own, so nothing on the ribbon or in\nthe cell menu is a button that does nothing:\n\n| Dialog | Opens from |\n| ------ | ---------- |\n| Find and Replace | `Ctrl+F` or `Ctrl+H`, Find & Select on the ribbon. Find Next, Find All, Replace, Replace All; match case, whole cell, look in values or formulas. Replace All is one undo. |\n| Paste Special | `Ctrl+Shift+V`, the Clipboard group's launcher, the last entry under the Paste arrow, the cell menu. All / Formulas / Values / Formats, Add / Subtract / Multiply / Divide, Skip blanks, Transpose. Works on what Ctrl+C took from the sheet. |\n| Chart | A double-click on a chart, or Insert > Setup while one is selected. Type (Column, Line, Area, Pie, Scatter), title, series in columns or rows, whether the first row and column are labels, stacking, a trendline over every series and one series on a secondary axis; Delete removes the chart. |\n| Page Setup | Page Layout > Print Titles, the Page Setup group's launcher. Orientation, paper, margins, scale, print area, rows to repeat at top, gridlines and headings; Print... applies and prints. |\n| Protect Sheet | Review > Protect Sheet. Excel's \"allow all users of this worksheet to\" list; OK protects with what is ticked. |\n| Allow Edit Ranges | Review > Allow Edit Ranges. Titled blocks that take an edit on a protected sheet; New over the selection, Modify, Delete, Protect Sheet... |\n| Format Cells | `Ctrl+1`, the launchers on the Font, Alignment and Number groups, the cell menu, and the end of Home > Cells > Format. Number (category, decimals, separator, the Accounting symbol, the Special type, custom code, live sample), Alignment (horizontal, vertical top/middle/bottom, wrap, indent), Font, Border presets, Fill, Protection. Opens on the active cell's format and applies only what was changed to the whole selection, as one undo. |\n| Insert Function | the `fx` button. Search or pick a category, read the signature and what the function does; OK starts the cell on `=NAME(` with the caret inside. |\n| Name Manager | Formulas > Name Manager, `Ctrl+F3`. Every defined name with what it refers to and its value; edit, delete, add. |\n| Goal Seek | Data > Goal Seek. Set a formula cell to a value by changing one input; the status page shows the answer and OK keeps it as one undo. |\n| Create Table | Insert > Table, `Ctrl+T`, and again on a cell inside a table, which is how one is renamed, resized or restyled. The range (the selection, or the region around the active cell), the name its columns are read by, whether the first row is the header, a totals row, and the styles gallery. A totals row asked for is ADDED under the data, as Excel's Total Row is, with Total in the first column and `=SUBTOTAL(109,[Last column])` in the last; unticked, it goes. A structured reference is a range to SUBTOTAL, so a totals row's formulas skip the rows a filter folds. Insert > Table Styles opens the same dialog on the table the cursor is in. |\n| Evaluate Formula | Formulas > Evaluate Formula. The active cell's formula with the next part underlined; Evaluate replaces it with its value, Step Back and Restart walk it again. |\n| Error Checking | Formulas > Error Checking. Every cell on the sheet that reports an error, and every formula that breaks its column's pattern, walked with Previous and Next; Show Calculation Steps opens Evaluate Formula on the cell. |\n| Calculation Options | Formulas > Calculation Options. Excel's Enable iterative calculation, with the maximum passes and the smallest change worth another one; OK recalculates, so a circular reference goes from #CYCLE! to its fixed point, or back. See Iterative calculation below. |\n| Sort | Data > Sort. A level per key, each a column (named from the header row when \"My data has headers\" is on; the guess looks across every column of the block, so a Name column over names still reads as a header when the Pay column beside it is text over numbers, as Excel guesses it), what to sort on and an order; Add Level and Delete Level; the block is the selection or the region around the active cell. Numbers sort before text, blanks go last, ties keep their order, formats and one-row merges ride with their rows, and it is one undo. A level can sort on the cell colour or the font colour instead of the value: the list offers the colours that column carries, and the one picked goes On Top or On Bottom while every other row keeps its order, which is Excel's model, because two colours are not greater or lesser than one another. Sort A to Z and Z to A beside it sort on the active cell's column. |\n| Text to Columns | Data > Text to Columns. The delimiter is guessed from the column, the preview shows the split, Finish writes it as one undo. |\n| Remove Duplicates | Data > Remove Duplicates. Tick the columns that decide a duplicate, say whether the first row is headers; the count goes to the status bar. |\n| Data Validation | Data > Data Validation. Settings (Allow, Data, the bounds or the source, Ignore blank, In-cell dropdown) and Error Alert (Style, Title, Message); OK puts one rule over the selection, Clear All removes it. See Data validation below. |\n| Conditional formatting | Home > Styles > Conditional Formatting: Greater Than..., Less Than..., Between..., Equal To..., Text that Contains..., Duplicate Values..., Top 10 Items..., Bottom 10 Items..., Above Average..., Below Average... and New Rule > Use a Formula... each open the small dialog (the value or values, or the formula, and the \"with\" style); Manage Rules... opens the Rules Manager. See Conditional formatting below. |\n\nHome > Cells > Insert and Delete are Excel's split buttons. The face takes\nthe selection's axis: a whole column selected inserts or deletes columns,\nanything else rows, which is what Excel does with a cell selected. The\narrow spells the axis out, so a column goes in from a cell without\nselecting the column first: Insert Sheet Rows, Insert Sheet Columns and\nInsert Sheet (Shift+F11); Delete Sheet Rows, Delete Sheet Columns and\nDelete Sheet, which asks the tab menu's question first when the sheet\nholds anything (raised as `delete-sheet`). The cell menu and the column\nand row header menus offer the same Insert and Delete for their axis, and\nCtrl+Shift++ and Ctrl+- take a whole row or column selection.\n\nHome > Cells > Format is Excel's menu: Row Height..., AutoFit Row Height,\nColumn Width... and AutoFit Column Width for the rows and columns the\nselection spans (`row-height`, `autofit-rows`, `column-width`,\n`autofit-columns`; an AutoFit is one undo), Hide Rows, Hide Columns, Unhide\nRows and Unhide Columns with their keys, Lock Cell under Protection (a\ntoggle, lit while the active cell is locked, raised as `toggle-lock`), and\nFormat Cells... at the end. A ribbon item of kind `dropdown` is that shape:\nthe face opens the list and every entry raises its own action.\n\n### AutoFilter\n\nExcel's Filter: Ctrl+Shift+L, Home > Editing > Filter or Data > Filter puts\nan arrow on every header cell of the current region (the block around the\nactive cell, its first row the headers; a selected range is the region\ninstead). An arrow drops Excel's menu: Sort A to Z and Z to A (Smallest to\nLargest on a column of numbers), Clear Filter From the column, Filter by\nColor when the column's cells carry more than one fill (its own or a\nconditional format's, No Fill included), Date Filters on a column of\ndates (Today, This Week, Last Month, This Quarter, Year to Date and the\nrest, or Equals, Before, After and Between with a typed date; weeks run\nSunday to Saturday), Top 10 on a column of numbers (top or bottom, so\nmany items or so many percent, counted over the whole column), Text\nFilters or Number Filters (equals, does not equal, begins with, contains,\ngreater than, between and the rest, two of them joined with And or Or), a\nsearch box, (Select All) and the column's values with their counts, the\nblanks last. A search narrows the list and OK (or Enter in the box)\napplies what it shows, as Excel does; Alt+Down on a header cell drops the\nmenu. OK folds the rows that fail; the arrow turns into a funnel,\nthe row numbers of the region turn blue and the status bar reads \"N of M\nrecords found\". A folded row is the filter's, not a hidden row: Unhide\nleaves it, `getState().hidden` leaves it out, and Filter off (Ctrl+Shift+L\nagain) shows it while a row hidden by hand stays hidden. The rows are\nworked out again after every change, so a formula that drops out of a\nNumber Filter folds away and one that comes back returns, as Excel's\nReapply would; a sort from the menu keeps the header row where it is.\nConditions run through the grid's own Excel-filter compiler\n(`@svgrid/grid/filtering`), so the operators are the ones the grid's\nfilter row has. The filter is per sheet, moves with an insert or delete,\nrides in `getState()` as `autoFilter` (`{ range, filters }`, a filter\nbeing `values`, `condition`, `date`, `color` or `top`) and reports\n`{ kind: 'filter' }` on `onChange`. Raised as `toggle-filter`.\n`hiddenRowsFor` takes a `fillAt` reader for the colour filters and a\n`today` for the date periods; `datePeriodBounds` is the calendar on its\nown.\n\n### Merged cells\n\nExcel's Merge & Center, on Home > Alignment as Excel's split button: the\nface merges the selection into one cell and centres it, the arrow opens\nMerge & Center, Merge Across (one merge per row), Merge Cells (no centring)\nand Unmerge Cells; the cell menu offers Merge & Center on a range and\nUnmerge Cells on a merge, and Merge & Center on a merged cell unmerges it.\nA merge keeps the top-left cell's value and drops the rest, so a merge\nover values asks first, with Excel's words; the clears, the centring and\nthe merge are one undo. The grid draws a merge as one cell (its\n`mergedCells`): it is selected as one, the arrow keys step over it, a\nclick anywhere on it lands on its top-left cell, which is what the formula\nbar shows and an edit writes, and the covered cells take no entry, fill or\npaste. A merge moves with an insert or delete (and grows when rows are\ninserted inside it), a one-row merge rides with its row through a sort,\nand a sort over a range a merge crosses is refused with Excel's sentence.\nMerges are per sheet, ride in `getState()` as `merges`\n(`[minRow, minCol, maxRow, maxCol]`) and report `{ kind: 'merges' }` on\n`onChange`. Raised as `merge-center`, `merge-across`, `merge-cells`\nand `unmerge-cells`.\n\n### Conditional formatting\n\nExcel's rules that colour cells by what they hold, on Home > Styles >\nConditional Formatting. Highlight Cells Rules (Greater Than, Less Than,\nBetween, Equal To, Text that Contains, Duplicate Values) and Top/Bottom\nRules (Top 10 Items, Bottom 10 Items, Above Average, Below Average) open\nthe small dialog: the value or values (a literal or a formula, so\n`=$B$1` follows B1) and the \"with\" style, Excel's Light Red Fill with\nDark Red Text and the rest. Data Bar, the Green - Yellow - Red and Green -\nWhite Color Scales and the Icon Set (3 Arrows) apply to the selection at\nonce; a data bar measures from zero, as Excel's do, so 10, 20 and 40 draw\na quarter, a half and the full width. Clear Rules from Selected Cells cuts the selection out of every rule\nit touches; Clear Rules from Entire Sheet empties the sheet's rules.\nManage Rules... is the Rules Manager: every rule in priority order with what\nit does, a sample and where it applies, Edit Rule (for the rules the\ndialog can edit), Delete Rule, Move Up, Move Down and Stop If True, for\nthis worksheet or the current selection; OK applies the lot as one undo.\n\nRules are evaluated over the COMPUTED values, so a Greater Than on a\ncolumn of formulas follows their results and a cell drops out when an\ninput changes. Priority is the Rules Manager's order (a new rule goes\nfirst, as in Excel): the first rule that decides a property (the fill,\nthe text colour, the bar, the icon) keeps it, and a rule with Stop If True\nstops the rules below it for the cells it matches. A rule's fill and text\ncolour paint over the cell's own format, as they do in Excel. The rules\nare per sheet, move with an insert or delete, ride in `getState()` as\n`conditionalFormats` and report `{ kind: 'conditional-formats' }` on\n`onChange`.\n\nNew Rule > Use a Formula... is Excel's \"format values where this formula\nis true\": the formula is written for the top-left cell of the selection\nand moves with each cell as a copied formula would, so `=$B2>100` on\nA2:A9 reads each row's B, and TRUE (or a number other than 0) formats.\nThe rule is `{ kind: 'formula', formula, style }`. A data bar over a range\nwith negatives puts its axis where zero falls between the minimum and the\nmaximum, and the bars grow away from it: right for a positive value, left\nand in red (or the rule's `negativeColor`) for a negative one. `evaluateCf`, `ruleStats`, `removeCf`, `shiftCf`,\n`describeCf` and the rule types are exported from\n`@svgrid/enterprise/sheet`; the grid's own value-driven\n[conditional formatting](./conditional-formatting.md) is a different thing\n(rules by column over raw values) and the shell does not use it.\n\nFreeze Panes lives on the View tab, Excel's dropdown: Freeze Panes (at the\nactive cell), Freeze Top Row, Freeze First Column, Unfreeze Panes\n(`freeze-panes`, `freeze-top-row`, `freeze-first-column`,\n`unfreeze-panes`). Beside it, View > Show has Excel's three toggles:\nGridlines, Formula Bar and Headings (the row numbers and column letters),\nraised as `toggle-gridlines`, `toggle-formula-bar` and\n`toggle-headings`. They are view settings of the component, not part of\nthe document, so `getState()` leaves them out.\n\n### Data validation\n\nExcel's rules over cells: Data > Data Validation opens on the rule at the\nactive cell and puts one over the selection. Allow is Any value, Whole\nnumber, Decimal, List, Date, Text length or Custom; the bounds (between,\nnot between, equal to, greater than and the rest) are literals or\nformulas, so `=$B$1` as a maximum follows B1 and a relative `=A2` on\nG2:G17 reads the same row's A, as in Excel; a List is a comma list or a\nrange or a name starting with `=`; a Custom formula is written for the\ntop-left cell of the selection and moves with each cell, TRUE (or a number\nother than 0) allowing the entry. A typed entry that breaks the rule never\nreaches the workbook: the alert box shows the rule's title and message (or\nExcel's own words) with Retry, which reopens the editor with the entry,\nand Cancel; a Warning rule asks \"Continue?\" and Yes lets the entry through.\nThe formula bar is checked the same way. Only typed entries are checked,\nas in Excel: paste, fill, Ctrl+D and the commands write what they are\ngiven.\n\nA List rule with In-cell dropdown draws an arrow on the active cell; the\narrow, or Alt+Down, drops the list (arrows and Enter pick, Escape leaves\nthe cell alone), and a pick is one undo. A typed entry is matched against\nthe list without regard to case.\n\nThe dialog's Input Message tab gives a rule a title and a message that\nshow in a small box under the cell while it is selected, as Excel's do;\nthe rule carries it as `input`. The arrow beside Data Validation has\nCircle Invalid Data, which draws Excel's red oval on every cell under a\nrule whose current contents break it (pasted, filled or written before the\nrule was), and Clear Validation Circles. The circles are worked out again\non every repaint while they are on, so a corrected cell loses its circle\nat once; switching sheets turns them off. `invalidCells` is the same check\nfor a shell of your own. Raised as `circle-invalid` and `clear-circles`.\n\nThe rules are per sheet, move with an insert or delete (and drop when\ntheir cells go), ride in `getState()` as `validation` and report\n`{ kind: 'validation' }` on `onChange`. `ruleAt`, `checkEntry`,\n`listChoices`, `removeValidation` and `shiftValidation` are exported\nfrom `@svgrid/enterprise/sheet` for a shell of your own, with\n`wb.evaluateText` and `wb.evaluateRange` evaluating a bound or a list\nsource in the sheet without storing it. Raised as `data-validation` and\n`open-list`.\n\n### Objects: charts and pictures\n\nInsert > Chart charts the selected block, or the region of data around\na single selected cell, as Excel starts one: the first row and column are\nread as the labels when they look like labels, each column of the block\nis a series, and the chart is anchored just under the block. It reads the\n**range**, not a copy of the numbers, so editing a cell redraws it.\nInsert > Picture puts an image on the sheet from a file, carried in the\ndocument as a data URL. A file the .xlsx cannot hold as it is (an SVG, say:\nExcel keeps only a few raster types in `xl/media`) is drawn to a PNG on\nthe way in, so what is on the sheet is what the file will carry; a picture\nwhose source is a web address is left out of the file, and Save As says\nso in the status bar.\n\n**`=IMAGE(source, [alt])`** is the other kind of picture, and the\ndifference is the point: an `IMAGE` cell IS the picture rather than\nfloating over one. It sorts with its row, filters with it, copies as a\nformula and moves when the cells move, with nothing to keep in step, which\nis what a catalogue with a thumbnail column wants. The source is a web\naddress or a `data:` URL; anything else stays text rather than becoming a\nbroken image. The second argument is the alt text, worked out like any\nother argument, so a screen reader is told what the picture is. The\nfunction's own value is the source, so a cell that reads it gets an\naddress rather than a picture it cannot use, and the file stores it as\n`_xlfn.IMAGE`, which is where Excel keeps it.\n\nAn object floats over the cells rather than living in them: drag it to\nmove, drag its corner to resize, press Delete to remove it, and\ndouble-click a chart (or Insert > Setup) to open the Chart dialog, where a\ntrendline (linear or a three-point moving average) goes over every series\nand one series can be moved to a secondary axis on the right, which is\nwhat makes a revenue-and-margin chart readable when the two are orders of\nmagnitude apart. Each\nchange is one undo. It hangs from a cell and an offset inside it, the way\nExcel's does, so inserting a row above moves it, deleting that row takes\nit with it, and resizing a column under it moves it without reshaping it.\n\nObjects are per sheet, ride in `getState()` as `objects` and report\n`{ kind: 'objects' }` on `onChange`. The chart's own drawing is the free\n`<SvChart>` from `@svgrid/grid`, so its types, palette and tooltips are\nthe grid's. Raised as `insert-chart`, `insert-picture`, `chart-setup`\nand `delete-object`, so an application can put its own chart builder in\ntheir place.\n\nObjects print with the sheet. File > Print hangs each one from its anchor\ncell with the offset and size it has on the page, and the default print\narea grows down and across to hold a chart anchored below the numbers,\nwhich is where a chart usually is. A named print area is honoured exactly\nas it stands, so an object outside it is left out.\n\nObjects ride in the .xlsx both ways, as Excel's own drawing part. A\npicture's bytes go into `xl/media` and a chart becomes a chart part of its\nown, both anchored to their cell; a file being opened gives its pictures\nand charts back the same way. A chart part carries the REFERENCES its\nseries read rather than a copy of the numbers, so Excel redraws it from\nthe cells beside it instead of from a snapshot that can drift. The one\nthing left behind is a picture whose source is a URL rather than a `data:`\nURL: its bytes are somewhere else, and a file with a broken image in it is\nworse than one without the image.\n\n### Hyperlinks\n\nTwo ways to put a link in a cell, both Excel's.\n\n**Insert > Link**, or Ctrl+K, puts a link ON the cell: the address, the\ntext to display, and a ScreenTip. The cell keeps whatever it says and the\nlink is kept beside it, so editing the text keeps the link, a format\nchange keeps it, and clearing the cell takes it away. Insert > Remove\ntakes the links off the selection. The cell menu has Excel's entries too:\nLink... on a plain cell; Edit Link..., Open Link and Remove Link on one\nthat carries a link. Each is one undo.\n\n**`=HYPERLINK(link, [friendly])`** puts one in a formula, showing the\nfriendly name. A cell holding that formula is clickable too, and its\ntarget is whatever the first argument works out to, so a link built from\nits neighbours (`\"…/issues/\" & A2`) goes where it says.\n\nA target that reads like an address moves the selection instead of leaving\nthe page: `Sheet2!B4`, `B4`, or a defined name, switching sheets when it\nhas to. Anything with a scheme, or a bare `www.`, opens in a new tab. A\nsingle click follows the link and a drag from the same cell selects, which\nis Excel's rule.\n\nOnly the schemes a link can sensibly mean are followed: `http`, `https`,\n`mailto`, `tel`, `sms`, `ftp` and `ftps`. A target carrying any other one,\n`javascript:` among them, is not a link at all: the cell keeps its text,\nnothing is clickable, the target is never written into a saved file, and one\nfound in a file being opened is dropped on the way in rather than carried\ninto the document. A\nlink is data, arriving from a colleague, a delta stream or an opened file,\nso what may be followed is named rather than assumed. Insert > Link refuses\nsuch an address in the dialog and names the ones a cell does open, and\nclicking a cell that already carries one says the same thing in the status\nbar rather than pretending the address is empty or somewhere on this\nworkbook.\n\nLinks are per sheet, ride in `getState()` as `links`, report\n`{ kind: 'links' }` on `onChange`, and move with an insert or a delete.\nThey go into the .xlsx as real hyperlinks, an external one as a\nrelationship with `TargetMode=\"External\"` and an internal one as a\n`location`, tooltips included, and a file opened back carries both.\nRaised as `insert-link` and `remove-link`.\n\n### Iterative calculation\n\nA circular reference is normally an error, and the shell says so: every\ncell in the loop shows `#CYCLE!` while the rest of the sheet keeps working.\nSome models are circular on purpose, because the answer is a fixed point: a\nbonus that is a tenth of the profit the bonus is taken out of, interest\ncharged on the balance the interest is part of.\n\n**Formulas > Calculation Options** is Excel's switch for those, raised from\nFile > Options because it is the one setting there that changes what a\nformula is worth. It takes the maximum number of passes over the loop, 100\nby default, and the smallest change worth another pass, 0.001 by default.\nWith it on, the loop runs from the values it last had until the numbers\nstop moving or the passes run out, and every cell in it holds a number\ninstead of an error.\n\nIt is workbook-wide rather than per sheet, so `wb.iteration` reads it and\n`wb.setIteration({ enabled: true })` sets it from code, recalculating as it\ngoes. It rides in `getState()` beside the defined names and the tables, and\nit goes into the .xlsx as `calcPr`, which is where Excel keeps it, so a\nfile saved with iteration on opens with it on. Raised as `calc-options`.\n\n### PivotTable from a range\n\nInsert > PivotTable summarises the selected block, or the region around\na single selected cell, on the same pivot engine the grid uses for its own\npivot mode. The dialog takes the source block\n(its first row the field names), where the result goes, and which field is\na row, a column or a measure, with Sum, Average, Count, Distinct count,\nMin and Max to summarise by.\n\nWhat the sheet keeps is the **definition**; what it writes is **cells**. A\npivot's result is an ordinary block, so it can be formatted, charted,\nfiltered, printed and saved to an .xlsx like anything else you typed.\nInsert > Refresh rebuilds it from the source and clears whatever the last\none wrote, which is what a pivot over live cells owes the user. With the\ncursor inside a written block, Insert > PivotTable opens the dialog on\nthat pivot instead of making another, so moving a field or changing an\naggregation is two clicks. Each write is one undo, cells and definition\ntogether.\n\nA field placed in **Filter** narrows the whole pivot to one of its values.\nThe filter is written above the block, a line per filter with the field\nand the value, so a reader sees what the numbers are of before reading\none; the dialog offers the values that field carries, and typing another\nvalue straight into the cell (or `(All)` to clear it) narrows the pivot at\nonce, since the cell IS the control. The filter applies before anything is\ngrouped, so the totals, the subtotals and Show Details all see the same\nrows.\n\n**Insert > Show Details** is Excel's drill-down: the source rows behind\nthe number in the cell the cursor is in, written to a sheet of their own,\nwith the field names across the top and the first row frozen. A cell in a\nsubtotal line opens its whole group, one in the grand total column opens\nthe whole line, and the grand total opens every row, which is what each of\nthose numbers stands for. The sheet is an ordinary one, so deleting it is\nhow it is dismissed. `pivotDrill` is the same answer as data.\n\nPivots are per sheet, ride in `getState()` as `pivots` and report\n`{ kind: 'pivots' }` on `onChange`. The definition moves with an insert or\na delete, and is dropped when its source or its target cell is deleted.\nRaised as `insert-pivot`, `refresh-pivot` and `pivot-details`.\n\nWith one measure the block is Excel's compact form: the column values\ncarry the corner label, the row labels run down the first column indented\nby level, and the grand total row and column can be turned off in the\ndialog. With two or more measures each column value gets one column per\nmeasure, under a header row naming them.\n\n### Sparklines\n\nExcel's smallest chart, and the one that is not an object: a sparkline IS\nthe cell. Insert > Sparklines offers Line, Column and Win/Loss, each\nopening the Create Sparklines dialog on the selected block with the data\nrange filled in and the location the column just past it. One sparkline\nis drawn per row of the data (per column, when the location is a row),\nread from the range rather than a copy of it, so editing a number redraws\nthe cell.\n\nThey are kept per GROUP, the way Excel keeps them: a data range, a\nlocation range of the same shape and the settings they share. So\n\n- selecting a cell that holds one turns the three kind buttons into a\n change to that group, Excel's Sparkline tab in one press;\n- Insert > Sparklines > Edit reopens the dialog on the group here, for\n its ranges, its colours, whether the whole group is drawn on one value\n scale and whether a line marks its last point;\n- Insert > Sparklines > Clear removes every group the selection touches.\n\nEach of those is one undo. A group moves with an insert or a delete, and\ngoes when the cells it is drawn in, or the cells it reads, are deleted.\nSparklines are per sheet, ride in `getState()` as `sparklines` and report\n`{ kind: 'sparklines' }` on `onChange`. The drawing is the free\n`<SvSparkline>` from `@svgrid/grid`. Raised as `sparkline-line`,\n`sparkline-column`, `sparkline-winloss`, `sparkline-setup` and\n`clear-sparklines`.\n\nA sparkline is behind whatever the cell shows, so a label typed over one\nstill reads, and the pointer goes through it: the cell is selected,\ndragged and edited as a cell. Sparklines ride in the .xlsx as Excel's own\nsparkline groups, both ways, in the worksheet's extension list where Excel\nkeeps them, and they are drawn on the printed page too, in the cells they\nbelong to.\n\n```svelte\n<script>\n const doc = createSheetDocument({ workbook: wb })\n doc.get('Traffic').sparklines = [{\n id: sparklineId(),\n data: [1, 1, 4, 12], // B2:M5, one row per channel\n location: [1, 13, 4, 13], // N2:N5, the cells they are drawn in\n type: 'line',\n markers: true,\n }]\n</script>\n```\n\n### Comments\n\nExcel's notes and its threaded comments: a text on a cell, marked by a red\ncorner and read by hovering the cell, and under it the replies, each with\nits author and time. Review > New Comment, Shift+F2 and the cell menu's\nNew Comment open the box beside the active cell; for a new comment it is\nthe note editor, and Ctrl+Enter or Save closes it, as does Escape or a\nclick elsewhere, keeping what was typed, as Excel keeps a note. On a cell\nthat has one, the box is the thread: the first entry with Edit and Delete,\nthe replies with theirs, a reply box (Ctrl+Enter posts) and Resolve in the\nhead, which greys the thread in the list and hides the reply box until\nReopen. Delete (on Review, in the box, in the cell menu) removes the whole\nthread; Previous and Next walk the sheet's comments row by row and wrap;\nShow All Comments opens a strip under the formula bar listing every\ncomment with its author, each a jump to its cell, which is the honest form\nover a sheet that only paints the rows in view. Every change is one undo.\n\n`commentAuthor` is who signs: with it set, a new comment and every reply\ncarry that name and the time, the way Excel signs a threaded comment with\nthe signed-in user; without it a new comment is a plain note and a reply\ncarries its time alone. Comments are per sheet, move with an insert or\ndelete, ride in `getState()` as `comments` (`r4` -> `B` -> a note's text,\nor `{ text, author, at, replies, resolved }` for a thread; every document\nsaved before threads existed reads as it did) and report\n`{ kind: 'comments' }` on `onChange`. Save As writes a note as Excel's\nlegacy note and a thread as its threaded comment with the persons part,\nand Open reads both. Raised as `new-comment`, `edit-comment`,\n`delete-comment`, `prev-comment`, `next-comment` and `toggle-comments`.\n\n### Protection\n\nExcel's model, without the password. Every cell is locked to begin with;\nFormat Cells > Protection (or Format > Lock Cell) unlocks the ones that may\nchange; Review > Protect Sheet turns the flags on. From then on a locked\ncell refuses to change wherever the change comes from: typing, F2, Delete,\nthe formula bar, paste, a fill, Ctrl+D, Find and Replace (which skips it),\na sort of the region, and the formats (the Font, Alignment and Number\nbuttons grey out while the selection holds a locked cell, and Ctrl+B\ndeclines). Insert and Delete grey out for the whole sheet, so do Row\nHeight, Column Width, AutoFit, Hide and Unhide, and the resize handles go\naway. The refusal is a sentence in the status bar, Excel's own: \"The cell\nyou're trying to change is on a protected sheet. To make a change,\nunprotect the sheet.\" Unlocked cells take every edit as before.\n\nWhat is drawn OVER the cells is covered too: a chart or a picture cannot be\nmoved, resized, retyped or deleted, and the sparklines of a protected sheet\ncannot be changed or cleared. The dialogs that would write them do not open\non a protected sheet, and the refusal sits on the write itself, so every way\nin (the ribbon, a dialog, the keyboard, a drag) says the same thing.\n\nProtect Sheet opens Excel's dialog with its \"allow all users of this\nworksheet to\" list: format cells, format columns, format rows, insert\ncolumns, insert rows, delete columns, delete rows, sort, use AutoFilter.\nWhat is ticked stays open once the sheet is protected: with Format cells\nthe Font, Alignment and Number buttons, Format Cells, merges and Lock\nCell work on every cell; with Format rows or columns Row Height, Column\nWidth, AutoFit, Hide and Unhide and the resize handles come back for that\naxis; the insert and delete entries open Home > Cells > Insert and Delete\nfor their axis; Sort allows a sort of a block that holds no locked cell,\nas Excel's does (a block with one is refused whatever the tick); Use\nAutoFilter allows the arrows on and off and their menus. The list is per\nsheet and kept, so Unprotect and Protect again open on the same ticks.\n\nReview > Allow Edit Ranges is Excel's Allow Users to Edit Ranges: a list\nof titled blocks (`B2:B10`, or several areas with commas) that take an\nedit on a protected sheet whether their cells are locked or not. New adds\none over the selection, Modify edits it in place, Delete removes it, and\nProtect Sheet... goes straight to that dialog. A range moves with an\ninsert or delete and goes with the rows it spans.\n\nProtect Sheet and Unprotect Sheet share one slot on the Review tab\n(`protect-sheet`, `unprotect-sheet`, and `allow-edit-ranges`); each is\none undo, so Ctrl+Z after Unprotect protects again, and after Protect\nputs the old allow list back. The flag is per sheet and rides in\n`getState()` as `protected`, the list and the ranges as\n`protection: { allow, ranges }` beside it (absent in an older document,\nwhich reads as nothing allowed and no ranges), the unlocked cells as\n`locked: false` in their format entries; `onChange` reports\n`{ kind: 'protection' }`. Save As writes the flag, the list and the\nranges as the file's `sheetProtection` and `protectedRanges`, and Open\nreads Excel's. A command written against the grid asks `cmd.canEdit(r, c)`\nbefore it writes and gets the same answer the editor does.\n\nThere is no password, on the sheet or on a range: the protection is\nagainst mistakes, not against the person at the keyboard, who can unprotect\nwith one click.\n\nData > Sort A to Z and Z to A sort the current region (or the selection) by\nthe active cell's column: numbers before text, blanks last, a header row of\ntext over numbers left in place, values and formats moving together, one\nundo.\n\nEvery one of them raises its `onAction` first. A handler that returns\n`true` has taken the action over and the shell's dialog stays closed, so an\napplication can put its own Format Cells in place of this one:\n\n```svelte\n<SvSheet workbook={wb} onAction={(action, cmd) => {\n if (action === 'format-cells') { openMyDialog(cmd); return true }\n}} />\n```\n\n## Grouping rows and columns\n\nData > Group folds a block of rows or columns away behind a button, the way\nExcel's outline does. Group and Ungroup act on the selection, grouping an\nalready grouped run nests it, and the numbered buttons at the corner of the\nbar show the whole sheet at one depth. Auto Outline groups the runs between\nthe totals the sheet already has; Clear Outline removes the lot.\n\nThe summary line is the one the button sits on, and it is the line AFTER\nthe detail on both axes: the total row under its rows, the total column to\nthe right of its columns. That is Excel's `summaryBelow` and `summaryRight`,\nand both round-trip through the .xlsx as `outlineLevel` and `collapsed`\nper line.\n\nA line a collapsed group folds is not a line you hid. The two are kept\napart in the document on purpose: if folding recorded a hidden row, the row\nwould stay invisible after its group reopened and there would be no button\nleft to bring it back. Unhide leaves a folded row to its group, the way it\nalready leaves a filtered row to its filter.\n\n## Cells drawn as a control\n\nInsert > Controls draws the selected cells as a checkbox or a button\ninstead of as text, and `SvSheet`'s model has a radio group beside them.\n\nA control is a rendering and never a second source of truth. A checkbox is\nticked because its cell reads `TRUE`, not because the checkbox remembers\nbeing clicked, so everything else keeps working over it:\n`=COUNTIF(A1:A9, TRUE)` counts the ticks, undo undoes one, a paste sets\none, and the file carries an ordinary boolean. Excel's own cell checkbox\nworks this way too, which is why the default pair is `TRUE` and `FALSE`.\nA sheet that wants `Yes` and `No` says so on the region.\n\nA button reports its press through `onCellAction`:\n\n```svelte\n<SvSheet {data}\n onCellAction={(event) => {\n // `event.action` is the region's own action name, so one column of\n // buttons can be told from another.\n if (event.action === 'approve') approve(event.row)\n }} />\n```\n\nClear Control puts the cells back to ordinary ones and leaves their values\nwhere they are.\n\n## A tab that is a table, not cells\n\nA workbook tab can be a bound table rather than a cell grid. Name it in\n`gridSheets` with its fields and its records and it renders as a `<SvGrid>`,\nwith its own headers, sorting, filtering and inline editing, in the same\nworkbook as the cell sheets beside it.\n\n```svelte\n<SvSheet\n data={[{ name: 'Orders', cells: [] }, { name: 'Summary', cells: [] }]}\n gridSheets={{\n Orders: {\n fields: [\n { field: 'item', label: 'Item' },\n { field: 'qty', label: 'Qty', type: 'number' },\n { field: 'price', label: 'Price', type: 'number' },\n ],\n rows: orders,\n editable: true,\n },\n }} />\n```\n\nThe records are the truth, and after every change they are projected into\nthe workbook's cells, header row included. That is what makes a bound tab\nmore than an embedded widget: on the Summary tab,\n\n```\n=SUM(Orders!B2:B99)\n=VLOOKUP(\"Gadget\", Orders!A2:C99, 2, FALSE)\n```\n\nboth work, because the formula engine, the dependency graph, the file\nwriters and the printer are never told that the Orders tab is different.\nThe projection goes through the same write an ordinary edit uses, which\nignores a write that changes nothing, so editing one field of one record\nrewrites one cell and recalculates only what read it.\n\nWhat a bound tab deliberately does not do is hold formulas of its own. Its\ncells are a rendering of its records, so anything typed into them would be\noverwritten by the next projection. A sheet that needs formulas beside the\ndata is a cell sheet reading the bound one across, which is what the\nexample above is.\n\n## Localisation\n\nEvery string the shell shows is English by default and lives in one flat\nmap, `SheetMessages`, the way the grid's `GridMessages` does. Pass any\nsubset as `localization.text` and unset keys stay English; `locale` is the\nBCP-47 tag the status bar's Sum, Average and Count are printed with, and\nit reaches the grid underneath for its own matching and formatting.\n\n```svelte\n<SvSheet {data}\n localization={{\n locale: 'de-DE',\n text: {\n 'ribbon.tab.home': 'Start',\n 'ribbon.bold.title': 'Fett',\n statusReady: 'Bereit',\n statusRecordsFound: '{shown} von {total} Datensätzen gefunden',\n 'formatCells.title': 'Zellen formatieren',\n ok: 'OK', cancel: 'Abbrechen',\n },\n }} />\n```\n\nThree families of key:\n\n- **The ribbon's** are read off the ribbon model, so every tab, group and\n button is covered without a second list: `ribbon.tab.<tab>`,\n `ribbon.group.<group>`, `ribbon.<item>.label`, `ribbon.<item>.title`\n (the tooltip), `ribbon.<item>.option.<value>` for a dropdown's entries\n and `ribbon.<item>.none` for its \"no fill\" kind of entry. The ids are\n the `RibbonActionId`s: `ribbon.bold.label`, `ribbon.file-open.title`,\n `ribbon.number-format.option.percent`.\n- **The dialogs'** are `<dialog>.<part>`: `formatCells.title`,\n `filter.dateFilters`, `validation.allow.list`, `findReplace.findNext`,\n `sort.addLevel`, `goalSeek.setCell`, and so on.\n- **The chrome's** are plain camelCase: `statusReady`, `nameBox`,\n `newSheet`, `menuFormatCells`, `ok`, `cancel`, the sentences the status\n bar says (`openedFile`, `sheetProtected`, `noInvalidData`).\n\nA sentence with `{placeholders}` (`statusRecordsFound`, `deleteSheetMessage`,\n`linesToggled`, `textToColumns.status`) lets a translator choose the word\norder; `formatMessage` fills them. `defaultSheetMessages` is exported, so\nthe full English map is there to copy from, and `resolveSheetMessages`\ngives the merged map for an app that wants to read it.\n\nWhat stays English: the function names and error values (`SUM`,\n`#VALUE!`), which Excel keeps in every locale too, the rule descriptions\nthe Conditional Formatting Rules Manager lists, and the font names.\n\n### How numbers and formulas are spelled\n\n`locale` also decides how a number and a formula are WRITTEN. Under\n`de-DE` a cell shows `1.234,5`, the formula bar shows `=ROUND(A1/3; 2)`,\nand `1,5` typed into a cell is the number rather than text. The marks come\nfrom `Intl`, so a locale nobody planned for still comes out right, and\nExcel's own rule decides the separator: wherever the decimal mark is a\ncomma the argument separator is a semicolon, since the comma is busy.\n\nThe document is unaffected, and that is the point. A formula is stored as\n`=SUM(1.5, A1)` whatever the locale, so a file written in Berlin opens in\nBoston and `getState()` means the same thing on every machine. The culture\nis put on when a formula is shown for editing and taken off when one is\ntyped. A `,` typed where the separator is `;` is taken as a separator too,\nbecause a comma with no digit after it cannot be a decimal mark, so an\nEnglish keyboard habit is not punished.\n\nA number FORMAT string stays invariant as well: `#,##0.00` is the pattern\nin every locale, and it is the rendering of that pattern that follows the\nculture.\n\nOverride it where the locale is not the whole story:\n\n```svelte\n<!-- German strings, but the dot for decimals this team is used to. -->\n<SvSheet {data} localization={{ locale: 'de-DE', culture: { decimal: '.' } }} />\n\n<!-- German strings, invariant spelling: for a team sharing files with\n English-speaking colleagues. -->\n<SvSheet {data} localization={{ locale: 'de-DE', culture: false }} />\n```\n\n## Two people on one sheet\n\n`createDeltaStream` turns the document's changes into small messages, and\n`applySheetDelta` applies one that arrived from somewhere else. Put a\nsocket between the two and the sheet is collaborative.\n\n```svelte\n<script>\n import { createSheetDocument, createDeltaStream } from '@svgrid/enterprise'\n\n const doc = createSheetDocument({ sheets })\n const socket = new WebSocket(url)\n const stream = createDeltaStream(doc, {\n onDelta: (delta) => socket.send(JSON.stringify(delta)),\n })\n socket.onmessage = (event) => stream.apply(JSON.parse(event.data))\n // A new participant asks for everything once:\n socket.onopen = () => stream.resync()\n</script>\n\n<SvSheet document={doc} height=\"100%\" />\n```\n\nFive kinds of delta go out, and each is as small as it can be:\n\n- **`cells`** carries the RAW TEXT of the cells that were written, not\n their values. A formula travels as `=SUM(B2:B4)` and the other side works\n out its own answer, so the two cannot disagree about a number and the\n receiving sheet keeps a formula in the cell.\n- **`structure`** carries the insert or delete itself, so both sides\n rewrite their own formulas identically.\n- **`state`** carries the one part of the one sheet that changed, the way\n `getState` serializes it: the formats, the merges, a rule, the objects.\n- **`document`** carries the whole state, for the changes not worth\n describing piecemeal or that cannot be: adding or removing a sheet, a\n restore, and the parts the WORKBOOK keeps rather than a sheet, which are\n its tables, its defined names and its calculation settings. A `state`\n delta is keyed by sheet and carries a sheet's own entry, so none of those\n fit in one: without this the two sides drift silently, and the same\n `=SUM(Orders[Amount])` answers a number on one and `#REF!` on the other.\n `resync()` sends the same kind.\n\nThe shell follows its document, so a delta applied from outside repaints\nthe sheet without anything else being called. `refresh()` remains for a\nwrite made straight to the workbook, which the document never hears about.\n\n**Conflicts are last writer wins, per cell.** A delta carries no version\nand no transform: two people typing in the same cell end on whichever\nmessage arrived last, and both converge on it. Two people typing in\ndifferent cells never conflict, which is the case that actually happens.\nAnything stronger, operational transform or a CRDT, is a different piece of\nwork and is not pretended at here. Applying is idempotent for `cells` and\n`state` and is not for `structure`, so a transport that can deliver twice\nhas to deduplicate.\n\n### Presence: where everyone is\n\nThe deltas carry what people TYPE. Presence carries where they ARE, which\nis what stops two people typing into the same cell.\n\n`presence` is a prop, a list of who else is on this workbook, and the shell\ndraws each person on the active sheet as a thin coloured box around their\nselection with their name on their cursor. `onPresence` fires whenever this\nuser's own selection moves, with the sheet, the rectangle and the active\ncell, which is what an application broadcasts.\n\n```svelte\n<SvSheet\n document={doc}\n presence={others}\n onPresence={(me) => stream.sendPresence({ id: myId, name: myName, ...me })}\n/>\n```\n\nThe fifth delta kind, `presence`, rides the same wire so an application\nneeds only one: `sendPresence` sends it, and it arrives at the receiving\nstream's `onPresence` rather than at the document. That is deliberate.\n**Presence is never part of the document**: not in `getState()`, not in the\n.xlsx, and `applySheetDelta` ignores it. A cursor belongs to a session, and\na file that remembered where someone's cursor was last week would be\nremembering nothing worth keeping.\n\nA person with no `colour` is given a stable one from their id, so a set of\npeers is never all one colour, and anyone who has not been heard from for\nfifteen seconds is dropped, since a closed tab says no goodbye. A cursor\nmoves on every arrow key, so throttle `onPresence` if the transport\ncharges by the message; the shell does not, because it cannot know what\nthe transport costs.\n\n## How big a sheet it holds\n\nMeasured rather than guessed, by `pnpm bench` over the built engine (the\ncases are in `tools/bench/sheet-cases.mjs`, the numbers and the rig in\n[Benchmarks](../benchmarks.md#spreadsheet-engine)). On a CI-class Linux\ncontainer:\n\n| Sheet | Opens in | Retains |\n| ---------------------------------------- | -------- | ------- |\n| 1,000 rows, a formula each | 8 ms | 1.6 MB |\n| 10,000 rows, a formula each | 91 ms | 16.5 MB |\n| 50,000 rows, a formula each | 547 ms | 84.2 MB |\n\nA keystroke costs one evaluation rather than a sheet's worth: the\ndependency graph recomputes what read the cell, and nothing else. What\nmakes an edit expensive is not the size of the sheet but how much of it\none cell feeds. A single `SUM` over 10,000 cells is one evaluation that\nre-reads ten thousand cells (14 ms); a running-balance column, where every\nrow reads the row above, is 10,000 evaluations for one keystroke, and that\nis the true cost of that shape.\n\nCI gates the evaluation counts rather than the milliseconds, since a count\nis the same on every machine.\n\n## Right to left, touch and assistive tech\n\nThe shell carries no direction of its own: set `dir=\"rtl\"` on the page or\non any element above it and everything mirrors, the row gutter and the\nfrozen columns to the right edge, the ribbon and the tab strip in reading\norder, an anchored chart hanging from its cell's right edge and growing\nleftwards. The arrow keys follow the reading order with it, so on a\nright-to-left ribbon ArrowLeft is the next tab.\n\nThe two bands that cannot fit a phone pan with a finger rather than\nclipping: the ribbon scrolls sideways over its groups and over its tab\nlabels, and the cells pan in both axes. The formula bar keeps a long entry\non one line, cut off at the right and scrolling with the caret, until the\nchevron expands it; a one-row box that wrapped would only hide the rest. A tap picks a cell, a second tap on the same cell opens its\neditor, and the formula bar is where the address and the formula are read\nand typed.\n\nFor assistive tech the shell is the bands it looks like: the ribbon is a\ntoolbar whose tab strip is a `tablist` with one Tab stop, the arrows plus\nHome and End moving between tabs with focus following the selection; the\nsheet tabs are a second `tablist` with the same model; the formula bar is\na labelled group whose Name Box reads the active cell as its value; the\ncells are the grid's own `role=\"grid\"`; every dialog takes focus and\ngives it back to the cells on Escape. `tests/e2e/sheet-a11y.spec.ts`,\n`sheet-rtl.spec.ts` and `mobile/sheet-touch.spec.ts` hold the shell to\nall of this in a real browser, the first with an axe audit of every band.\n\nOne thing the shell does not decide: a font colour or a fill the DOCUMENT\nsets is used as it stands, on a dark theme as on a light one. A red on a\ndark canvas is as hard to read here as it is in Excel, and the answer is\nthe same one: change the document's colours, not the app's.\n\n## What it does not do\n\nThe shell is the part of Excel a team's spreadsheet uses every day; these\nare the parts it leaves out, on purpose, so nothing on the ribbon is a\nbutton that does nothing.\n\n- **Tabs:** no track-changes on Review; Page Layout has no themes, no\n page breaks and no header or footer text, and Print has no preview of\n its own beyond the browser's.\n- **Objects** are charts and pictures: a chart is what the Chart dialog\n sets and no more, so there are no per-series colours, no data labels and\n no axis titles. A floating picture whose source is a URL rather than a\n `data:` URL is left out of the .xlsx, since its bytes are not in the\n document to write; a picture in a cell, `=IMAGE(...)`, has no such limit,\n because the formula is what the file carries.\n- **Sparklines** are the three Excel draws: no axis options beyond one\n scale for the group, and no high and low point marks beyond the last one.\n They ride in the .xlsx as Excel's sparkline groups, both ways, and they\n print with the sheet.\n- **A PivotTable** is a definition plus the cells it writes, not a live\n object: no drag-and-drop field list and no slicers, and a report filter\n holds one value at a time rather than a tick list. Refresh brings it up\n to date and Show Details is the drill-down.\n- **Collaboration** is the delta stream and the presence overlay above and\n nothing more: no server, no operational transform, no follow-the-leader\n scrolling, and last writer wins per cell.\n- **The outline** groups, ungroups, collapses and shows a level, and it\n round-trips. What it does not have is Excel's Data > Subtotal, the\n command that inserts `SUBTOTAL` formulas at each break AND groups what\n it just broke up; the grouping half is here, the formula-writing half is\n not. `SUBTOTAL` itself works, so a sheet can write its own.\n- **A cell drawn as a control** carries its VALUE into a file and not its\n control: a checkbox column saves as the `TRUE` and `FALSE` it means, and\n reopens as ordinary cells. The controls live in `getState()`, so an app\n that saves the document keeps them; an app that saves only .xlsx keeps\n the data and loses the decoration.\n- **A bound tab** holds no formulas of its own, since its cells are made\n from its records and would be overwritten. It is not written to the\n .xlsx as an Excel table either: its projection goes out as ordinary\n cells, which is what Excel would show anyway.\n- **The culture** changes how a number and a formula are spelled, not what\n they are called: function names stay English, as they do in the file\n format itself.\n- **Protection** takes no password, on the sheet or on an edit range: it\n guards against mistakes, not against the person at the keyboard.\n- **Validation** checks what is typed; pasted-over cells are left as they\n land until Circle Invalid Data is asked for.\n- **Conditional formatting** has Excel's presets and Excel's \"with\"\n styles, one icon set per flavour, and no colour pickers of its own for\n bars and scales; the grid's own value-driven rules are a separate\n feature.\n- **AutoFilter**'s values list is the whole column, unvirtualised, which\n is what a sheet's region holds.\n- **Files** carry what each format has a place for, so the three are not\n equals: the .xlsx carries the most, the .ods has no charts, pictures,\n validation, conditional formatting or frozen panes, and the .xls\n is the oldest and carries the least, with no charts, pictures,\n sparklines, validation, conditional formatting or comments, a\n sheet of 65,536 rows by 256 columns, and a formula using a function Excel\n 97 never had saved as the value it worked out.\n\n## More examples\n\n### A tab that is a table, and rows that fold away\n\nA workbook tab that holds RECORDS rather than cells: the Orders tab renders as the data grid, with its own headers, sorting, filtering and inline editing, beside ordinary cell sheets. Its records are projected into the workbook's cells, header row included, so the Summary tab reads it with plain formulas: SUM, SUMIF, SUMPRODUCT and VLOOKUP all reach across, because the formula engine is never told the tab is different. Summary also shows Data > Group, with three regional blocks folded under their subtotals and the numbered level buttons at the corner of the outline bar. Edit a Qty on Orders and every figure follows.\n\n<div data-docs-demo=\"498-sheet-bound-tabs\" data-height=\"560\"></div>\n\n### Engineering, statistics, and the locale you type in\n\nFour function families the engine gained at once, worked through a real example of each: a bearing from ATAN2 and DEGREES, a register mask in binary and hex with BITAND, an orchard queried through Excel's criteria-block grammar with DSUM, DCOUNT, DAVERAGE and DGET, and NORM.DIST with its inverse, a confidence interval and the chi-squared and t tails. The picker switches how numbers and formulas are SPELLED: German shows 1.234,5 and =ROUND(A1/3; 2) while the document still stores 1234.5 and a comma, so the file opens anywhere. Column H is drawn as checkboxes whose ticks an ordinary COUNTIF counts.\n\n<div data-docs-demo=\"499-sheet-engineering-stats\" data-height=\"560\"></div>\n\n\n### Review workflow: comments + Protect Sheet\n\nAn expense sheet under review on the Excel shell. It opens protected: every cell is locked except the reviewer's Receipt and Status columns, so a keystroke on an amount is refused and the status bar says why. Three reviewer notes sit on the cells they are about (Excel's red corner): hover, Shift+F2, Review > Next, Show All Comments. Status is a list, the totals by status are SUMIFs, and protection, notes and rules all ride in the document.\n\n<div data-docs-demo=\"460-review-comments-protection\" data-height=\"560\"></div>\n\n### Order intake: data validation\n\nAn order form with Excel's Data Validation on it: Region and Product are lists (the arrow or Alt+Down drops them; Product reads the price list on a second sheet), Qty is a whole number from 1 to 500 with a Stop alert and Retry, Discount is a decimal up to 20% with a Warning you can keep, Ship by is a date on or after the order date through a relative bound (=A2 moves with the row). Unit price is an XLOOKUP, the total follows.\n\n<div data-docs-demo=\"461-order-intake-validation\" data-height=\"560\"></div>\n\n### Regional scorecard: conditional formatting\n\nEight regions, three months, a target, and every colour on the page is a conditional formatting rule over computed values: Highlight Cells on attainment (under 90% red, 100% and over green), a Data Bar and a Top 3 on the quarter, a Green - Yellow - Red Color Scale across the months, an Icon Set on the trend. Edit a month and the arrow, the bar, the fill and the rank all move; Manage Rules lists the six in priority order.\n\n<div data-docs-demo=\"462-regional-scorecard-cf\" data-height=\"560\"></div>\n\n### Budget report: merged headers\n\nA half-year budget report laid out with merged cells the way Excel lays one out: a title merged across the page, Q1 / Q2 / H1 group headers merged over their months, a Line corner merged down two rows, a notes paragraph merged into one wrapped block. A merge is one cell to the grid: click inside Q1 and the Name Box says B2, Merge & Center lights, the arrows step over it and its column letters are shaded. Unmerge and merge again from the ribbon; Ctrl+Z each step.\n\n<div data-docs-demo=\"463-merged-report-headers\" data-height=\"560\"></div>\n\n### Support ticket log: AutoFilter\n\nForty support tickets with Excel's Filter on the header row, opened already filtered to what is still open: the funnel on Status, blue row numbers, \"N of 40 records found\" in the status bar. The arrows drop Excel's menu: sort, Clear Filter, Text and Number Filters with two conditions, a search box, (Select All) and the values with counts. The rows are worked out again after every edit, so a ticket typed Closed folds away at once. Ctrl+Shift+L toggles it.\n\n<div data-docs-demo=\"464-ticket-log-autofilter\" data-height=\"560\"></div>\n\n### Autosave: a document that survives a reload\n\nA project tracker that keeps itself in localStorage: onChange fires once per tick with every kind of change, the demo debounces it and writes getState() as JSON, and the next visit rebuilds the document with createSheetDocument({ state }) and hands it to <SvSheet document>. Add a comment, bold a row, hide a column, type a task, reload: it is all back, rules and merged title and frozen rows included. Reset puts the shipped document back with setState().\n\n<div data-docs-demo=\"465-autosave-document\" data-height=\"560\"></div>\n\n### Paste from Excel: formats and formulas survive\n\nExcel puts an HTML document on the clipboard beside the tab-separated text: formats in a style block keyed by class, formulas in x:fmla, raw numbers in x:num. The sheet reads it, so a pasted block arrives bold, filled, with its number formats, and with its formulas moved to where they landed; Google Sheets' data-sheets-formula flavour reads the same. Two buttons put exactly what Excel and Sheets put on the clipboard; click a cell and Ctrl+V.\n\n<div data-docs-demo=\"466-paste-from-excel\" data-height=\"560\"></div>\n\n### Loan model: PMT, accounting formats and the File tab\n\nA mortgage model built from the sheet's own financial functions: PMT for the payment, IPMT and PPMT for each period's split, NPER and RATE for the term and the rate that fit, SUMPRODUCT for the first year of interest. The money cells wear the Accounting format, Extra Payment carries a validation rule with an Input Message, and Circle Invalid Data rings what breaks it. The File tab is the point: Save As writes the whole model as an .xlsx Excel opens with its formulas, formats and rules, Open reads one back, Export CSV takes the active sheet.\n\n<div data-docs-demo=\"484-loan-model-files\" data-height=\"560\"></div>\n\n### Charts and pictures on the sheet\n\nCharts anchored over the cells the way Excel anchors one: each reads a range rather than a copy of the numbers, so typing into a cell redraws it. Insert > Chart charts the selected block and reads its first row and column as the labels, Insert > Picture puts an image on the sheet, and a double-click opens the Chart dialog for the type, the title, series in columns or rows and stacking. Drag an object to move it, its corner to resize, Delete to remove. It hangs from a cell, so inserting a row above moves it, and it rides in getState().\n\n<div data-docs-demo=\"485-sheet-charts-objects\" data-height=\"560\"></div>\n\n### Sparklines: a chart inside the cell\n\nExcel's smallest chart, and not an object: a sparkline IS the cell. One per row of a block of numbers, drawn from the range rather than a copy, so editing a number redraws it. Insert > Sparklines offers Line, Column and Win/Loss, with Edit for the group's ranges, kind and colours and Clear for the groups the selection touches; selecting a cell that holds one turns the kind buttons into a change to that group. They are kept per group the way Excel keeps them, ride in getState() and move with an insert or a delete.\n\n<div data-docs-demo=\"486-sheet-sparklines\" data-height=\"560\"></div>\n\n### PivotTable from a range\n\nExcel's Insert > PivotTable over a block of cells, on the same pivot engine the grid uses for its own pivot mode. The sheet keeps the definition, the source block, where the result goes and which field is a row, a column or a measure; the result is plain cells written in one undo, so it can be formatted, charted, printed and saved to an .xlsx like any other block. Refresh rebuilds it from the source, opening the dialog from inside one edits it, and Show Details writes the source rows behind a cell to a sheet of their own.\n\n<div data-docs-demo=\"487-sheet-pivot-range\" data-height=\"560\"></div>\n\n### Two people on one sheet\n\nTwo full spreadsheets over two separate documents, wired to each other by createDeltaStream: type in either and the other follows, and each window shows the other's cursor as a coloured box with a name on it. What crosses the wire is a delta rather than the document, and the log shows each one as it goes: a formula travels as its text so the other side works out its own answer, an insert travels as the edit so both rewrite their own formulas, a format travels as the one part of the one sheet that changed. Conflicts are last writer wins, per cell.\n\n<div data-docs-demo=\"488-sheet-collaboration\" data-height=\"620\"></div>\n\n### Evaluate Formula and Error Checking\n\nThe two auditing tools that answer why a cell says what it says, over a commission model with three planted faults. Evaluate Formula underlines one part of the formula and replaces it with its value on each click. Error Checking walks every cell that reports an error, with a sentence on what each one means, and the one row whose formula is not the column's formula. Show Calculation Steps takes one straight into the other.\n\n<div data-docs-demo=\"493-sheet-auditing\" data-height=\"560\"></div>\n\n### Iterative calculation\n\nA circular reference is normally an error, and every cell in the loop shows #CYCLE!. Two models here are circular on purpose: a bonus that is a share of the profit it is taken out of, and interest charged on the balance it is part of. Formulas > Calculation Options turns iteration on with its two limits, and both settle on their fixed point; turn it off and the cycle is an error again. The setting rides in getState() and goes into the .xlsx as calcPr.\n\n<div data-docs-demo=\"492-sheet-iterative\" data-height=\"560\"></div>\n\n### IMAGE: a picture inside the cell\n\nExcel's IMAGE puts a picture IN a cell rather than floating one over it, so it sorts with its row, filters with it, copies as a formula and moves when the cells move. A product list whose thumbnail column reads the swatch beside it: sort by price and the pictures follow their rows. The source is a web address or a data URL, anything else stays text rather than becoming a broken image, and the second argument is the alt text a screen reader reads.\n\n<div data-docs-demo=\"494-sheet-cell-images\" data-height=\"520\"></div>\n\n### Hyperlinks: Insert > Link and HYPERLINK\n\nExcel's two ways of putting a link in a cell. Insert > Link (Ctrl+K) puts one on the cell, so editing the text keeps it and clearing the cell takes it away; the HYPERLINK function puts one in a formula with a friendly name. A target that reads like an address moves the selection instead of leaving the page, so a cell links to another sheet; anything with a scheme opens in a new tab. A single click follows, a drag selects. Links ride in getState(), move with an insert, and go into the .xlsx both ways.\n\n<div data-docs-demo=\"489-sheet-hyperlinks\" data-height=\"560\"></div>\n\n## See also\n\n- [`<sv-sheet>`](../web-components/sv-sheet.md): the shell as a custom\n element for React, Vue, Angular or a plain page, with React and Vue\n wrappers.\n\n- [Excel keyboard shortcuts](./keyboard-shortcuts.md)\n- [Formulas](../spreadsheet-formulas.md)\n- [Number formats](./number-formats.md)\n- [Workbooks](./workbooks.md)\n"
|
|
4170
|
+
"markdown": "# The spreadsheet shell\n\nEverything between the ribbon and the sheet tabs, as one component.\n\n```svelte\n<script>\n import { SvSheet, createWorkbook } from '@svgrid/enterprise'\n\n const wb = createWorkbook([\n { name: 'Budget', cells: [['Line', 'Jan'], ['Rent', '2400']] },\n ])\n</script>\n\n<SvSheet workbook={wb} />\n```\n\n<div data-docs-demo=\"27-spreadsheet-ribbon\" data-height=\"640\"></div>\n\nThat is the whole required API. `<SvSheet />` with no props at all opens an\nempty single-sheet workbook, which is what \"open a spreadsheet\" means.\n\nThis page is the reference: every prop, every dialog, the file formats\nand the limits. The tutorials walk through the same ground a task at a\ntime, with a live sheet under each step: [Getting started](../sheet/start.md),\n[Formulas](../sheet/formulas.md), [Formatting](../sheet/formatting.md),\n[Data validation](../sheet/validation.md), [Files](../sheet/files.md),\n[Data tools](../sheet/data-tools.md),\n[Charts, sparklines and pictures](../sheet/charts-and-objects.md) and\n[Review and share](../sheet/review.md).\n\n## What you get\n\n| Part | Comes from |\n| --- | --- |\n| Ribbon: File, Home, Insert, Formulas, Data, Review, View | `SvSheetRibbon` |\n| Name Box and fx bar, showing the RAW text of the active cell | `SvFormulaBar` |\n| A..Z headers over the built-in 1..N row gutter | `SvGrid` |\n| Sheet tabs: switch, rename, reorder, add, duplicate, hide and unhide | `SvSheetTabs` |\n| Sum / Average / Count of the selection | the shell |\n| Every Excel shortcut | `enableSheet()` |\n\nThis is composition, not a second grid. All of it already existed and was\nbeing re-assembled by hand in four separate demos. What the component adds is\nthe wiring BETWEEN the parts, done once:\n\n- The formula bar shows the raw text while the grid shows the computed value.\n Editing in the bar edits the formula, not its result.\n- The format store is keyed by row id, so sorting cannot strand formatting on\n whatever row took that index.\n- Insert and delete go through the workbook, so references are rewritten on\n every sheet rather than only the active one.\n- The fill handle translates references, so `=B2` filled down becomes `=B3`.\n- The tab strip is told when a shortcut moved the active sheet, so\n `Ctrl+PageUp` and the tabs cannot disagree.\n- AutoSum measures its run against evaluated values, so a column of subtotals\n counts as numbers rather than as `\"=SUM(...)\"` strings. With one cell\n selected it proposes the `=SUM(...)` in the cell's editor, as Excel's\n does, so a guess that stopped a row short is corrected before Enter takes\n it; a wider selection has the sum written outright.\n- Ctrl+Z and Ctrl+Y, and the ribbon's Undo and Redo, go to the sheet the\n change was made on: a step made on Orders and undone from Summary brings\n Orders up and changes the cell back there, as Excel does, rather than\n landing on Summary's cell of the same address. Every step the grid\n records is marked with its sheet for this (`api.setHistoryTag` and\n `api.peekUndo` on the grid), the mark follows a rename, and Delete\n Sheet empties the undo list, as Excel's does. A click that selects a\n chart or a picture is not a step; a drag that moves one is.\n- A click on a sheet tab, on the strip's add button or on an entry of the\n tab menu leaves the keyboard on the cells, so the arrow keys, Ctrl+Z and\n Shift+F11 work on the sheet that just came up. The arrow keys inside the\n strip keep the focus on the tabs, as a tablist's do.\n- Formats are kept per sheet, as they are in Excel. Bold on Summary!C5 says\n nothing about Orders!C5, and the store follows a sheet through a rename.\n- The Name Box lists the workbook's defined names. Pick one and the shell\n selects what it refers to, switching sheets first when it lives elsewhere;\n type an address and press Enter to go there; type a name that is not one\n yet and it is defined for the selection, sheet-qualified and absolute as\n the Name Manager writes one, one undo. Either way focus comes back\n to the sheet, so the next keystroke edits the cell just reached. Beside it\n sit Excel's three: Cancel and Enter while a formula is being typed, and\n fx, which raises `insert-function`.\n- A formula-bar entry lands in the cell the edit started in, however the\n edit ends: Enter, or a click on another cell. It is one step in the\n grid's history, so Ctrl+Z takes it back.\n- Enter after an in-cell edit commits and moves down, Shift+Enter up, Tab\n right, as in Excel. Alt+Enter is a line break: the cell turns on Wrap\n Text and its row grows to show every line, as a wrapped row does after\n Wrap Text on the ribbon or in Format Cells; a row the fitting grew is\n back to its height once the wrap goes or the text gets shorter, while a\n row sized by hand, dragged or typed into Row Height..., stays as the\n user left it, which is Excel's auto-height rule. F4 while editing turns the\n reference at the caret through `$A$1`, `A$1`, `$A1` and back.\n- AutoComplete, as Excel's: while a cell is typed into, the text entries in\n the same column that run without a blank above and below it are matched\n against what was typed, and when exactly one fits, the rest of it appears\n selected in the cell. Typing on overwrites it, Backspace takes it away,\n and Enter or Tab accepts it in the entry's own case, so \"jo\" under \"John\n Smith\" commits \"John Smith\". Numbers, formulas and booleans are never\n offered, and two entries that both fit (\"Apple\", \"Apricot\" for \"Ap\")\n offer nothing until the typing tells them apart. `completeEntry(typed,\n entries)` is the rule on its own.\n- Formulas are typed the way Excel's are. Pointing: a click on a cell while\n a formula is being typed, in the cell or in the bar, puts that cell's\n address in at the caret rather than ending the edit, a drag puts a range,\n and a second click replaces what the last one put there; typing goes on\n from there. Formula AutoComplete: the function names a typed prefix could\n be are listed under the cell, Up and Down walk them, Tab or Enter takes\n one with the caret inside its brackets, Escape closes the list. Backspace\n on a cell opens the editor empty, so Escape gives the value back where\n Delete would have blanked it.\n- Data entry steps the way Excel's does: the Enter that ends a run of Tabs\n goes down from the column the run began in, and inside a selected block\n Enter and Tab walk the block and wrap at its edges while the block stays\n selected. Ctrl+Enter puts the entry into every cell of the block at once,\n each taking its value and format as if typed there, as one undo. A drag, Shift+click or Shift+Arrow grows the selection from\n its far corner and leaves the active cell at the anchor, white inside the\n tinted range, and the formula bar shows that cell.\n- The formula bar follows an in-cell edit as it is typed, and the cell\n follows the bar; both go back when the edit is cancelled.\n- The formula bar keeps a cell's line breaks. It shows one line until the\n chevron at its end expands it to every line the text takes at the bar's\n width, whether from a line break or from wrapping, up to six, with a\n scrollbar past that; Alt+Enter typed in the bar breaks the line there and\n expands the bar.\n- A typed `12%` is the number 0.12 shown as a percentage, `$1,200` is 1200\n shown as currency, `1,234.5` keeps its separator: the entry names a value\n and a format, and the cell takes both unless it already has a number\n format of the same kind (a time typed into a currency cell makes it a\n time cell, as it does in Excel; `5%` typed into a `0.0%` cell keeps the\n cell's decimals). `=A1*2` over a `12%` cell is 0.24. A plain number\n typed into a cell that already shows percentages is that percentage, so\n `5` in a `0%` cell is 5% rather than 500%, as Excel's automatic percent\n entry has it; `(5)` is -5, the way a statement writes a negative. A mixed\n fraction with a whole part, `3 1/2`, is 3.5 shown back as a fraction, and\n `0 1/2` is how you enter a bare half without `1/2` becoming the second of\n January; `=A1*2` over `3 1/2` is 7.\n- A date typed the American way, `3/4/2026`, `3/4/26` or `3/4`, or with\n the month spelled out, `4-Mar-2026`, `4 Mar 26`, `March 4, 2026` or\n `Mar 4` (English month names, in either order), is the sheet's\n `2026-03-04` under a date format, and a clock time, `10:30`,\n `6:00:00` or `10:30 PM`, is the fraction of a day it is under a time\n format, so the times add up. In arithmetic a date written as text is its\n day number, as a date cell is in Excel: `=A1+1` under a date is the next\n day rather than `#VALUE!`, and `=B1-A1` over two dates is the days\n between them.\n- A formula typed into a cell with no format takes the number format of\n the first cell it reads, as Excel's does: `=A1*2` under `$5.00` is\n `$10.00`, `=SUM(B2:B9)` over currency is currency, `=A1+1` under a date\n is a date and `=TODAY()+7` is a day. A formula whose function returns a\n count or a part (COUNT, LEN, YEAR and the rest) stays General, and so\n does one date taken from another, which is a number of days, the one\n exception Excel makes too.\n- File > Open reads what the other spreadsheets write, not only what this\n one wrote: a sheet's own `<cols>` or a table's filter, a row height\n LibreOffice left unflagged, `General` as the absence of a format, a row\n or a cell that carries no position of its own, an error or a boolean\n saved as a formula. Save As writes a package Excel, Google Sheets and\n LibreOffice all open; a rule it cannot spell is left out rather than\n written badly, since a file with one bad element in it is a file Excel\n offers to repair.\n- A sort reorders the rows you can see and leaves the hidden ones where\n they are, as Excel's does, so sorting a filtered list cannot drag a\n filtered-out row into view or overwrite what one holds. The status bar\n says how many stayed put. A row takes its formulas along the way a copy\n would: the relative references shift to the row's new place and the\n absolute ones stay, so `=SUM(B5:E5)` on a row that lands in row 2 reads\n `B2:E2`, and an `=IMAGE(C5)` thumbnail still shows its own row's picture.\n- `SUBTOTAL` follows the filter: `=SUBTOTAL(9, C2:C99)` under a filtered\n list totals the rows that matched, and the 101-111 codes leave out rows\n hidden by hand too. That is what a table's totals row should be written\n with; `SUM` counts everything, as it does in Excel.\n- The status bar's Average, Count and Sum are of the cells you can see: a\n filtered-out row is not in the total, which is what makes the bar worth\n reading over a filtered block. Delete and Clear Contents leave such a row\n as it is, so clearing a filtered selection cannot wipe what the filter\n hid.\n- A copy leaves out the rows a filter folded away, and the block closes up\n around them, so filtering a log and copying the block gives the rows that\n matched with nothing between them. A row hidden by hand with Hide Rows\n IS copied, as Excel copies one (the grid's `includeCollapsedRows` prop is\n how the shell tells the two apart); a hidden column is left out. The\n merges inside the block travel with a paste of everything or of the\n formats, laid over the landing from its corner, and a merged cell copied\n on its own is the whole merge, as it is in Excel.\n- Ctrl+X marks the block rather than emptying it, as Excel's cut does: the\n cells stay where they are until the paste lands, Escape leaves the sheet\n as it was, and one Ctrl+Z puts a whole move back. The ribbon's Cut and\n the menu's mean the same thing.\n- Cut and paste MOVES the cells, so every formula that read them follows:\n moving A1 to D1 rewrites `=A1*2` as `=D1*2`, across every sheet and the\n defined names, the way Excel does. A `$` makes no difference, since the\n cell itself moved; a range follows only when all of it moved, so\n `=SUM(A1:A2)` stays as it is when A1 alone goes. A copy translates\n relative references by the distance pasted, as before.\n- A typed error code is that error, as it is in Excel: a cell holding\n `#N/A` answers TRUE to `=ISNA(A1)` and carries the error into anything\n that reads it, so a placeholder row reads as missing rather than as the\n text that spells it. Case does not matter, and `'#N/A` is the text.\n- A leading apostrophe says the rest is text, as it does in Excel: `'007`\n is the text `007`, leading zero kept, and `'=A1+1` shows the formula\n rather than working it out. The apostrophe is not part of the value, so\n it is never displayed, printed, exported or compared; the formula bar\n shows it, since it is what was typed. A cell that comes back from an\n .xlsx as text keeps its prefix, so a part number survives the round trip.\n- The fill handle fills the way Excel's does: a lone value repeats, `1, 2`\n continues, `Jan` runs on to `Feb`, and a formula moves every relative\n reference by the distance dragged. Ctrl held at the release turns it the\n other way, a series into a copy and one number into a series. Double-click\n it to fill down as far as the column beside the selection has data. A number too wide for its\n column reads as `####` until the column fits it; `TRUE` and `FALSE`\n typed into a cell are booleans, centred.\n- Freeze Panes freezes everything above and left of the active cell, both\n halves, per sheet: the rows stay under the column letters and the columns\n beside the row numbers while the rest scrolls, and every frozen cell is\n still an ordinary cell. Unfreeze clears both.\n- A dragged column width or row height is one Ctrl+Z, and double-clicking\n a row number's lower edge fits the row to its wrapped text, as a\n double-click on a column letter's edge fits the column to its text.\n- Row heights are the sheet's: a row dragged taller stays that way through\n every edit, moves with its row when rows are inserted or deleted above it,\n comes back with an undo, and belongs to one sheet. The fill handle's drag\n is one undo, like Ctrl+D. Column widths move with their columns the same\n way.\n- Hide and Unhide, on the column letter and row number menus and on Ctrl+9\n (rows), Ctrl+0 (columns), Ctrl+Shift+9 and Ctrl+Shift+0: a hidden line\n keeps its letter or number and every reference to it, and takes no room,\n so the headers read A, B, D. Unhide reveals what the selection spans, or\n the hidden lines right next to it when it spans none. The cursor steps\n over hidden lines, Ctrl+Arrow lands on the last cell of a run that shows\n rather than its hidden end, and the Name Box can still reach a hidden\n cell; a hide is one Ctrl+Z, moves with an insert or delete, and belongs\n to its sheet.\n- Text spills over empty neighbours and is clipped by the first cell that\n holds something, so a title in A1 reads in full and a label beside a\n number does not run under it. Numbers never spill. The text runs only\n over neighbours with the same background as its own cell: a title on a\n banded row reads across the band, and a filled cell beside an unfilled\n one (or the other way round) keeps its text in its cell, since the run\n would drag one colour over the other.\n- General alignment: numbers right, text left, errors and booleans centred,\n unless the cell says otherwise.\n- The headers of every column and row the selection touches are shaded and\n marked with a line in the accent, the Select All corner sits top-left,\n and the status bar reads Ready on the left and Average / Count / Sum on\n the right.\n- Sheet tabs delete, insert, rename, move, duplicate, hide and unhide from\n a right-click menu, as in Excel; nothing on the tab face can remove a\n sheet, and deleting one that holds data asks first. A hidden sheet keeps\n its cells and every reference to it, Ctrl+PageUp and PageDown step over\n it, and the flag rides in the document. Duplicate copies the cells and\n everything the document keeps beside them, and the copy's table, charts,\n pictures, sparklines and PivotTables are its own: each gets a fresh name\n or identity, so working on the copy leaves the sheet it came from alone.\n- Click a column letter to select the column, a row number to select the\n row, the corner to select the sheet; Shift+click extends. Drag the edge of\n a row number to resize the row, as with a column letter.\n- Copy puts two things on the system clipboard: the text each cell shows,\n which is what a text editor expects, and an HTML table with the formats,\n the numbers behind the formatted text and the formulas, written the way\n Excel writes its own (`mso-number-format`, `x:num`), so a paste into\n Excel or Google Sheets keeps the number format, the bold and the fill. A\n paste back into the sheet gets the formula, moved by the distance it\n travelled (`=A1*2` copied from B1 to B3 reads `=A3*2`), and the cell's\n format, as Excel pastes. A cut block is moved, not translated. The block\n Ctrl+C or Ctrl+X took is outlined by Excel's marching ants until Escape,\n a typed entry, a structural edit, the paste of a cut, or the next copy; a\n Ctrl+V paste of a copy keeps them, so the block can be pasted again, and\n Enter pastes it once and drops them, as Excel's Enter does. The Copy and\n Cut buttons on the ribbon and in the cell menu are the same commands as\n the keys.\n- Paste reads what the clipboard carries. A block copied from Excel arrives\n with its values (the number, not the text `1,234.50`), its number\n formats, fonts, colours and fills, and its formulas: Excel's HTML says\n nothing about where a block was copied from, so a formula whose\n references all fall inside the block is kept and moved with the paste as\n if the block came from A1 (a totals column keeps totalling), and one that\n reaches outside the block lands as its value. A block from Google Sheets\n the same, with its R1C1 formulas turned into A1 for the cells they land\n in. Plain text lands as typed. One copied cell fills the\n selected range, and a block that fits the selection a whole number of\n times is repeated over it, as in Excel. One paste is one Ctrl+Z, formats\n included.\n- Selecting works as it does in Excel. A click on a column letter or a\n row number selects the whole line and puts the active cell on its first\n visible row or column; a drag along the letters or the numbers selects a\n run; Shift+click extends the run from the active cell's line; Ctrl+click\n adds a line beside the selection; the corner selects everything and\n leaves the active cell where it was. Shift+Arrow grows a run of whole\n lines, an arrow collapses to the active cell, and typing lands in the\n active cell, so the keyboard never leaves the sheet. Shift+Space and\n Ctrl+Space take the whole rows or columns the selection touches, Ctrl+A\n the current region then the sheet, Ctrl+Shift+8 the region alone. A\n column or row selected whole paints its letter or number in the accent,\n white on green as Excel's; a line the selection merely touches is\n tinted. While a range is dragged out the Name Box reads \"3R x 2C\".\n- The pointer reads as it does in Excel: the white cross over the cells,\n a black arrow down a column letter or along a row number, the\n double-headed arrows on the borders, the crosshair on the fill handle.\n Dragging a border shows Excel's tip beside the pointer, \"Width: 8.43 (64\n pixels)\" or \"Height: 15.00 (20 pixels)\", with a dotted guide across the\n sheet at the new edge; dragging the fill handle shows what the cell\n under the pointer will get (Feb, 7, 2026-09-18), in the source cell's\n number format.\n- A General cell shrinks a number to its column before it hashes, as\n Excel's does: the decimals go first (5.333333 reads 5.3333, then 5.33),\n then a whole part too wide turns scientific (1.76E+04). A formatted\n number hashes as soon as its format does not fit, since the format said\n what to show.\n- Right-click a column letter or a row number for its menu: the column or\n row is selected first, unless it was already inside the selection, and the\n menu offers Insert, Delete and Column Width... or Row Height... for that\n axis alone, as Excel's does.\n- Right-click a cell for Excel's menu: Cut, Copy, Paste, Paste Special,\n Insert / Delete Rows and Columns, Clear Contents, Clear Formats, Merge &\n Center or Unmerge, the comment entries, Format Cells, each with the\n ribbon's icon for it so the two read as one set. Structure goes through\n the sheet's own insert and delete, which rewrite every formula the\n change reaches, move the formats with their cells, and undo as one step.\n- The in-cell editor opens with the caret after the text and widens over\n the cells to its right as a long formula is typed. While a formula is\n being written, in the cell or in the formula bar, every range it refers\n to is outlined in its own colour, one rectangle per range, and the\n reference in the text takes the same colour, in the cell and in the bar:\n Excel's range finder. Colours go by distinct range in order of first\n appearance, so `B5:B8` twice is one colour and `b5`, `$B$5` and `B5` are\n one range. `referenceSpans(text)` is the rule, exported.\n\n## The ribbon is Excel's ribbon\n\nThe layout is Excel's, down to the geometry: a group is three rows of 24px,\na **large** button (icon over label) fills a column of that height and\n**small** buttons stack three to a column, so Cut / Copy / Format Painter\nline up beside a tall Paste, as icons alone the way Excel draws them. The\nHome tab runs Clipboard, Font, Alignment, Number, Cells, Styles, Editing in\nExcel's order, with Excel's names on the buttons (Undo and Redo open the\nEditing group as a column of small icons), a dialog-box launcher in the corner of the groups that have a\ndialog, and the group label underneath, which is what makes a ribbon read\nas a ribbon rather than a toolbar.\n\nSome things worth knowing are in there because Excel users reach for them\nwithout looking:\n\n- **Paste is Excel's split button.** The icon pastes; \"Paste\" with its\n chevron under it opens the kinds: Paste, Formulas, Values, Formatting,\n Transpose, and Paste Special... at the end, each raised as an action\n (`paste-formulas`, `paste-values`, `paste-formats`, `paste-transpose`,\n `paste-special`) that the shell answers through its Paste Special engine.\n- **Format Painter is the brush.** Select the cells whose look you want,\n press the brush, and the next click or drag takes it, as one undo: a\n single cell takes the whole block from that corner, a dragged range has\n the block tiled over it, and a cell whose source had no format loses its\n own, as in Excel. The button reads pressed while the brush is up, the\n pointer is a brush over the cells, Enter paints onto the current\n selection, and Escape or the button again puts it down. Raised as\n `format-painter`.\n- **Fill Colour, Font Colour and Borders are split buttons.** The face\n re-applies the last pick (yellow and red to start, as in Excel); the arrow\n opens the menu. The colour menus are Excel's picker: the theme row, five\n tints and shades under each colour, then the standard colours. Borders\n applies to the *selection* the way Excel does: Bottom Border lines the\n bottom edge of the block, All Borders lines every cell, Outside frames it.\n- **The combos show the cell.** Font, size and number format read the\n active cell, so a 17px title says 17 even though 17 is not on the list.\n- **Every format change is one Ctrl+Z**, through the grid's own history,\n so the ribbon's Undo lights up after Bold and undoing it puts the cell's\n whole entry back. A command that writes cells and formats together is\n still one press.\n- **Paste pastes.** It reads the system clipboard the way Ctrl+V does, so\n it needs a secure context; elsewhere it stays quiet, as Ctrl+V does.\n\n**It is responsive the way Excel's is, in Excel's two steps.** When the\nband is narrower than its groups, the groups go compact from the right\nfirst: their small buttons keep the icon and drop the label, so Fill Down\nbecomes an arrow and Wrap Text a glyph, with the title still in the\ntooltip. Only when every group is compact and the band is still too\nnarrow do groups fold, again from the right, into one large button each\n(the group's own icon over its name and a chevron) that opens the group in\na dropdown. Nothing is hidden, only folded; widen the window and it all\ncomes back in the same order. Every width is measured off an invisible\ncopy of the band, never estimated, so a group folds exactly when it would\nnot fit. On a phone the folded band scrolls sideways.\n\n**It collapses the way Excel's does.** Double-click a tab, press the\nchevron at the band's end, or Ctrl+F1, and the band goes away leaving the\ntab row. A click on a tab then shows that tab's band over the sheet until\na command runs, Escape, or a click elsewhere; the pin at its end, a\ndouble-click or Ctrl+F1 brings the band back for good. The state is the\n`collapsed` prop of `SvSheetRibbon`, bindable, and the action is raised\nas `toggle-ribbon` so `onAction` can watch or take it over.\n\n## The ribbon and the keyboard are the same thing\n\nClick a bold cell and the Bold button lights up. Press `Ctrl+B` and it goes\nout. Neither is reacting to the other: both call one function in\n`sheet/shortcuts.ts`, and the button's pressed state is read from the format\nstore rather than tracked separately.\n\nThat is why `sheet/ribbon.ts` is a table of data rather than markup. An item\neither carries a `run` that calls a real action, or an `emits` naming\nsomething the ribbon cannot do alone:\n\n```ts\n{ id: 'bold', label: 'B', title: 'Bold', keys: 'Ctrl+B',\n kind: 'toggle',\n run: (cmd) => toggleFormat(cmd, 'bold'),\n isOn: (cmd) => everyCellHas(cmd, (e) => e?.bold === true) }\n```\n\nButtons that open a dialog set `emits` and raise an action. Every action\nreaches your `onAction` handler first; return `true` to take it over,\notherwise the shell's own dialog opens:\n\n```svelte\n<SvSheet\n {wb}\n onAction={(action, cmd) => {\n if (action === 'goal-seek') { openMyGoalSeekDialog(cmd); return true }\n }}\n/>\n```\n\nThe shell answers everything on the ribbon itself: `recalculate`,\n`toggle-formulas` (Excel's `Ctrl+` `` ` ``), `toggle-filter`, `sort-asc` /\n`sort-desc` / `sort-custom`, and the dialogs listed below. One button has\nnothing behind it in the library, Insert > Table, and is left off the\nribbon unless `extras={['insert-table']}` says the application answers it.\n\n## Props\n\n| Prop | Default | What it does |\n| --- | --- | --- |\n| `document` | one built from `workbook` or `data` | The sheet document: the workbook plus every sheet's formats, sizes, hidden lines, frozen panes, comments, protection, validation and conditional formatting. Build it with `createSheetDocument` to save, restore or listen to it outside the component. |\n| `workbook` | a new empty one | The workbook to edit, when not supplying a document. |\n| `data` | | Seed sheets, when not supplying a workbook. |\n| `rows` / `columns` | 50 / 12 | Minimum size, so a sparse sheet still looks like a sheet. |\n| `height` | 420 | Grid viewport height. |\n| `columnWidth` | 96 | Default column width. |\n| `rowHeight` | 22 | Row height. Excel's is 20 at 100%; 22 fits the 13px cell font. |\n| `look` | `'theme'` | `theme` paints the shell with the host's `--sg-*` tokens, so it follows the app's theme; `excel` pins Excel's own palette and geometry, light or dark with the page. |\n| `columnWidths` | | Per-column overrides keyed by letter: `{ A: 150 }`. |\n| `formats` | | Cell formats keyed by address: `{ B2: { bold: true } }` for the sheet active at mount, `{ 'Orders!F2': ... }` for another sheet. |\n| `showRibbon` / `showFormulaBar` / `showTabs` / `showStatusBar` | `true` | Hide any part of the chrome. |\n| `onAction` | | Every ribbon action, first; return `true` to take one over. |\n| `extras` | `[]` | Whether to show Insert > Table, because the application answers it. |\n| `commentAuthor` | | Who signs a new comment and a reply, with the time; unset, a new comment is a plain note. |\n| `localization` | | `{ locale, text }`: the language of the chrome. `text` overrides any of the shell's strings (see [Localisation](#localisation)); `locale` formats the status bar's numbers and reaches the grid underneath. |\n| `onReady` | | The `SvGridApi` and the document, once the grid has mounted. |\n| `onChange` | | Every change the user lands, once per tick: `cells`, `formats`, `sizes`, `hidden`, `freeze`, `sheets`, `comments`, `protection`, `page-setup`, `objects`, `validation`, `conditional-formats`, `structure` (with the insert or delete), `restore`. Undo and redo report too. |\n\n`formats` matters more than it looks: without it a sheet can only be\nformatted by hand after it loads, which makes it impossible to ship a\ndocument that opens the way it was saved.\n\nA bare address formats the sheet that is active when the shell mounts.\nQualify it to reach another sheet, the way a formula would - `Orders!F2`,\nor `'Price list'!C2` when the name has a space:\n\n```svelte\n<SvSheet\n workbook={wb}\n columnWidths={{ A: 150 }}\n formats={{\n A1: { bold: true, fill: '#e2e8f0', color: '#0f172a' },\n E2: { numFmt: '#,##0' },\n B13: { numFmt: '0.0%' },\n 'Orders!F2': { numFmt: '$#,##0.00' },\n }}\n/>\n```\n\n<div data-docs-demo=\"456-sales-report-workbook\" data-height=\"600\"></div>\n\n## Files\n\nThe File tab is the part of Excel's a document in a page can do: New,\nOpen, Save As, Save As ODS, Save As XLS, Export CSV and Print. Open takes an\n.xlsx, an .ods, an .xls or a .csv from disk - the kind is read from the\nbytes, not from the name - and replaces\nthe document with everything the file holds that the document keeps\n(cells with their formulas, formats, widths and heights, hidden lines and\nsheets, frozen panes, merges, the filter with its criteria and the rows it\nfolds, validation, conditional\nformatting, protection, comments, names, the active sheet); Save As\ndownloads the document as an .xlsx that Excel, Google Sheets and LibreOffice\nopen with the same parts; Save As ODS writes the same document in\nOpenDocument, the format LibreOffice Calc saves by default and one of the\nfiles Google Sheets hands back, with the cells, their formulas in ODF's own\nreference grammar, the number formats, the looks, widths and heights, merges,\nhidden lines, links and notes; Save As XLS writes an Excel 97-2003 workbook,\nthe binary .xls that every Excel since 1997 opens and that a bank or an ERP\nexport still often is; Export CSV downloads the active sheet as its cells\nshow, and Open reads such a file back: the separator is guessed, so a comma,\na semicolon or a tab file all read as themselves, a quoted field keeps its\ncommas and line breaks, and a field that reads as a percentage, a currency\namount or a grouped number lands as the number with the format it implies.\nA CSV field is never read as a formula, as it is not in Excel;\nNew starts over with one empty sheet, asking first when the sheets hold\nanything; Print (Ctrl+P) opens the active sheet in the browser's print\ndialog as its Page Layout says (below). `documentToXlsx` and\n`documentFromXlsx` are the two halves, in `@svgrid/enterprise/sheet`, and\nneed the `jszip` peer; `documentToOds` and `documentFromOds` are the same\npair for OpenDocument; `documentToXls` and `documentFromXls` are the pair for\nthe old binary format, and need nothing, since an .xls is not a zip; and\n`documentFromFile` takes any of the four and decides by the bytes. `csvText`\nand `sheetStateFromCsv` are the CSV pair, and need nothing either.\n\nWhat an .ods carries is the document this package keeps, not every corner of\neither format: cells and their formulas (translated both ways, so\n`=SUM(A1:A3)` here is `of:=SUM([.A1:.A3])` there, and a structured reference\nbecomes the rectangle it names, since ODF has none), number formats, the cell\nlooks, column widths and row heights, merges, hidden rows and columns, the\nfilter with its values and conditions (a date period or a colour has no ODF\nspelling, so its rows go out hidden instead), hyperlinks, notes, defined\nnames and sheet protection. Charts,\nimages, sparklines, validation, conditional formatting and frozen\npanes travel in the .xlsx and not yet in the .ods. A PivotTable travels\nin every format as the cells it wrote, never as a definition: the .xlsx\nhas no pivot part, so Excel sees the block, not a pivot to refresh.\n\nThe .xls is the oldest of the three and carries the least. Cells and their\nformulas travel, with the formulas as the RPN tokens Excel 97 stored rather\nthan as text: a function that format never had (XLOOKUP, LET, TEXTJOIN, a\nstructured reference) has no token, so such a cell goes out as the value it\nworked out, and a workbook opened from an .xls reads those the same way\nround - Excel writes `_xlfn.XLOOKUP` as a defined name and calls it, and\nthat name turns back into the function here. Number formats, fonts, colours,\nfills, borders, alignment, column widths, row heights, hidden rows, columns\nand sheets, merges, frozen panes, sheet protection and defined names travel;\ncharts, pictures, sparklines, validation, conditional formatting and\ncomments do not, since the format kept them somewhere this reader does not\ngo. A filter goes out as the rows it folds, hidden, and nothing else: the\nfile shows the same rows, and the arrows do not come back from it. The sheet is 65,536 rows by 256 columns, and anything past that edge is\nleft out rather than written wrong.\n\nAn app that keeps its workbooks somewhere other than the user's disk takes\nthe actions over through `onAction` (`file-open`, `file-save-xlsx`,\n`file-save-ods`, `file-save-xls`, `file-new`, `file-export-csv`,\n`file-print`) and calls the component's\nown methods: `open(file)` replaces the document with an .xlsx, .ods, .xls or\n.csv Blob, `toXlsx()` returns the document as a Blob, `toOds()` the same\ndocument in OpenDocument, `toXls()` it as an Excel 97-2003 workbook,\n`toCsv()` the active sheet as text, `newWorkbook()` empties it, `print()` opens the print dialog and\n`printHtml()` returns the page it would print, for an app that prints\nits own way.\n\n```svelte\n<SvSheet bind:this={sheet} onAction={(action) => {\n if (action === 'file-save-xlsx') { sheet.toXlsx().then((blob) => upload(blob)); return true }\n if (action === 'file-open') { pickFromServer().then((blob) => sheet.open(blob)); return true }\n}} />\n```\n\nDates are `yyyy-mm-dd` text in the sheet and serial numbers in the file:\na text date goes out as a serial under a date format, and a serial under a\ndate format comes back as text. Tables, charts and images are not carried\neither way.\n\n## Page Layout and Print\n\nThe Page Layout tab is Excel's Page Setup group and Sheet Options, per\nsheet and part of the document: Margins (Normal, Narrow, Wide),\nOrientation, Size (Letter, Legal, Tabloid, A3, A4, A5), Print Area (Set\ntakes the selection, Clear puts the whole sheet back), Print Titles (the\nrows that repeat at the top of every page), the group's launcher opening\nthe Page Setup dialog with all of it and the scale, and Print Gridlines\nand Print Headings. Each change is one undo and reports\n`{ kind: 'page-setup' }`.\n\nFile > Print (Ctrl+P) lays the sheet out as one HTML document and hands it\nto the browser's own print engine, so fonts, CJK and RTL come out right\nand nothing is bundled: the print area or the used range, column widths\nand row heights as the sheet shows them, hidden lines and the rows a filter\nfolds away left out, merges as\nspans, every cell as it shows with its format and its conditional style,\nthe title rows repeated on each page, the sparklines drawn in their cells\nand the charts and pictures hung from theirs, a table's header and banding\nin the colours it wears on the sheet, gridlines and headings when\nasked, and the orientation, paper and margins in `@page`. \"Save as PDF\" in that\ndialog is the PDF. The setup rides in `getState()` as `pageSetup` and in\nthe xlsx as `pageSetup`, `pageMargins`, `printOptions` and Excel's own\n`Print_Area` and `Print_Titles` names, both ways.\n\n## Saving and restoring\n\nEverything the user does lands in the sheet's document: the workbook (raw\ntext, formulas as typed, names, the active sheet), and per sheet the\nformats (locked flags included), column widths, row heights, hidden lines,\nfrozen panes, comments, the protection flag with its allow list and edit\nranges, the page setup, the charts and pictures on the sheet, the\nvalidation rules and the conditional formatting rules.\n`getState()` returns it as plain JSON and\n`setState()` puts it back into the same workbook object, so an autosave\nis an `onChange` handler and a reload is one call after mount:\n\n```svelte\n<script lang=\"ts\">\n import { SvSheet, type SheetState } from '@svgrid/enterprise'\n\n let sheet = $state<SvSheet>()\n let timer: ReturnType<typeof setTimeout> | undefined\n\n function autosave() {\n clearTimeout(timer)\n timer = setTimeout(() => {\n if (sheet) localStorage.setItem('budget', JSON.stringify(sheet.getState()))\n }, 400)\n }\n\n const saved = localStorage.getItem('budget')\n</script>\n\n<SvSheet\n bind:this={sheet}\n onChange={autosave}\n onReady={() => { if (saved) sheet?.setState(JSON.parse(saved) as SheetState) }}\n/>\n```\n\n### Autosave to a server\n\nThe same handler with a `fetch` in it, plus the three things a network\nmakes you think about:\n\n```ts\nlet timer: ReturnType<typeof setTimeout> | undefined\nlet saving: Promise<unknown> = Promise.resolve()\nlet revision = 0\n\nfunction autosave() {\n clearTimeout(timer)\n // Coalesce a burst of edits into one request. onChange already coalesces\n // per tick; this coalesces per pause in the typing.\n timer = setTimeout(() => {\n const state = sheet!.getState()\n // One request at a time, in order: a save that overtakes its\n // predecessor writes an older document over a newer one.\n saving = saving.then(async () => {\n const response = await fetch(`/api/books/${id}`, {\n method: 'PUT',\n headers: { 'content-type': 'application/json', 'if-match': String(revision) },\n body: JSON.stringify(state),\n })\n if (response.status === 412) return onSomeoneElseSaved()\n revision = Number(response.headers.get('etag') ?? revision + 1)\n })\n }, 800)\n}\n```\n\n- **Debounce, then serialise.** `getState()` is a full snapshot, so the\n cost is in the transfer, not the call. One request per pause, and never\n two in flight, or a slow request lands after a fast one and the older\n document wins.\n- **Carry a revision.** Send what you last saw and let the server refuse a\n save built on a stale document, rather than discovering the overwrite\n later. `412` is the moment to reload, or to ask.\n- **Send changes instead, when there are two people.** A whole state per\n keystroke is fine for one user and wrong for two. `createDeltaStream`\n (see \"Two people on one sheet\") gives a delta per change, small enough to\n send as it happens, and applies one from someone else without a reload.\n\nA save that runs on a timer rather than on `onChange` is worse, not\nbetter: it saves when nothing changed and misses the tab closing. If the\ntab closing matters, save on `visibilitychange` as well, with the same\nsnapshot.\n\n`onChange` is called once per tick with every reason since the last call,\nso a paste of forty cells is one call, and it says what changed\n(`reasons.some((r) => r.kind === 'structure')` for an insert or delete, with\nthe edit on the reason). A change to the workbook's own parts rather than a\nsheet's, its names or its calculation settings, reports `workbook`; a table\nreports `tables`. A host writing those straight to the workbook raises the\nsame reason with `document.changed({ kind: 'workbook' })`, which is what\nputs it on a delta stream. `setState` clears the grid's undo history, since\nnone of it describes the restored sheet, and reports a single `restore`.\n\nThe same document is available outside the component: build it with\n`createSheetDocument({ sheets })` or `createSheetDocument({ state })`, pass\nit as `document`, and call `document.getState()`, `document.setState()` and\n`document.subscribe()` on it directly. A document built this way can be\nsaved and restored in Node, with no component at all.\n\nThe shape, `SheetState` (exported with `SheetStateEntry`, `ValidationRule`\nand `CfRule`):\n\n```ts\n{\n version: 1,\n workbook: {\n sheets: [{ name, cells }], active, names,\n tables: [{ name: 'Orders', sheet: 'Budget', headerRow: 0, firstCol: 0, lastCol: 4, lastRow: 12, hasTotals: false }],\n iteration: { enabled: true, maxIterations: 100, maxChange: 0.001 },\n },\n sheets: {\n Budget: {\n formats: { 'r0 A': { bold: true, numFmt: '#,##0', locked: false } },\n columnWidths: { A: 150 },\n rowHeights: [[4, 44]],\n hidden: { rows: [7], cols: [] },\n freeze: { rows: 1, cols: 0 },\n comments: { r1: { B: 'Check with finance' }, r4: { D: { text: 'Over budget?', author: 'Ana', at: '2026-03-04T10:00:00.000Z', replies: [{ text: 'By 3%', author: 'Ben', at: '2026-03-04T11:00:00.000Z' }] } } },\n protected: false,\n protection: { allow: { formatRows: true }, ranges: [{ id, title: 'Inputs', rects: [[1, 1, 9, 1]] }] },\n pageSetup: { orientation: 'landscape', paper: 'A4', margins: { top: 0.75, bottom: 0.75, left: 0.7, right: 0.7, header: 0.3, footer: 0.3 }, printArea: null, printTitleRows: [0, 0], gridlines: false, headings: false, scale: 100 },\n objects: [{ id, kind: 'chart', anchor: { row: 12, col: 1, dx: 8, dy: 8, width: 420, height: 260 }, range: [0, 0, 11, 2], type: 'bar', headers: true, series: 'columns' }],\n sparklines: [{ id, location: [1, 6, 9, 6], data: [1, 1, 9, 5], type: 'line', color: '#2563eb' }],\n pivots: [{ id, source: [0, 0, 200, 3], target: { row: 0, col: 8 }, rows: ['Region'], cols: [], values: [{ field: 'Amount', agg: 'sum' }] }],\n links: { r1: { B: { target: 'https://example.com', tip: 'The spec' } } },\n merges: [],\n validation: [{ id, rects, allow: 'whole', operator: 'between', value1: '1', value2: '10', ignoreBlank: true, inCellDropdown: false, alert: { style: 'stop' } }],\n conditionalFormats: [{ id, rects, kind: 'cellIs', operator: 'greater', value1: '50000', style: { fill: '#FFC7CE', color: '#9C0006' } }],\n },\n },\n}\n```\n\nRows are `r` plus the 0-based index and columns are letters, a format key is the two with a\nspace between; rectangles are `[minRow, minCol, maxRow, maxCol]`,\n0-based. A hidden row keeps the height it had before it was hidden, not\nthe 0 it shows while hidden, so an Unhide after a restore brings it back\nat its size.\n\n## A component, not a mode\n\nKanban, the scheduler and charts are modes of `<SvGrid>` (`board`,\n`scheduler`, `chart`) because they are different pictures of the same\nrows: the grid keeps the data, the row model, sorting and filtering, and the\nmode draws cards, a calendar or a chart from them. The spreadsheet is the\nother way round: the same picture, a table, over a different data model. It\nowns a document (formulas, address-keyed formats, comments, rules) and uses\nthe grid as its rendering primitive, which is why it is a component that\ncomposes `<SvGrid>` rather than a prop on it. `installEnterprise()` enables\nit along with everything else; there is nothing extra to register.\n\n## Rows into a sheet\n\n`sheetCellsFromRows(rows, fields, { totals })` turns an array of records\ninto the cells `data` or `createWorkbook` take: a header row of labels, a\nrow per record with each value as the text the engine reads (a number as\nits digits, a boolean as `TRUE` or `FALSE`, a `Date` as `yyyy-mm-dd`,\nnothing for null), and with `totals` a last row with `=SUM(...)` under\nevery column that held a number. It is how a grid's rows, or an API's,\nbecome a sheet the user can add formulas to; Studio's Spreadsheet block\nis this over the screen's dataset.\n\n```svelte\n<script lang=\"ts\">\n import { SvSheet, sheetCellsFromRows } from '@svgrid/enterprise'\n\n const cells = sheetCellsFromRows(orders, [{ field: 'item', label: 'Item' }, 'qty', { field: 'price', label: 'Price' }], { totals: true })\n</script>\n\n<SvSheet data={[{ name: 'Orders', cells }]} />\n```\n\n## Writing to the workbook from outside\n\nEvery edit made through the shell repaints it: typing, the ribbon, a\nshortcut, the fill handle, and `cmd.setCellValue` from an `onAction`\nhandler, which is also how a dialog should write a cell - the write lands in\nthe grid's undo history and the shell sees it.\n\nA write the shell cannot see does not repaint: a Name Manager redefining a\nname, an import replacing a sheet, a solver applying its answer with\n`wb.setRaw`. Call `refresh()` on the component afterwards:\n\n```svelte\n<script>\n let sheet\n</script>\n\n<SvSheet bind:this={sheet} workbook={wb} onAction={(action, cmd) => {\n if (action === 'name-manager') { openNameManager(wb.names, () => sheet.refresh()); return true }\n}} />\n```\n\n`act(action)` runs a ribbon action as if its button had been clicked\n(`sheet.act('sort-asc')`, `sheet.act('circle-invalid')`), for a host with\nchrome of its own; what the ribbon would raise for `onAction` is raised\nhere too.\n\nThe shell carries Goal Seek, Text to Columns, Remove Duplicates and the\nName Manager itself. The demos below each replace one with a dialog of\ntheir own, returning `true` from `onAction`, and show the write paths a\nreplacement should use: Goal Seek applies through `cmd.setCellValue`, Text\nto Columns and Remove Duplicates write inside `cmd.batch` so each is one\nCtrl+Z, and the Name Manager calls `refresh()`.\n\n<div data-docs-demo=\"457-goal-seek\" data-height=\"600\"></div>\n\n<div data-docs-demo=\"458-data-cleanup\" data-height=\"560\"></div>\n\n## Theming\n\nEvery part of the shell is painted with the grid's `--sg-*` tokens, so\nby default it follows the app's theme the way the grid does: the accent\non the selection and the active tab, the header and ribbon grounds, the\nfont and the corner radius are the host's. The icons are one set of line\nglyphs drawn in the text colour, black on a light ribbon and white on a\ndark one, as Excel's monochrome set is; the only colour on the ribbon is\nthe bar under Fill Colour and Font Colour, which is the colour the button\nwill apply.\n\n`look=\"excel\"` pins Excel's palette instead: the shell sets the tokens on\nits own root to Office's light theme, or its dark theme when the page is\ndark, so it reads as Excel whatever theme the app around it runs, and the\ngrid inside picks the same tokens up. Nothing else changes: the geometry,\nthe icons and the layout are the same either way.\n\n## Using the parts separately\n\n`SvSheet` is a composition of exported components, so anything it does you\ncan do by hand when you want a different arrangement:\n\n```svelte\n<SvSheetRibbon cmd={() => api?.getCommandContext() ?? null} onChange={refresh} />\n<SvFormulaBar {active} value={raw} onCommit={commit} />\n<SvGrid ... />\n<SvSheetTabs workbook={wb} {version} onChange={refresh} />\n```\n\n`api.getCommandContext()` is the seam that makes this work. A keyboard\ncommand is handed a context on every keystroke; a ribbon button has no\nkeystroke to ride in on, and without it every action would have to be\nre-implemented against the public api and kept in step by hand.\n\nFormulas > Formula Auditing has Excel's arrows: Trace Precedents draws\na blue arrow from each cell the active formula reads to the formula, a\ndot on the cell read and an arrowhead on the formula, and pressing it\nagain traces one more level out; Trace Dependents draws the arrows the\nother way, to the formulas that read the active cell, every formula in\nthe workbook counted whether or not it has been on screen; Remove Arrows\nclears them, as does a switch to another sheet. Cells on other sheets are\nleft out, since there is nothing on this sheet to draw to. The arrows\nare measured from the rendered cells and follow every scroll and\nrepaint; they are not part of the document. `wb.precedents(sheet, row,\ncol)` and `wb.dependents(sheet, row, col)` are the two readings on the\nworkbook. Raised as `trace-precedents`, `trace-dependents` and\n`remove-arrows`.\n\nThe same group carries the two dialogs that answer \"why does this cell say\nthat?\". **Evaluate Formula** shows the active cell's formula with one part\nunderlined, and each click on Evaluate replaces that part with what it is\nworth, until the formula has collapsed into the cell's answer; Step Back and\nRestart walk it again. A branch of an `IF` that is not taken is never\nevaluated, so it never reports an error the cell does not have, and a range\nsuch as `A1:A9` stays where it is because a block is not a value.\n\n**Error Checking** walks the cells on the sheet worth a second look, one at\na time, with the selection following: every formula whose value is an error,\nsaid in a sentence rather than a code, and every formula that breaks the\npattern of the ones above and below it. That second check is deliberately\nquiet. A formula is only called the odd one out when the cells above and\nbelow it both hold formulas, those two agree with each other once\ntranslated, and this one does not, which is the shape of a column filled\ndown and then broken in the middle. Show Calculation Steps hands the cell\nstraight to Evaluate Formula.\n\nBoth are engine functions first, so a save-time check or a report can use\nthem with no browser:\n\n```ts\nimport { evaluationSteps, checkSheet } from '@svgrid/enterprise'\n\nconst steps = evaluationSteps(parseFormula(text), (part) => wb.evaluateText(sheet, part))\nconst problems = checkSheet(sheet, {\n rowCount: () => wb.rowCount(sheet), colCount: () => wb.colCount(sheet),\n getRaw: (r, c) => wb.getRaw(sheet, r, c), getValue: (r, c) => wb.getValue(sheet, r, c),\n})\n```\n\nRaised as `evaluate-formula` and `error-checking`.\n\n## The dialogs\n\nFour of Excel's dialogs are the shell's own, so nothing on the ribbon or in\nthe cell menu is a button that does nothing:\n\n| Dialog | Opens from |\n| ------ | ---------- |\n| Find and Replace | `Ctrl+F` or `Ctrl+H`, Find & Select on the ribbon. Find Next, Find All, Replace, Replace All; match case, whole cell, look in values or formulas. Replace All is one undo. |\n| Paste Special | `Ctrl+Shift+V`, the Clipboard group's launcher, the last entry under the Paste arrow, the cell menu. All / Formulas / Values / Formats, Add / Subtract / Multiply / Divide, Skip blanks, Transpose. Works on what Ctrl+C took from the sheet. |\n| Chart | A double-click on a chart, or Insert > Setup while one is selected. Type (Column, Line, Area, Pie, Scatter), title, series in columns or rows, whether the first row and column are labels, stacking, a trendline over every series and one series on a secondary axis; Delete removes the chart. |\n| Page Setup | Page Layout > Print Titles, the Page Setup group's launcher. Orientation, paper, margins, scale, print area, rows to repeat at top, gridlines and headings; Print... applies and prints. |\n| Protect Sheet | Review > Protect Sheet. Excel's \"allow all users of this worksheet to\" list; OK protects with what is ticked. |\n| Allow Edit Ranges | Review > Allow Edit Ranges. Titled blocks that take an edit on a protected sheet; New over the selection, Modify, Delete, Protect Sheet... |\n| Format Cells | `Ctrl+1`, the launchers on the Font, Alignment and Number groups, the cell menu, and the end of Home > Cells > Format. Number (category, decimals, separator, the Accounting symbol, the Special type, custom code, live sample), Alignment (horizontal, vertical top/middle/bottom, wrap, indent), Font, Border presets, Fill, Protection. Opens on the active cell's format and applies only what was changed to the whole selection, as one undo. |\n| Insert Function | the `fx` button. Search or pick a category, read the signature and what the function does; OK starts the cell on `=NAME(` with the caret inside. |\n| Name Manager | Formulas > Name Manager, `Ctrl+F3`. Every defined name with what it refers to and its value; edit, delete, add. |\n| Goal Seek | Data > Goal Seek. Set a formula cell to a value by changing one input; the status page shows the answer and OK keeps it as one undo. |\n| Create Table | Insert > Table, `Ctrl+T`, and again on a cell inside a table, which is how one is renamed, resized or restyled. The range (the selection, or the region around the active cell), the name its columns are read by, whether the first row is the header, a totals row, and the styles gallery. A totals row asked for is ADDED under the data, as Excel's Total Row is, with Total in the first column and `=SUBTOTAL(109,[Last column])` in the last; unticked, it goes. A structured reference is a range to SUBTOTAL, so a totals row's formulas skip the rows a filter folds. Insert > Table Styles opens the same dialog on the table the cursor is in. |\n| Evaluate Formula | Formulas > Evaluate Formula. The active cell's formula with the next part underlined; Evaluate replaces it with its value, Step Back and Restart walk it again. |\n| Error Checking | Formulas > Error Checking. Every cell on the sheet that reports an error, and every formula that breaks its column's pattern, walked with Previous and Next; Show Calculation Steps opens Evaluate Formula on the cell. |\n| Calculation Options | Formulas > Calculation Options. Excel's Enable iterative calculation, with the maximum passes and the smallest change worth another one; OK recalculates, so a circular reference goes from #CYCLE! to its fixed point, or back. See Iterative calculation below. |\n| Sort | Data > Sort. A level per key, each a column (named from the header row when \"My data has headers\" is on; the guess looks across every column of the block, so a Name column over names still reads as a header when the Pay column beside it is text over numbers, as Excel guesses it), what to sort on and an order; Add Level and Delete Level; the block is the selection or the region around the active cell. Numbers sort before text, blanks go last, ties keep their order, formats and one-row merges ride with their rows, and it is one undo. A level can sort on the cell colour or the font colour instead of the value: the list offers the colours that column carries, and the one picked goes On Top or On Bottom while every other row keeps its order, which is Excel's model, because two colours are not greater or lesser than one another. Sort A to Z and Z to A beside it sort on the active cell's column. |\n| Text to Columns | Data > Text to Columns. The delimiter is guessed from the column, the preview shows the split, Finish writes it as one undo. |\n| Remove Duplicates | Data > Remove Duplicates. Tick the columns that decide a duplicate, say whether the first row is headers; the count goes to the status bar. |\n| Data Validation | Data > Data Validation. Settings (Allow, Data, the bounds or the source, Ignore blank, In-cell dropdown) and Error Alert (Style, Title, Message); OK puts one rule over the selection, Clear All removes it. See Data validation below. |\n| Conditional formatting | Home > Styles > Conditional Formatting: Greater Than..., Less Than..., Between..., Equal To..., Text that Contains..., Duplicate Values..., Top 10 Items..., Bottom 10 Items..., Above Average..., Below Average... and New Rule > Use a Formula... each open the small dialog (the value or values, or the formula, and the \"with\" style); Manage Rules... opens the Rules Manager. See Conditional formatting below. |\n\nHome > Cells > Insert and Delete are Excel's split buttons. The face takes\nthe selection's axis: a whole column selected inserts or deletes columns,\nanything else rows, which is what Excel does with a cell selected. The\narrow spells the axis out, so a column goes in from a cell without\nselecting the column first: Insert Sheet Rows, Insert Sheet Columns and\nInsert Sheet (Shift+F11); Delete Sheet Rows, Delete Sheet Columns and\nDelete Sheet, which asks the tab menu's question first when the sheet\nholds anything (raised as `delete-sheet`). The cell menu and the column\nand row header menus offer the same Insert and Delete for their axis, and\nCtrl+Shift++ and Ctrl+- take a whole row or column selection.\n\nHome > Cells > Format is Excel's menu: Row Height..., AutoFit Row Height,\nColumn Width... and AutoFit Column Width for the rows and columns the\nselection spans (`row-height`, `autofit-rows`, `column-width`,\n`autofit-columns`; an AutoFit is one undo), Hide Rows, Hide Columns, Unhide\nRows and Unhide Columns with their keys, Lock Cell under Protection (a\ntoggle, lit while the active cell is locked, raised as `toggle-lock`), and\nFormat Cells... at the end. A ribbon item of kind `dropdown` is that shape:\nthe face opens the list and every entry raises its own action.\n\n### AutoFilter\n\nExcel's Filter: Ctrl+Shift+L, Home > Editing > Filter or Data > Filter puts\nan arrow on every header cell of the current region (the block around the\nactive cell, its first row the headers; a selected range is the region\ninstead). An arrow drops Excel's menu: Sort A to Z and Z to A (Smallest to\nLargest on a column of numbers), Clear Filter From the column, Filter by\nColor when the column's cells carry more than one fill (its own or a\nconditional format's, No Fill included), Date Filters on a column of\ndates (Today, This Week, Last Month, This Quarter, Year to Date and the\nrest, or Equals, Before, After and Between with a typed date; weeks run\nSunday to Saturday), Top 10 on a column of numbers (top or bottom, so\nmany items or so many percent, counted over the whole column), Text\nFilters or Number Filters (equals, does not equal, begins with, contains,\ngreater than, between and the rest, two of them joined with And or Or), a\nsearch box, (Select All) and the column's values with their counts, the\nblanks last. A search narrows the list and OK (or Enter in the box)\napplies what it shows, as Excel does; Alt+Down on a header cell drops the\nmenu. OK folds the rows that fail; the arrow turns into a funnel,\nthe row numbers of the region turn blue and the status bar reads \"N of M\nrecords found\". A folded row is the filter's, not a hidden row: Unhide\nleaves it, `getState().hidden` leaves it out, and Filter off (Ctrl+Shift+L\nagain) shows it while a row hidden by hand stays hidden. The rows are\nworked out again after every change, so a formula that drops out of a\nNumber Filter folds away and one that comes back returns, as Excel's\nReapply would; a sort from the menu keeps the header row where it is.\nConditions run through the grid's own Excel-filter compiler\n(`@svgrid/grid/filtering`), so the operators are the ones the grid's\nfilter row has. The filter is per sheet, moves with an insert or delete,\nrides in `getState()` as `autoFilter` (`{ range, filters }`, a filter\nbeing `values`, `condition`, `date`, `color` or `top`) and reports\n`{ kind: 'filter' }` on `onChange`. Raised as `toggle-filter`.\n`hiddenRowsFor` takes a `fillAt` reader for the colour filters and a\n`today` for the date periods; `datePeriodBounds` is the calendar on its\nown.\n\n### Merged cells\n\nExcel's Merge & Center, on Home > Alignment as Excel's split button: the\nface merges the selection into one cell and centres it, the arrow opens\nMerge & Center, Merge Across (one merge per row), Merge Cells (no centring)\nand Unmerge Cells; the cell menu offers Merge & Center on a range and\nUnmerge Cells on a merge, and Merge & Center on a merged cell unmerges it.\nA merge keeps the top-left cell's value and drops the rest, so a merge\nover values asks first, with Excel's words; the clears, the centring and\nthe merge are one undo. The grid draws a merge as one cell (its\n`mergedCells`): it is selected as one, the arrow keys step over it, a\nclick anywhere on it lands on its top-left cell, which is what the formula\nbar shows and an edit writes, and the covered cells take no entry, fill or\npaste. A merge moves with an insert or delete (and grows when rows are\ninserted inside it), a one-row merge rides with its row through a sort,\nand a sort over a range a merge crosses is refused with Excel's sentence.\nMerges are per sheet, ride in `getState()` as `merges`\n(`[minRow, minCol, maxRow, maxCol]`) and report `{ kind: 'merges' }` on\n`onChange`. Raised as `merge-center`, `merge-across`, `merge-cells`\nand `unmerge-cells`.\n\n### Conditional formatting\n\nExcel's rules that colour cells by what they hold, on Home > Styles >\nConditional Formatting. Highlight Cells Rules (Greater Than, Less Than,\nBetween, Equal To, Text that Contains, Duplicate Values) and Top/Bottom\nRules (Top 10 Items, Bottom 10 Items, Above Average, Below Average) open\nthe small dialog: the value or values (a literal or a formula, so\n`=$B$1` follows B1) and the \"with\" style, Excel's Light Red Fill with\nDark Red Text and the rest. Data Bar, the Green - Yellow - Red and Green -\nWhite Color Scales and the Icon Set (3 Arrows) apply to the selection at\nonce; a data bar measures from zero, as Excel's do, so 10, 20 and 40 draw\na quarter, a half and the full width. Clear Rules from Selected Cells cuts the selection out of every rule\nit touches; Clear Rules from Entire Sheet empties the sheet's rules.\nManage Rules... is the Rules Manager: every rule in priority order with what\nit does, a sample and where it applies, Edit Rule (for the rules the\ndialog can edit), Delete Rule, Move Up, Move Down and Stop If True, for\nthis worksheet or the current selection; OK applies the lot as one undo.\n\nRules are evaluated over the COMPUTED values, so a Greater Than on a\ncolumn of formulas follows their results and a cell drops out when an\ninput changes. Priority is the Rules Manager's order (a new rule goes\nfirst, as in Excel): the first rule that decides a property (the fill,\nthe text colour, the bar, the icon) keeps it, and a rule with Stop If True\nstops the rules below it for the cells it matches. A rule's fill and text\ncolour paint over the cell's own format, as they do in Excel. The rules\nare per sheet, move with an insert or delete, ride in `getState()` as\n`conditionalFormats` and report `{ kind: 'conditional-formats' }` on\n`onChange`.\n\nNew Rule > Use a Formula... is Excel's \"format values where this formula\nis true\": the formula is written for the top-left cell of the selection\nand moves with each cell as a copied formula would, so `=$B2>100` on\nA2:A9 reads each row's B, and TRUE (or a number other than 0) formats.\nThe rule is `{ kind: 'formula', formula, style }`. A data bar over a range\nwith negatives puts its axis where zero falls between the minimum and the\nmaximum, and the bars grow away from it: right for a positive value, left\nand in red (or the rule's `negativeColor`) for a negative one. `evaluateCf`, `ruleStats`, `removeCf`, `shiftCf`,\n`describeCf` and the rule types are exported from\n`@svgrid/enterprise/sheet`; the grid's own value-driven\n[conditional formatting](./conditional-formatting.md) is a different thing\n(rules by column over raw values) and the shell does not use it.\n\nFreeze Panes lives on the View tab, Excel's dropdown: Freeze Panes (at the\nactive cell), Freeze Top Row, Freeze First Column, Unfreeze Panes\n(`freeze-panes`, `freeze-top-row`, `freeze-first-column`,\n`unfreeze-panes`). Beside it, View > Show has Excel's three toggles:\nGridlines, Formula Bar and Headings (the row numbers and column letters),\nraised as `toggle-gridlines`, `toggle-formula-bar` and\n`toggle-headings`. They are view settings of the component, not part of\nthe document, so `getState()` leaves them out.\n\n### Data validation\n\nExcel's rules over cells: Data > Data Validation opens on the rule at the\nactive cell and puts one over the selection. Allow is Any value, Whole\nnumber, Decimal, List, Date, Text length or Custom; the bounds (between,\nnot between, equal to, greater than and the rest) are literals or\nformulas, so `=$B$1` as a maximum follows B1 and a relative `=A2` on\nG2:G17 reads the same row's A, as in Excel; a List is a comma list or a\nrange or a name starting with `=`; a Custom formula is written for the\ntop-left cell of the selection and moves with each cell, TRUE (or a number\nother than 0) allowing the entry. A typed entry that breaks the rule never\nreaches the workbook: the alert box shows the rule's title and message (or\nExcel's own words) with Retry, which reopens the editor with the entry,\nand Cancel; a Warning rule asks \"Continue?\" and Yes lets the entry through.\nThe formula bar is checked the same way. Only typed entries are checked,\nas in Excel: paste, fill, Ctrl+D and the commands write what they are\ngiven.\n\nA List rule with In-cell dropdown draws an arrow on the active cell; the\narrow, or Alt+Down, drops the list (arrows and Enter pick, Escape leaves\nthe cell alone), and a pick is one undo. A typed entry is matched against\nthe list without regard to case.\n\nThe dialog's Input Message tab gives a rule a title and a message that\nshow in a small box under the cell while it is selected, as Excel's do;\nthe rule carries it as `input`. The arrow beside Data Validation has\nCircle Invalid Data, which draws Excel's red oval on every cell under a\nrule whose current contents break it (pasted, filled or written before the\nrule was), and Clear Validation Circles. The circles are worked out again\non every repaint while they are on, so a corrected cell loses its circle\nat once; switching sheets turns them off. `invalidCells` is the same check\nfor a shell of your own. Raised as `circle-invalid` and `clear-circles`.\n\nThe rules are per sheet, move with an insert or delete (and drop when\ntheir cells go), ride in `getState()` as `validation` and report\n`{ kind: 'validation' }` on `onChange`. `ruleAt`, `checkEntry`,\n`listChoices`, `removeValidation` and `shiftValidation` are exported\nfrom `@svgrid/enterprise/sheet` for a shell of your own, with\n`wb.evaluateText` and `wb.evaluateRange` evaluating a bound or a list\nsource in the sheet without storing it. Raised as `data-validation` and\n`open-list`.\n\n### Objects: charts and pictures\n\nInsert > Chart charts the selected block, or the region of data around\na single selected cell, as Excel starts one: the first row and column are\nread as the labels when they look like labels, each column of the block\nis a series, and the chart is anchored just under the block. It reads the\n**range**, not a copy of the numbers, so editing a cell redraws it.\nInsert > Picture puts an image on the sheet from a file, carried in the\ndocument as a data URL. A file the .xlsx cannot hold as it is (an SVG, say:\nExcel keeps only a few raster types in `xl/media`) is drawn to a PNG on\nthe way in, so what is on the sheet is what the file will carry; a picture\nwhose source is a web address is left out of the file, and Save As says\nso in the status bar.\n\n**`=IMAGE(source, [alt])`** is the other kind of picture, and the\ndifference is the point: an `IMAGE` cell IS the picture rather than\nfloating over one. It sorts with its row, filters with it, copies as a\nformula and moves when the cells move, with nothing to keep in step, which\nis what a catalogue with a thumbnail column wants. The source is a web\naddress or a `data:` URL; anything else stays text rather than becoming a\nbroken image. The second argument is the alt text, worked out like any\nother argument, so a screen reader is told what the picture is. The\nfunction's own value is the source, so a cell that reads it gets an\naddress rather than a picture it cannot use, and the file stores it as\n`_xlfn.IMAGE`, which is where Excel keeps it.\n\nAn object floats over the cells rather than living in them: drag it to\nmove, drag its corner to resize, press Delete to remove it, and\ndouble-click a chart (or Insert > Setup) to open the Chart dialog, where a\ntrendline (linear or a three-point moving average) goes over every series\nand one series can be moved to a secondary axis on the right, which is\nwhat makes a revenue-and-margin chart readable when the two are orders of\nmagnitude apart. Each\nchange is one undo. It hangs from a cell and an offset inside it, the way\nExcel's does, so inserting a row above moves it, deleting that row takes\nit with it, and resizing a column under it moves it without reshaping it.\n\nObjects are per sheet, ride in `getState()` as `objects` and report\n`{ kind: 'objects' }` on `onChange`. The chart's own drawing is the free\n`<SvChart>` from `@svgrid/grid`, so its types, palette and tooltips are\nthe grid's. Raised as `insert-chart`, `insert-picture`, `chart-setup`\nand `delete-object`, so an application can put its own chart builder in\ntheir place.\n\nObjects print with the sheet. File > Print hangs each one from its anchor\ncell with the offset and size it has on the page, and the default print\narea grows down and across to hold a chart anchored below the numbers,\nwhich is where a chart usually is. A named print area is honoured exactly\nas it stands, so an object outside it is left out.\n\nObjects ride in the .xlsx both ways, as Excel's own drawing part. A\npicture's bytes go into `xl/media` and a chart becomes a chart part of its\nown, both anchored to their cell; a file being opened gives its pictures\nand charts back the same way. A chart part carries the REFERENCES its\nseries read rather than a copy of the numbers, so Excel redraws it from\nthe cells beside it instead of from a snapshot that can drift. The one\nthing left behind is a picture whose source is a URL rather than a `data:`\nURL: its bytes are somewhere else, and a file with a broken image in it is\nworse than one without the image.\n\n### Hyperlinks\n\nTwo ways to put a link in a cell, both Excel's.\n\n**Insert > Link**, or Ctrl+K, puts a link ON the cell: the address, the\ntext to display, and a ScreenTip. The cell keeps whatever it says and the\nlink is kept beside it, so editing the text keeps the link, a format\nchange keeps it, and clearing the cell takes it away. Insert > Remove\ntakes the links off the selection. The cell menu has Excel's entries too:\nLink... on a plain cell; Edit Link..., Open Link and Remove Link on one\nthat carries a link. Each is one undo.\n\n**`=HYPERLINK(link, [friendly])`** puts one in a formula, showing the\nfriendly name. A cell holding that formula is clickable too, and its\ntarget is whatever the first argument works out to, so a link built from\nits neighbours (`\"…/issues/\" & A2`) goes where it says.\n\nA target that reads like an address moves the selection instead of leaving\nthe page: `Sheet2!B4`, `B4`, or a defined name, switching sheets when it\nhas to. Anything with a scheme, or a bare `www.`, opens in a new tab. A\nsingle click follows the link and a drag from the same cell selects, which\nis Excel's rule.\n\nOnly the schemes a link can sensibly mean are followed: `http`, `https`,\n`mailto`, `tel`, `sms`, `ftp` and `ftps`. A target carrying any other one,\n`javascript:` among them, is not a link at all: the cell keeps its text,\nnothing is clickable, the target is never written into a saved file, and one\nfound in a file being opened is dropped on the way in rather than carried\ninto the document. A\nlink is data, arriving from a colleague, a delta stream or an opened file,\nso what may be followed is named rather than assumed. Insert > Link refuses\nsuch an address in the dialog and names the ones a cell does open, and\nclicking a cell that already carries one says the same thing in the status\nbar rather than pretending the address is empty or somewhere on this\nworkbook.\n\nLinks are per sheet, ride in `getState()` as `links`, report\n`{ kind: 'links' }` on `onChange`, and move with an insert or a delete.\nThey go into the .xlsx as real hyperlinks, an external one as a\nrelationship with `TargetMode=\"External\"` and an internal one as a\n`location`, tooltips included, and a file opened back carries both.\nRaised as `insert-link` and `remove-link`.\n\n### Iterative calculation\n\nA circular reference is normally an error, and the shell says so: every\ncell in the loop shows `#CYCLE!` while the rest of the sheet keeps working.\nSome models are circular on purpose, because the answer is a fixed point: a\nbonus that is a tenth of the profit the bonus is taken out of, interest\ncharged on the balance the interest is part of.\n\n**Formulas > Calculation Options** is Excel's switch for those, raised from\nFile > Options because it is the one setting there that changes what a\nformula is worth. It takes the maximum number of passes over the loop, 100\nby default, and the smallest change worth another pass, 0.001 by default.\nWith it on, the loop runs from the values it last had until the numbers\nstop moving or the passes run out, and every cell in it holds a number\ninstead of an error.\n\nIt is workbook-wide rather than per sheet, so `wb.iteration` reads it and\n`wb.setIteration({ enabled: true })` sets it from code, recalculating as it\ngoes. It rides in `getState()` beside the defined names and the tables, and\nit goes into the .xlsx as `calcPr`, which is where Excel keeps it, so a\nfile saved with iteration on opens with it on. Raised as `calc-options`.\n\n### PivotTable from a range\n\nInsert > PivotTable summarises the selected block, or the region around\na single selected cell, on the same pivot engine the grid uses for its own\npivot mode. The dialog takes the source block\n(its first row the field names), where the result goes, and which field is\na row, a column or a measure, with Sum, Average, Count, Distinct count,\nMin and Max to summarise by.\n\nWhat the sheet keeps is the **definition**; what it writes is **cells**. A\npivot's result is an ordinary block, so it can be formatted, charted,\nfiltered, printed and saved to an .xlsx like anything else you typed.\nInsert > Refresh rebuilds it from the source and clears whatever the last\none wrote, which is what a pivot over live cells owes the user. With the\ncursor inside a written block, Insert > PivotTable opens the dialog on\nthat pivot instead of making another, so moving a field or changing an\naggregation is two clicks. Each write is one undo, cells and definition\ntogether.\n\nA field placed in **Filter** narrows the whole pivot to one of its values.\nThe filter is written above the block, a line per filter with the field\nand the value, so a reader sees what the numbers are of before reading\none; the dialog offers the values that field carries, and typing another\nvalue straight into the cell (or `(All)` to clear it) narrows the pivot at\nonce, since the cell IS the control. The filter applies before anything is\ngrouped, so the totals, the subtotals and Show Details all see the same\nrows.\n\n**Insert > Show Details** is Excel's drill-down: the source rows behind\nthe number in the cell the cursor is in, written to a sheet of their own,\nwith the field names across the top and the first row frozen. A cell in a\nsubtotal line opens its whole group, one in the grand total column opens\nthe whole line, and the grand total opens every row, which is what each of\nthose numbers stands for. The sheet is an ordinary one, so deleting it is\nhow it is dismissed. `pivotDrill` is the same answer as data.\n\nPivots are per sheet, ride in `getState()` as `pivots` and report\n`{ kind: 'pivots' }` on `onChange`. The definition moves with an insert or\na delete, and is dropped when its source or its target cell is deleted.\nRaised as `insert-pivot`, `refresh-pivot` and `pivot-details`.\n\nWith one measure the block is Excel's compact form: the column values\ncarry the corner label, the row labels run down the first column indented\nby level, and the grand total row and column can be turned off in the\ndialog. With two or more measures each column value gets one column per\nmeasure, under a header row naming them.\n\n### Sparklines\n\nExcel's smallest chart, and the one that is not an object: a sparkline IS\nthe cell. Insert > Sparklines offers Line, Column and Win/Loss, each\nopening the Create Sparklines dialog on the selected block with the data\nrange filled in and the location the column just past it. One sparkline\nis drawn per row of the data (per column, when the location is a row),\nread from the range rather than a copy of it, so editing a number redraws\nthe cell.\n\nThey are kept per GROUP, the way Excel keeps them: a data range, a\nlocation range of the same shape and the settings they share. So\n\n- selecting a cell that holds one turns the three kind buttons into a\n change to that group, Excel's Sparkline tab in one press;\n- Insert > Sparklines > Edit reopens the dialog on the group here, for\n its ranges, its colours, whether the whole group is drawn on one value\n scale and whether a line marks its last point;\n- Insert > Sparklines > Clear removes every group the selection touches.\n\nEach of those is one undo. A group moves with an insert or a delete, and\ngoes when the cells it is drawn in, or the cells it reads, are deleted.\nSparklines are per sheet, ride in `getState()` as `sparklines` and report\n`{ kind: 'sparklines' }` on `onChange`. The drawing is the free\n`<SvSparkline>` from `@svgrid/grid`. Raised as `sparkline-line`,\n`sparkline-column`, `sparkline-winloss`, `sparkline-setup` and\n`clear-sparklines`.\n\nA sparkline is behind whatever the cell shows, so a label typed over one\nstill reads, and the pointer goes through it: the cell is selected,\ndragged and edited as a cell. Sparklines ride in the .xlsx as Excel's own\nsparkline groups, both ways, in the worksheet's extension list where Excel\nkeeps them, and they are drawn on the printed page too, in the cells they\nbelong to.\n\n```svelte\n<script>\n const doc = createSheetDocument({ workbook: wb })\n doc.get('Traffic').sparklines = [{\n id: sparklineId(),\n data: [1, 1, 4, 12], // B2:M5, one row per channel\n location: [1, 13, 4, 13], // N2:N5, the cells they are drawn in\n type: 'line',\n markers: true,\n }]\n</script>\n```\n\n### Comments\n\nExcel's notes and its threaded comments: a text on a cell, marked by a red\ncorner and read by hovering the cell, and under it the replies, each with\nits author and time. Review > New Comment, Shift+F2 and the cell menu's\nNew Comment open the box beside the active cell; for a new comment it is\nthe note editor, and Ctrl+Enter or Save closes it, as does Escape or a\nclick elsewhere, keeping what was typed, as Excel keeps a note. On a cell\nthat has one, the box is the thread: the first entry with Edit and Delete,\nthe replies with theirs, a reply box (Ctrl+Enter posts) and Resolve in the\nhead, which greys the thread in the list and hides the reply box until\nReopen. Delete (on Review, in the box, in the cell menu) removes the whole\nthread; Previous and Next walk the sheet's comments row by row and wrap;\nShow All Comments opens a strip under the formula bar listing every\ncomment with its author, each a jump to its cell, which is the honest form\nover a sheet that only paints the rows in view. Every change is one undo.\n\n`commentAuthor` is who signs: with it set, a new comment and every reply\ncarry that name and the time, the way Excel signs a threaded comment with\nthe signed-in user; without it a new comment is a plain note and a reply\ncarries its time alone. Comments are per sheet, move with an insert or\ndelete, ride in `getState()` as `comments` (`r4` -> `B` -> a note's text,\nor `{ text, author, at, replies, resolved }` for a thread; every document\nsaved before threads existed reads as it did) and report\n`{ kind: 'comments' }` on `onChange`. Save As writes a note as Excel's\nlegacy note and a thread as its threaded comment with the persons part,\nand Open reads both. Raised as `new-comment`, `edit-comment`,\n`delete-comment`, `prev-comment`, `next-comment` and `toggle-comments`.\n\n### Protection\n\nExcel's model, without the password. Every cell is locked to begin with;\nFormat Cells > Protection (or Format > Lock Cell) unlocks the ones that may\nchange; Review > Protect Sheet turns the flags on. From then on a locked\ncell refuses to change wherever the change comes from: typing, F2, Delete,\nthe formula bar, paste, a fill, Ctrl+D, Find and Replace (which skips it),\na sort of the region, and the formats (the Font, Alignment and Number\nbuttons grey out while the selection holds a locked cell, and Ctrl+B\ndeclines). Insert and Delete grey out for the whole sheet, so do Row\nHeight, Column Width, AutoFit, Hide and Unhide, and the resize handles go\naway. The refusal is a sentence in the status bar, Excel's own: \"The cell\nyou're trying to change is on a protected sheet. To make a change,\nunprotect the sheet.\" Unlocked cells take every edit as before.\n\nWhat is drawn OVER the cells is covered too: a chart or a picture cannot be\nmoved, resized, retyped or deleted, and the sparklines of a protected sheet\ncannot be changed or cleared. The dialogs that would write them do not open\non a protected sheet, and the refusal sits on the write itself, so every way\nin (the ribbon, a dialog, the keyboard, a drag) says the same thing.\n\nProtect Sheet opens Excel's dialog with its \"allow all users of this\nworksheet to\" list: format cells, format columns, format rows, insert\ncolumns, insert rows, delete columns, delete rows, sort, use AutoFilter.\nWhat is ticked stays open once the sheet is protected: with Format cells\nthe Font, Alignment and Number buttons, Format Cells, merges and Lock\nCell work on every cell; with Format rows or columns Row Height, Column\nWidth, AutoFit, Hide and Unhide and the resize handles come back for that\naxis; the insert and delete entries open Home > Cells > Insert and Delete\nfor their axis; Sort allows a sort of a block that holds no locked cell,\nas Excel's does (a block with one is refused whatever the tick); Use\nAutoFilter allows the arrows on and off and their menus. The list is per\nsheet and kept, so Unprotect and Protect again open on the same ticks.\n\nReview > Allow Edit Ranges is Excel's Allow Users to Edit Ranges: a list\nof titled blocks (`B2:B10`, or several areas with commas) that take an\nedit on a protected sheet whether their cells are locked or not. New adds\none over the selection, Modify edits it in place, Delete removes it, and\nProtect Sheet... goes straight to that dialog. A range moves with an\ninsert or delete and goes with the rows it spans.\n\nProtect Sheet and Unprotect Sheet share one slot on the Review tab\n(`protect-sheet`, `unprotect-sheet`, and `allow-edit-ranges`); each is\none undo, so Ctrl+Z after Unprotect protects again, and after Protect\nputs the old allow list back. The flag is per sheet and rides in\n`getState()` as `protected`, the list and the ranges as\n`protection: { allow, ranges }` beside it (absent in an older document,\nwhich reads as nothing allowed and no ranges), the unlocked cells as\n`locked: false` in their format entries; `onChange` reports\n`{ kind: 'protection' }`. Save As writes the flag, the list and the\nranges as the file's `sheetProtection` and `protectedRanges`, and Open\nreads Excel's. A command written against the grid asks `cmd.canEdit(r, c)`\nbefore it writes and gets the same answer the editor does.\n\nThere is no password, on the sheet or on a range: the protection is\nagainst mistakes, not against the person at the keyboard, who can unprotect\nwith one click.\n\nData > Sort A to Z and Z to A sort the current region (or the selection) by\nthe active cell's column: numbers before text, blanks last, a header row of\ntext over numbers left in place, values and formats moving together, one\nundo.\n\nEvery one of them raises its `onAction` first. A handler that returns\n`true` has taken the action over and the shell's dialog stays closed, so an\napplication can put its own Format Cells in place of this one:\n\n```svelte\n<SvSheet workbook={wb} onAction={(action, cmd) => {\n if (action === 'format-cells') { openMyDialog(cmd); return true }\n}} />\n```\n\n## Grouping rows and columns\n\nData > Group folds a block of rows or columns away behind a button, the way\nExcel's outline does. Group and Ungroup act on the selection, grouping an\nalready grouped run nests it, and the numbered buttons at the corner of the\nbar show the whole sheet at one depth. Auto Outline groups the runs between\nthe totals the sheet already has; Clear Outline removes the lot.\n\nThe summary line is the one the button sits on, and it is the line AFTER\nthe detail on both axes: the total row under its rows, the total column to\nthe right of its columns. That is Excel's `summaryBelow` and `summaryRight`,\nand both round-trip through the .xlsx as `outlineLevel` and `collapsed`\nper line.\n\nA line a collapsed group folds is not a line you hid. The two are kept\napart in the document on purpose: if folding recorded a hidden row, the row\nwould stay invisible after its group reopened and there would be no button\nleft to bring it back. Unhide leaves a folded row to its group, the way it\nalready leaves a filtered row to its filter.\n\n## Cells drawn as a control\n\nInsert > Controls draws the selected cells as a checkbox or a button\ninstead of as text, and `SvSheet`'s model has a radio group beside them.\n\nA control is a rendering and never a second source of truth. A checkbox is\nticked because its cell reads `TRUE`, not because the checkbox remembers\nbeing clicked, so everything else keeps working over it:\n`=COUNTIF(A1:A9, TRUE)` counts the ticks, undo undoes one, a paste sets\none, and the file carries an ordinary boolean. Excel's own cell checkbox\nworks this way too, which is why the default pair is `TRUE` and `FALSE`.\nA sheet that wants `Yes` and `No` says so on the region.\n\nA button reports its press through `onCellAction`:\n\n```svelte\n<SvSheet {data}\n onCellAction={(event) => {\n // `event.action` is the region's own action name, so one column of\n // buttons can be told from another.\n if (event.action === 'approve') approve(event.row)\n }} />\n```\n\nClear Control puts the cells back to ordinary ones and leaves their values\nwhere they are.\n\n## A tab that is a table, not cells\n\nA workbook tab can be a bound table rather than a cell grid. Name it in\n`gridSheets` with its fields and its records and it renders as a `<SvGrid>`,\nwith its own headers, sorting, filtering and inline editing, in the same\nworkbook as the cell sheets beside it.\n\n```svelte\n<SvSheet\n data={[{ name: 'Orders', cells: [] }, { name: 'Summary', cells: [] }]}\n gridSheets={{\n Orders: {\n fields: [\n { field: 'item', label: 'Item' },\n { field: 'qty', label: 'Qty', type: 'number' },\n { field: 'price', label: 'Price', type: 'number' },\n ],\n rows: orders,\n editable: true,\n },\n }} />\n```\n\nThe records are the truth, and after every change they are projected into\nthe workbook's cells, header row included. That is what makes a bound tab\nmore than an embedded widget: on the Summary tab,\n\n```\n=SUM(Orders!B2:B99)\n=VLOOKUP(\"Gadget\", Orders!A2:C99, 2, FALSE)\n```\n\nboth work, because the formula engine, the dependency graph, the file\nwriters and the printer are never told that the Orders tab is different.\nThe projection goes through the same write an ordinary edit uses, which\nignores a write that changes nothing, so editing one field of one record\nrewrites one cell and recalculates only what read it.\n\nWhat a bound tab deliberately does not do is hold formulas of its own. Its\ncells are a rendering of its records, so anything typed into them would be\noverwritten by the next projection. A sheet that needs formulas beside the\ndata is a cell sheet reading the bound one across, which is what the\nexample above is.\n\n## Localisation\n\nEvery string the shell shows is English by default and lives in one flat\nmap, `SheetMessages`, the way the grid's `GridMessages` does. Pass any\nsubset as `localization.text` and unset keys stay English; `locale` is the\nBCP-47 tag the status bar's Sum, Average and Count are printed with, and\nit reaches the grid underneath for its own matching and formatting.\n\n```svelte\n<SvSheet {data}\n localization={{\n locale: 'de-DE',\n text: {\n 'ribbon.tab.home': 'Start',\n 'ribbon.bold.title': 'Fett',\n statusReady: 'Bereit',\n statusRecordsFound: '{shown} von {total} Datensätzen gefunden',\n 'formatCells.title': 'Zellen formatieren',\n ok: 'OK', cancel: 'Abbrechen',\n },\n }} />\n```\n\nThree families of key:\n\n- **The ribbon's** are read off the ribbon model, so every tab, group and\n button is covered without a second list: `ribbon.tab.<tab>`,\n `ribbon.group.<group>`, `ribbon.<item>.label`, `ribbon.<item>.title`\n (the tooltip), `ribbon.<item>.option.<value>` for a dropdown's entries\n and `ribbon.<item>.none` for its \"no fill\" kind of entry. The ids are\n the `RibbonActionId`s: `ribbon.bold.label`, `ribbon.file-open.title`,\n `ribbon.number-format.option.percent`.\n- **The dialogs'** are `<dialog>.<part>`: `formatCells.title`,\n `filter.dateFilters`, `validation.allow.list`, `findReplace.findNext`,\n `sort.addLevel`, `goalSeek.setCell`, and so on.\n- **The chrome's** are plain camelCase: `statusReady`, `nameBox`,\n `newSheet`, `menuFormatCells`, `ok`, `cancel`, the sentences the status\n bar says (`openedFile`, `sheetProtected`, `noInvalidData`).\n\nA sentence with `{placeholders}` (`statusRecordsFound`, `deleteSheetMessage`,\n`linesToggled`, `textToColumns.status`) lets a translator choose the word\norder; `formatMessage` fills them. `defaultSheetMessages` is exported, so\nthe full English map is there to copy from, and `resolveSheetMessages`\ngives the merged map for an app that wants to read it.\n\nWhat stays English: the function names and error values (`SUM`,\n`#VALUE!`), which Excel keeps in every locale too, the rule descriptions\nthe Conditional Formatting Rules Manager lists, and the font names.\n\n### How numbers and formulas are spelled\n\n`locale` also decides how a number and a formula are WRITTEN. Under\n`de-DE` a cell shows `1.234,5`, the formula bar shows `=ROUND(A1/3; 2)`,\nand `1,5` typed into a cell is the number rather than text. The marks come\nfrom `Intl`, so a locale nobody planned for still comes out right, and\nExcel's own rule decides the separator: wherever the decimal mark is a\ncomma the argument separator is a semicolon, since the comma is busy.\n\nThe document is unaffected, and that is the point. A formula is stored as\n`=SUM(1.5, A1)` whatever the locale, so a file written in Berlin opens in\nBoston and `getState()` means the same thing on every machine. The culture\nis put on when a formula is shown for editing and taken off when one is\ntyped. A `,` typed where the separator is `;` is taken as a separator too,\nbecause a comma with no digit after it cannot be a decimal mark, so an\nEnglish keyboard habit is not punished.\n\nA number FORMAT string stays invariant as well: `#,##0.00` is the pattern\nin every locale, and it is the rendering of that pattern that follows the\nculture.\n\nOverride it where the locale is not the whole story:\n\n```svelte\n<!-- German strings, but the dot for decimals this team is used to. -->\n<SvSheet {data} localization={{ locale: 'de-DE', culture: { decimal: '.' } }} />\n\n<!-- German strings, invariant spelling: for a team sharing files with\n English-speaking colleagues. -->\n<SvSheet {data} localization={{ locale: 'de-DE', culture: false }} />\n```\n\n## Two people on one sheet\n\n`createDeltaStream` turns the document's changes into small messages, and\n`applySheetDelta` applies one that arrived from somewhere else. Put a\nsocket between the two and the sheet is collaborative.\n\n```svelte\n<script>\n import { createSheetDocument, createDeltaStream } from '@svgrid/enterprise'\n\n const doc = createSheetDocument({ sheets })\n const socket = new WebSocket(url)\n const stream = createDeltaStream(doc, {\n onDelta: (delta) => socket.send(JSON.stringify(delta)),\n })\n socket.onmessage = (event) => stream.apply(JSON.parse(event.data))\n // A new participant asks for everything once:\n socket.onopen = () => stream.resync()\n</script>\n\n<SvSheet document={doc} height=\"100%\" />\n```\n\nFive kinds of delta go out, and each is as small as it can be:\n\n- **`cells`** carries the RAW TEXT of the cells that were written, not\n their values. A formula travels as `=SUM(B2:B4)` and the other side works\n out its own answer, so the two cannot disagree about a number and the\n receiving sheet keeps a formula in the cell.\n- **`structure`** carries the insert or delete itself, so both sides\n rewrite their own formulas identically.\n- **`state`** carries the one part of the one sheet that changed, the way\n `getState` serializes it: the formats, the merges, a rule, the objects.\n- **`document`** carries the whole state, for the changes not worth\n describing piecemeal or that cannot be: adding or removing a sheet, a\n restore, and the parts the WORKBOOK keeps rather than a sheet, which are\n its tables, its defined names and its calculation settings. A `state`\n delta is keyed by sheet and carries a sheet's own entry, so none of those\n fit in one: without this the two sides drift silently, and the same\n `=SUM(Orders[Amount])` answers a number on one and `#REF!` on the other.\n `resync()` sends the same kind.\n\nThe shell follows its document, so a delta applied from outside repaints\nthe sheet without anything else being called. `refresh()` remains for a\nwrite made straight to the workbook, which the document never hears about.\n\n**Conflicts are last writer wins, per cell.** A delta carries no version\nand no transform: two people typing in the same cell end on whichever\nmessage arrived last, and both converge on it. Two people typing in\ndifferent cells never conflict, which is the case that actually happens.\nAnything stronger, operational transform or a CRDT, is a different piece of\nwork and is not pretended at here. Applying is idempotent for `cells` and\n`state` and is not for `structure`, so a transport that can deliver twice\nhas to deduplicate.\n\n### Presence: where everyone is\n\nThe deltas carry what people TYPE. Presence carries where they ARE, which\nis what stops two people typing into the same cell.\n\n`presence` is a prop, a list of who else is on this workbook, and the shell\ndraws each person on the active sheet as a thin coloured box around their\nselection with their name on their cursor. `onPresence` fires whenever this\nuser's own selection moves, with the sheet, the rectangle and the active\ncell, which is what an application broadcasts.\n\n```svelte\n<SvSheet\n document={doc}\n presence={others}\n onPresence={(me) => stream.sendPresence({ id: myId, name: myName, ...me })}\n/>\n```\n\nThe fifth delta kind, `presence`, rides the same wire so an application\nneeds only one: `sendPresence` sends it, and it arrives at the receiving\nstream's `onPresence` rather than at the document. That is deliberate.\n**Presence is never part of the document**: not in `getState()`, not in the\n.xlsx, and `applySheetDelta` ignores it. A cursor belongs to a session, and\na file that remembered where someone's cursor was last week would be\nremembering nothing worth keeping.\n\nA person with no `colour` is given a stable one from their id, so a set of\npeers is never all one colour, and anyone who has not been heard from for\nfifteen seconds is dropped, since a closed tab says no goodbye. A cursor\nmoves on every arrow key, so throttle `onPresence` if the transport\ncharges by the message; the shell does not, because it cannot know what\nthe transport costs.\n\n## How big a sheet it holds\n\nMeasured rather than guessed, by `pnpm bench` over the built engine (the\ncases are in `tools/bench/sheet-cases.mjs`, the numbers and the rig in\n[Benchmarks](../benchmarks.md#spreadsheet-engine)). On a CI-class Linux\ncontainer:\n\n| Sheet | Opens in | Retains |\n| ---------------------------------------- | -------- | ------- |\n| 1,000 rows, a formula each | 8 ms | 1.6 MB |\n| 10,000 rows, a formula each | 91 ms | 16.5 MB |\n| 50,000 rows, a formula each | 547 ms | 84.2 MB |\n\nA keystroke costs one evaluation rather than a sheet's worth: the\ndependency graph recomputes what read the cell, and nothing else. What\nmakes an edit expensive is not the size of the sheet but how much of it\none cell feeds. A single `SUM` over 10,000 cells is one evaluation that\nre-reads ten thousand cells (14 ms); a running-balance column, where every\nrow reads the row above, is 10,000 evaluations for one keystroke, and that\nis the true cost of that shape.\n\nCI gates the evaluation counts rather than the milliseconds, since a count\nis the same on every machine.\n\n## Right to left, touch and assistive tech\n\nThe shell carries no direction of its own: set `dir=\"rtl\"` on the page or\non any element above it and everything mirrors, the row gutter and the\nfrozen columns to the right edge, the ribbon and the tab strip in reading\norder, an anchored chart hanging from its cell's right edge and growing\nleftwards. The arrow keys follow the reading order with it, so on a\nright-to-left ribbon ArrowLeft is the next tab.\n\nThe two bands that cannot fit a phone pan with a finger rather than\nclipping: the ribbon scrolls sideways over its groups and over its tab\nlabels, and the cells pan in both axes. The formula bar keeps a long entry\non one line, cut off at the right and scrolling with the caret, until the\nchevron expands it; a one-row box that wrapped would only hide the rest. A tap picks a cell, a second tap on the same cell opens its\neditor, and the formula bar is where the address and the formula are read\nand typed.\n\nFor assistive tech the shell is the bands it looks like: the ribbon is a\ntoolbar whose tab strip is a `tablist` with one Tab stop, the arrows plus\nHome and End moving between tabs with focus following the selection; the\nsheet tabs are a second `tablist` with the same model; the formula bar is\na labelled group whose Name Box reads the active cell as its value; the\ncells are the grid's own `role=\"grid\"`; every dialog takes focus and\ngives it back to the cells on Escape. `tests/e2e/sheet-a11y.spec.ts`,\n`sheet-rtl.spec.ts` and `mobile/sheet-touch.spec.ts` hold the shell to\nall of this in a real browser, the first with an axe audit of every band.\n\nOne thing the shell does not decide: a font colour or a fill the DOCUMENT\nsets is used as it stands, on a dark theme as on a light one. A red on a\ndark canvas is as hard to read here as it is in Excel, and the answer is\nthe same one: change the document's colours, not the app's.\n\n## What it does not do\n\nThe shell is the part of Excel a team's spreadsheet uses every day; these\nare the parts it leaves out, on purpose, so nothing on the ribbon is a\nbutton that does nothing.\n\n- **Tabs:** no track-changes on Review; Page Layout has no themes, no\n page breaks and no header or footer text, and Print has no preview of\n its own beyond the browser's.\n- **Objects** are charts and pictures: a chart is what the Chart dialog\n sets and no more, so there are no per-series colours, no data labels and\n no axis titles. A floating picture whose source is a URL rather than a\n `data:` URL is left out of the .xlsx, since its bytes are not in the\n document to write; a picture in a cell, `=IMAGE(...)`, has no such limit,\n because the formula is what the file carries.\n- **Sparklines** are the three Excel draws: no axis options beyond one\n scale for the group, and no high and low point marks beyond the last one.\n They ride in the .xlsx as Excel's sparkline groups, both ways, and they\n print with the sheet.\n- **A PivotTable** is a definition plus the cells it writes, not a live\n object: no drag-and-drop field list and no slicers, and a report filter\n holds one value at a time rather than a tick list. Refresh brings it up\n to date and Show Details is the drill-down.\n- **Collaboration** is the delta stream and the presence overlay above and\n nothing more: no server, no operational transform, no follow-the-leader\n scrolling, and last writer wins per cell.\n- **The outline** groups, ungroups, collapses and shows a level, and it\n round-trips. What it does not have is Excel's Data > Subtotal, the\n command that inserts `SUBTOTAL` formulas at each break AND groups what\n it just broke up; the grouping half is here, the formula-writing half is\n not. `SUBTOTAL` itself works, so a sheet can write its own.\n- **A cell drawn as a control** carries its VALUE into a file and not its\n control: a checkbox column saves as the `TRUE` and `FALSE` it means, and\n reopens as ordinary cells. The controls live in `getState()`, so an app\n that saves the document keeps them; an app that saves only .xlsx keeps\n the data and loses the decoration.\n- **A bound tab** holds no formulas of its own, since its cells are made\n from its records and would be overwritten. It is not written to the\n .xlsx as an Excel table either: its projection goes out as ordinary\n cells, which is what Excel would show anyway.\n- **The culture** changes how a number and a formula are spelled, not what\n they are called: function names stay English, as they do in the file\n format itself.\n- **Protection** takes no password, on the sheet or on an edit range: it\n guards against mistakes, not against the person at the keyboard.\n- **Validation** checks what is typed; pasted-over cells are left as they\n land until Circle Invalid Data is asked for.\n- **Conditional formatting** has Excel's presets and Excel's \"with\"\n styles, one icon set per flavour, and no colour pickers of its own for\n bars and scales; the grid's own value-driven rules are a separate\n feature.\n- **AutoFilter**'s values list is the whole column, unvirtualised, which\n is what a sheet's region holds.\n- **Files** carry what each format has a place for, so the three are not\n equals: the .xlsx carries the most, the .ods has no charts, pictures,\n validation, conditional formatting or frozen panes, and the .xls\n is the oldest and carries the least, with no charts, pictures,\n sparklines, validation, conditional formatting or comments, a\n sheet of 65,536 rows by 256 columns, and a formula using a function Excel\n 97 never had saved as the value it worked out.\n\n## More examples\n\n### A tab that is a table, and rows that fold away\n\nA workbook tab that holds RECORDS rather than cells: the Orders tab renders as the data grid, with its own headers, sorting, filtering and inline editing, beside ordinary cell sheets. Its records are projected into the workbook's cells, header row included, so the Summary tab reads it with plain formulas: SUM, SUMIF, SUMPRODUCT and VLOOKUP all reach across, because the formula engine is never told the tab is different. Summary also shows Data > Group, with three regional blocks folded under their subtotals and the numbered level buttons at the corner of the outline bar. Edit a Qty on Orders and every figure follows.\n\n<div data-docs-demo=\"498-sheet-bound-tabs\" data-height=\"560\"></div>\n\n### Engineering, statistics, and the locale you type in\n\nFour function families the engine gained at once, worked through a real example of each: a bearing from ATAN2 and DEGREES, a register mask in binary and hex with BITAND, an orchard queried through Excel's criteria-block grammar with DSUM, DCOUNT, DAVERAGE and DGET, and NORM.DIST with its inverse, a confidence interval and the chi-squared and t tails. The picker switches how numbers and formulas are SPELLED: German shows 1.234,5 and =ROUND(A1/3; 2) while the document still stores 1234.5 and a comma, so the file opens anywhere. Column H is drawn as checkboxes whose ticks an ordinary COUNTIF counts.\n\n<div data-docs-demo=\"499-sheet-engineering-stats\" data-height=\"560\"></div>\n\n\n### Review workflow: comments + Protect Sheet\n\nAn expense sheet under review on the Excel shell. It opens protected: every cell is locked except the reviewer's Receipt and Status columns, so a keystroke on an amount is refused and the status bar says why. Three reviewer notes sit on the cells they are about (Excel's red corner): hover, Shift+F2, Review > Next, Show All Comments. Status is a list, the totals by status are SUMIFs, and protection, notes and rules all ride in the document.\n\n<div data-docs-demo=\"460-review-comments-protection\" data-height=\"560\"></div>\n\n### Order intake: data validation\n\nAn order form with Excel's Data Validation on it: Region and Product are lists (the arrow or Alt+Down drops them; Product reads the price list on a second sheet), Qty is a whole number from 1 to 500 with a Stop alert and Retry, Discount is a decimal up to 20% with a Warning you can keep, Ship by is a date on or after the order date through a relative bound (=A2 moves with the row). Unit price is an XLOOKUP, the total follows.\n\n<div data-docs-demo=\"461-order-intake-validation\" data-height=\"560\"></div>\n\n### Regional scorecard: conditional formatting\n\nEight regions, three months, a target, and every colour on the page is a conditional formatting rule over computed values: Highlight Cells on attainment (under 90% red, 100% and over green), a Data Bar and a Top 3 on the quarter, a Green - Yellow - Red Color Scale across the months, an Icon Set on the trend. Edit a month and the arrow, the bar, the fill and the rank all move; Manage Rules lists the six in priority order.\n\n<div data-docs-demo=\"462-regional-scorecard-cf\" data-height=\"560\"></div>\n\n### Budget report: merged headers\n\nA half-year budget report laid out with merged cells the way Excel lays one out: a title merged across the page, Q1 / Q2 / H1 group headers merged over their months, a Line corner merged down two rows, a notes paragraph merged into one wrapped block. A merge is one cell to the grid: click inside Q1 and the Name Box says B2, Merge & Center lights, the arrows step over it and its column letters are shaded. Unmerge and merge again from the ribbon; Ctrl+Z each step.\n\n<div data-docs-demo=\"463-merged-report-headers\" data-height=\"560\"></div>\n\n### Support ticket log: AutoFilter\n\nForty support tickets with Excel's Filter on the header row, opened already filtered to what is still open: the funnel on Status, blue row numbers, \"N of 40 records found\" in the status bar. The arrows drop Excel's menu: sort, Clear Filter, Text and Number Filters with two conditions, a search box, (Select All) and the values with counts. The rows are worked out again after every edit, so a ticket typed Closed folds away at once. Ctrl+Shift+L toggles it.\n\n<div data-docs-demo=\"464-ticket-log-autofilter\" data-height=\"560\"></div>\n\n### Autosave: a document that survives a reload\n\nA project tracker that keeps itself in localStorage: onChange fires once per tick with every kind of change, the demo debounces it and writes getState() as JSON, and the next visit rebuilds the document with createSheetDocument({ state }) and hands it to <SvSheet document>. Add a comment, bold a row, hide a column, type a task, reload: it is all back, rules and merged title and frozen rows included. Reset puts the shipped document back with setState().\n\n<div data-docs-demo=\"465-autosave-document\" data-height=\"560\"></div>\n\n### Paste from Excel: formats and formulas survive\n\nExcel puts an HTML document on the clipboard beside the tab-separated text: formats in a style block keyed by class, formulas in x:fmla, raw numbers in x:num. The sheet reads it, so a pasted block arrives bold, filled, with its number formats, and with its formulas moved to where they landed; Google Sheets' data-sheets-formula flavour reads the same. Two buttons put exactly what Excel and Sheets put on the clipboard; click a cell and Ctrl+V.\n\n<div data-docs-demo=\"466-paste-from-excel\" data-height=\"560\"></div>\n\n### Loan model: PMT, accounting formats and the File tab\n\nA mortgage model built from the sheet's own financial functions: PMT for the payment, IPMT and PPMT for each period's split, NPER and RATE for the term and the rate that fit, SUMPRODUCT for the first year of interest. The money cells wear the Accounting format, Extra Payment carries a validation rule with an Input Message, and Circle Invalid Data rings what breaks it. The File tab is the point: Save As writes the whole model as an .xlsx Excel opens with its formulas, formats and rules, Open reads one back, Export CSV takes the active sheet.\n\n<div data-docs-demo=\"484-loan-model-files\" data-height=\"560\"></div>\n\n### Charts and pictures on the sheet\n\nCharts anchored over the cells the way Excel anchors one: each reads a range rather than a copy of the numbers, so typing into a cell redraws it. Insert > Chart charts the selected block and reads its first row and column as the labels, Insert > Picture puts an image on the sheet, and a double-click opens the Chart dialog for the type, the title, series in columns or rows and stacking. Drag an object to move it, its corner to resize, Delete to remove. It hangs from a cell, so inserting a row above moves it, and it rides in getState().\n\n<div data-docs-demo=\"485-sheet-charts-objects\" data-height=\"560\"></div>\n\n### Sparklines: a chart inside the cell\n\nExcel's smallest chart, and not an object: a sparkline IS the cell. One per row of a block of numbers, drawn from the range rather than a copy, so editing a number redraws it. Insert > Sparklines offers Line, Column and Win/Loss, with Edit for the group's ranges, kind and colours and Clear for the groups the selection touches; selecting a cell that holds one turns the kind buttons into a change to that group. They are kept per group the way Excel keeps them, ride in getState() and move with an insert or a delete.\n\n<div data-docs-demo=\"486-sheet-sparklines\" data-height=\"560\"></div>\n\n### PivotTable from a range\n\nExcel's Insert > PivotTable over a block of cells, on the same pivot engine the grid uses for its own pivot mode. The sheet keeps the definition, the source block, where the result goes and which field is a row, a column or a measure; the result is plain cells written in one undo, so it can be formatted, charted, printed and saved to an .xlsx like any other block. Refresh rebuilds it from the source, opening the dialog from inside one edits it, and Show Details writes the source rows behind a cell to a sheet of their own.\n\n<div data-docs-demo=\"487-sheet-pivot-range\" data-height=\"560\"></div>\n\n### Two people on one sheet\n\nTwo full spreadsheets over two separate documents, wired to each other by createDeltaStream: type in either and the other follows, and each window shows the other's cursor as a coloured box with a name on it. What crosses the wire is a delta rather than the document, and the log shows each one as it goes: a formula travels as its text so the other side works out its own answer, an insert travels as the edit so both rewrite their own formulas, a format travels as the one part of the one sheet that changed. Conflicts are last writer wins, per cell.\n\n<div data-docs-demo=\"488-sheet-collaboration\" data-height=\"620\"></div>\n\n### Evaluate Formula and Error Checking\n\nThe two auditing tools that answer why a cell says what it says, over a commission model with three planted faults. Evaluate Formula underlines one part of the formula and replaces it with its value on each click. Error Checking walks every cell that reports an error, with a sentence on what each one means, and the one row whose formula is not the column's formula. Show Calculation Steps takes one straight into the other.\n\n<div data-docs-demo=\"493-sheet-auditing\" data-height=\"560\"></div>\n\n### Iterative calculation\n\nA circular reference is normally an error, and every cell in the loop shows #CYCLE!. Two models here are circular on purpose: a bonus that is a share of the profit it is taken out of, and interest charged on the balance it is part of. Formulas > Calculation Options turns iteration on with its two limits, and both settle on their fixed point; turn it off and the cycle is an error again. The setting rides in getState() and goes into the .xlsx as calcPr.\n\n<div data-docs-demo=\"492-sheet-iterative\" data-height=\"560\"></div>\n\n### IMAGE: a picture inside the cell\n\nExcel's IMAGE puts a picture IN a cell rather than floating one over it, so it sorts with its row, filters with it, copies as a formula and moves when the cells move. A product list whose thumbnail column reads the swatch beside it: sort by price and the pictures follow their rows. The source is a web address or a data URL, anything else stays text rather than becoming a broken image, and the second argument is the alt text a screen reader reads.\n\n<div data-docs-demo=\"494-sheet-cell-images\" data-height=\"520\"></div>\n\n### Hyperlinks: Insert > Link and HYPERLINK\n\nExcel's two ways of putting a link in a cell. Insert > Link (Ctrl+K) puts one on the cell, so editing the text keeps it and clearing the cell takes it away; the HYPERLINK function puts one in a formula with a friendly name. A target that reads like an address moves the selection instead of leaving the page, so a cell links to another sheet; anything with a scheme opens in a new tab. A single click follows, a drag selects. Links ride in getState(), move with an insert, and go into the .xlsx both ways.\n\n<div data-docs-demo=\"489-sheet-hyperlinks\" data-height=\"560\"></div>\n\n## See also\n\n- [`<sv-sheet>`](../web-components/sv-sheet.md): the shell as a custom\n element for React, Vue, Angular or a plain page, with React and Vue\n wrappers.\n\n- [Excel keyboard shortcuts](./keyboard-shortcuts.md)\n- [Formulas](../spreadsheet-formulas.md)\n- [Number formats](./number-formats.md)\n- [Workbooks](./workbooks.md)\n"
|
|
4147
4171
|
},
|
|
4148
4172
|
{
|
|
4149
4173
|
"slug": "help/cells/styling-cells",
|
|
@@ -4444,7 +4468,7 @@ export const docs = [
|
|
|
4444
4468
|
"path": "docs/help/editing/validation.md",
|
|
4445
4469
|
"title": "Validation",
|
|
4446
4470
|
"section": "Editing",
|
|
4447
|
-
"markdown": "# Validation\n\nA column's `validate` hook flags a bad value: the cell turns red and the\nmessage becomes its tooltip. On its own it does not refuse anything, so\na form can be filled in any order and fixed afterwards; `rejectInvalid`\non the same column turns the flag into a refusal, and an edit the rule\nfails against is never written. Rules that need more than one row, or\nthat should be logged, run in `onCellValueChange` instead.\n\n## Flag with `validate`\n\n`validate` runs for every rendered cell, not only on edit, so bad data\nalready in the source is red on load, and it re-runs as the user types.\nIt receives `{ value, row, rowIndex, column }` and returns `null`,\n`undefined` or `true` for a valid value, `false` for invalid without a\nmessage, or a string that becomes the tooltip. Salary, email and age\nbelow each carry a rule; try a salary of 500 or an age of 12.\n\n<div data-docs-demo=\"206-cell-validation\" data-height=\"460\"></div>\n\n```svelte\nconst columns: GridColumns<Person> = [\n { field: 'salary', header: 'Salary', editorType: 'number',\n validate: ({ value }) => (Number(value) < 1000 ? 'Salary must be at least $1,000' : null) },\n { field: 'email', header: 'Email', editorType: 'text',\n validate: ({ value }) => (/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(String(value)) ? null : 'Not a valid email address') },\n]\n```\n\nA rule can read the row, so a column can be checked against another:\n`({ value, row }) => (value > row.budget ? 'Over budget' : null)`.\n\n## Refuse with `rejectInvalid`\n\n`rejectInvalid: true` makes the same rule a veto. The value is checked\nafter `valueParser` has run, so the rule sees what would be stored; when\nit fails, the editor closes, the cell keeps its old value, no undo step\nis recorded and `onCellValueChange` does not fire. The red highlight\nstill shows while the value is typed, so the user sees why before Enter\nputs the old value back. Type an age of 200 or an email without an\nat-sign:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { id: number; name: string; email: string; age: number }\n\n let rows = $state<Person[]>([\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', age: 36 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', age: 45 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', age: 54 },\n ])\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 170, editorType: 'text' },\n { field: 'email', header: 'Email', width: 210, editorType: 'text', rejectInvalid: true,\n validate: ({ value }) => (/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(String(value)) ? null : 'That is not an email address') },\n { field: 'age', header: 'Age', width: 90, editorType: 'number', rejectInvalid: true,\n validate: ({ value }) => (Number(value) < 16 || Number(value) > 100 ? 'Age must be between 16 and 100' : null) },\n ]\n</script>\n\n<SvGrid data={rows} {columns} editable containerHeight={200} />\n```\n\nThe inline editor, a commit to a selected range and a full-row edit all\ngo through the check. Paste does not open an editor and is not checked;\na pasted block that must be clean is checked in `onCellValueChange`, as\nbelow.\n\n## Built-in soft validation\n\n`parseEditorValue` already does light validation before either hook runs:\n\n- `number`: rejects non-finite results and stores `null`\n- `date` / `datetime`: rejects unparseable strings and stores `null`\n\nThe cell goes blank rather than red. A `validate` rule that treats `null`\nas invalid turns that into a message.\n\n## Rules over the whole row, and a log of rejections\n\nWhen the rule needs to see the row after the edit, or the app wants a\nrecord of what was refused, check the committed value in\n`onCellValueChange` and put the old one back. The event carries the row\nobject, so the revert is one assignment. Try an age of 200 or an email\nwithout an at-sign:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { id: number; name: string; email: string; age: number }\n\n let rows = $state<Person[]>([\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', age: 36 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', age: 45 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', age: 54 },\n ])\n let rejected = $state<string[]>([])\n\n function check(row: Person): string | null {\n if (row.age < 16 || row.age > 100) return 'Age must be between 16 and 100'\n if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(row.email)) return 'That is not an email address'\n return null\n }\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 170, editorType: 'text' },\n { field: 'email', header: 'Email', width: 210, editorType: 'text' },\n { field: 'age', header: 'Age', width: 90, editorType: 'number' },\n ]\n</script>\n\n<SvGrid\n data={rows}\n {columns}\n editable\n onCellValueChange={(e) => {\n const problem = check(e.row)\n if (!problem) return\n (e.row as Record<string, unknown>)[e.columnId] = e.oldValue\n rejected = [problem, ...rejected].slice(0, 4)\n }}\n/>\n\n<ul>\n {#each rejected as r}<li>{r}</li>{/each}\n</ul>\n```\n\nThe same pattern with a rollback through `api.setCellValue`, a red flash\nand a \"Recent rejections\" panel:\n\n<div data-docs-demo=\"24-validation\" data-height=\"500\"></div>\n\n## Marking the row instead of reverting\n\nReverting is right when the value is unusable. When it is merely wrong,\nkeeping it and marking the row lets someone fix a form in the order they want\nrather than the order the grid demands.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n city: string\n age: number\n salary: number\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', city: 'Portland', age: 54, salary: 155000 },\n ]\n\n import type { ConditionalFormat } from '@svgrid/grid'\n\n let rows = $state<Person[]>(seed.map((p) => ({ ...p })))\n\n const bad = (p: Person) => p.age < 16 || p.age > 100\n\n const problems = $derived(rows.filter(bad).length)\n\n const formats = $derived<ConditionalFormat<Person>[]>([\n { type: 'rule', columns: ['age'], when: (ctx) => bad(ctx.row),\n background: '#fee2e2', color: '#991b1b' },\n ])\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180, editorType: 'text' },\n { field: 'age', header: 'Age', width: 100, editorType: 'number' },\n ]\n</script>\n\n<SvGrid data={rows} {columns} editable conditionalFormats={formats} />\n\n<p aria-live=\"polite\">{problems === 0 ? 'All ages valid.' : problems + ' row(s) out of range.'}</p>\n```\n\n## See also\n\n- [Parsing values](./parsing-values.md)\n- [Saving values](./saving-values.md)\n- [demos/05-inline-editing.svelte](../../../examples/src/demos/05-inline-editing.svelte)\n"
|
|
4471
|
+
"markdown": "# Validation\n\nA column's `validate` hook flags a bad value: the cell turns red and the\nmessage becomes its tooltip. On its own it does not refuse anything, so\na form can be filled in any order and fixed afterwards; `rejectInvalid`\non the same column turns the flag into a refusal, and an edit the rule\nfails against is never written. Rules that need more than one row, or\nthat should be logged, run in `onCellValueChange` instead.\n\n## Flag with `validate`\n\n`validate` runs for every rendered cell, not only on edit, so bad data\nalready in the source is red on load, and it re-runs as the user types.\nIt receives `{ value, row, rowIndex, column }` and returns `null`,\n`undefined` or `true` for a valid value, `false` for invalid without a\nmessage, or a string that becomes the tooltip. Salary, email and age\nbelow each carry a rule; try a salary of 500 or an age of 12.\n\n<div data-docs-demo=\"206-cell-validation\" data-height=\"460\"></div>\n\n```svelte\nconst columns: GridColumns<Person> = [\n { field: 'salary', header: 'Salary', editorType: 'number',\n validate: ({ value }) => (Number(value) < 1000 ? 'Salary must be at least $1,000' : null) },\n { field: 'email', header: 'Email', editorType: 'text',\n validate: ({ value }) => (/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(String(value)) ? null : 'Not a valid email address') },\n]\n```\n\nA rule can read the row, so a column can be checked against another:\n`({ value, row }) => (value > row.budget ? 'Over budget' : null)`.\n\n## Refuse with `rejectInvalid`\n\n`rejectInvalid: true` makes the same rule a veto. The value is checked\nafter `valueParser` has run, so the rule sees what would be stored; when\nit fails, the editor closes, the cell keeps its old value, no undo step\nis recorded and `onCellValueChange` does not fire. The red highlight\nstill shows while the value is typed, so the user sees why before Enter\nputs the old value back. Type an age of 200 or an email without an\nat-sign:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { id: number; name: string; email: string; age: number }\n\n let rows = $state<Person[]>([\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', age: 36 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', age: 45 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', age: 54 },\n ])\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 170, editorType: 'text' },\n { field: 'email', header: 'Email', width: 210, editorType: 'text', rejectInvalid: true,\n validate: ({ value }) => (/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(String(value)) ? null : 'That is not an email address') },\n { field: 'age', header: 'Age', width: 90, editorType: 'number', rejectInvalid: true,\n validate: ({ value }) => (Number(value) < 16 || Number(value) > 100 ? 'Age must be between 16 and 100' : null) },\n ]\n</script>\n\n<SvGrid data={rows} {columns} editable containerHeight={200} />\n```\n\nThe inline editor, a commit to a selected range and a full-row edit all\ngo through the check. Paste does not open an editor and is not checked;\na pasted block that must be clean is checked in `onCellValueChange`, as\nbelow.\n\n## Built-in soft validation\n\n`parseEditorValue` already does light validation before either hook runs:\n\n- `number`: rejects non-finite results and stores `null`\n- `date` / `datetime`: rejects unparseable strings and stores `null`\n\nThe cell goes blank rather than red. A `validate` rule that treats `null`\nas invalid turns that into a message.\n\n## Rules over the whole row, and a log of rejections\n\nWhen the rule needs to see the row after the edit, or the app wants a\nrecord of what was refused, check the committed value in\n`onCellValueChange` and put the old one back. The event carries the row\nobject, so the revert is one assignment. Try an age of 200 or an email\nwithout an at-sign:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { id: number; name: string; email: string; age: number }\n\n let rows = $state<Person[]>([\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', age: 36 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', age: 45 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', age: 54 },\n ])\n let rejected = $state<string[]>([])\n\n function check(row: Person): string | null {\n if (row.age < 16 || row.age > 100) return 'Age must be between 16 and 100'\n if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(row.email)) return 'That is not an email address'\n return null\n }\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 170, editorType: 'text' },\n { field: 'email', header: 'Email', width: 210, editorType: 'text' },\n { field: 'age', header: 'Age', width: 90, editorType: 'number' },\n ]\n</script>\n\n<SvGrid\n data={rows}\n {columns}\n editable\n onCellValueChange={(e) => {\n const problem = check(e.row)\n if (!problem) return\n (e.row as Record<string, unknown>)[e.columnId] = e.oldValue\n rejected = [problem, ...rejected].slice(0, 4)\n }}\n/>\n\n<ul>\n {#each rejected as r}<li>{r}</li>{/each}\n</ul>\n```\n\nThe same pattern with a rollback through `api.setCellValue`, a red flash\nand a \"Recent rejections\" panel:\n\n<div data-docs-demo=\"24-validation\" data-height=\"500\"></div>\n\n## Marking the row instead of reverting\n\nReverting is right when the value is unusable. When it is merely wrong,\nkeeping it and marking the row lets someone fix a form in the order they want\nrather than the order the grid demands.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n city: string\n age: number\n salary: number\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', city: 'Portland', age: 54, salary: 155000 },\n ]\n\n import type { ConditionalFormat } from '@svgrid/grid'\n\n let rows = $state<Person[]>(seed.map((p) => ({ ...p })))\n\n const bad = (p: Person) => p.age < 16 || p.age > 100\n\n const problems = $derived(rows.filter(bad).length)\n\n const formats = $derived<ConditionalFormat<Person>[]>([\n { type: 'rule', columns: ['age'], when: (ctx) => bad(ctx.row),\n background: '#fee2e2', color: '#991b1b' },\n ])\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180, editorType: 'text' },\n { field: 'age', header: 'Age', width: 100, editorType: 'number' },\n ]\n</script>\n\n<SvGrid data={rows} {columns} editable conditionalFormats={formats} />\n\n<p aria-live=\"polite\">{problems === 0 ? 'All ages valid.' : problems + ' row(s) out of range.'}</p>\n```\n\n<!-- tutorial:learn-4-editing -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-4-editing\" data-docs-tutorial=\"learn-4-editing\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-4-editing.mp4\" poster=\"/tutorials/learn-4-editing.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 4: editing rows, and validating them, 91 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-4-editing.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-4-editing.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 4: editing rows, and validating them</strong> (1:31, silent). <a href=\"https://www.youtube.com/watch?v=4CMH4FKGZB4\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>A table people can only read is half a feature. This lesson makes it editable, and then makes it refuse the edits that should not happen.</p>\n<p>The same reactive rows as before. Three people, three fields.</p>\n<p>Each column picks its editor. Text for a name, number for an age. The editor is chosen per column, so the user gets the right control instead of a text box for everything.</p>\n<p>Then validate. It runs as the cell is edited and returns a message when the value is wrong. Pair it with rejectInvalid and the edit is refused outright rather than written and cleaned up later.</p>\n<p>Double-click a cell to edit it, type, and press Enter to commit. That is the whole gesture, and it is the one spreadsheet users already know.</p>\n<p>Now an email that is not one. Validate runs as you type, and hovering the cell tells the user what is wrong. Press Enter and rejectInvalid refuses the commit outright: the value is still the old one. Your row object is never touched by something you would have to clean up later.</p>\n<p>Undo with control zed, save in batches through the grid API, or hand every commit to your server. The editing pages in the docs cover each of those. Lesson five is selection, and doing something with the rows the user picked.</p>\n<p>The file is on the page below and you can edit it there too. See you in lesson five.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-4-editing -->\n\n## See also\n\n- [Parsing values](./parsing-values.md)\n- [Saving values](./saving-values.md)\n- [demos/05-inline-editing.svelte](../../../examples/src/demos/05-inline-editing.svelte)\n"
|
|
4448
4472
|
},
|
|
4449
4473
|
{
|
|
4450
4474
|
"slug": "help/errors",
|
|
@@ -4458,7 +4482,7 @@ export const docs = [
|
|
|
4458
4482
|
"path": "docs/help/export.md",
|
|
4459
4483
|
"title": "Data export and printing",
|
|
4460
4484
|
"section": "Import & export",
|
|
4461
|
-
"markdown": "# Data export and printing\n\n**CSV / TSV / JSON export and copy-to-clipboard are free** in the community\n**[@svgrid/grid](https://www.npmjs.com/package/@svgrid/grid)** - the commodity\n\"data out\" every grid needs. The richer formats - **Excel (xlsx)**, **legacy\nExcel (xls)**, **PDF**, styled **HTML**, **XML**, **Markdown**, multi-sheet\nworkbooks, password protection, conditional-format export, and the drop-in\nexport menu - ship in the paid\n**[@svgrid/enterprise](https://www.npmjs.com/package/@svgrid/enterprise)** add-on,\nwhich reuses the exact same serializers so the two tiers feel like one product.\n\nFor the report built step by step, with a live grid under most steps, see [Export: a report to Excel and PDF](./export/report.md); this page is the reference.\n\n\n\n| Capability | Package |\n| ---------- | ------- |\n| CSV / TSV / JSON export, copy-to-clipboard (TSV / CSV / Markdown) | **Free** (`@svgrid/grid`) |\n| Excel `.xlsx` (typed cells, styles, conditional formatting, tables, multi-sheet) | Enterprise |\n| PDF, styled HTML, XML, legacy `.xls`, password-protected export | Enterprise |\n| `exportValue` hook, conditional-format export, `SvExportMenu`, `print()` | Enterprise |\n\n## Free: CSV / TSV / JSON + clipboard\n\nThe community grid's `SvGridApi` carries four zero-dependency methods - no\nlicense, no peer deps:\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, tableFeatures, rowSortingFeature, columnFilteringFeature } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n department: string\n age: number\n salary: number\n city: string\n startDate: string\n active: boolean\n }\n\n type Order = {\n id: string\n customer: string\n product: string\n quantity: number\n total: number\n status: 'pending' | 'shipped' | 'delivered'\n orderedAt: string\n }\n\n type Row = Person\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering', age: 36, salary: 142000, city: 'London', startDate: '2021-03-01', active: true },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', department: 'Engineering', age: 45, salary: 168000, city: 'New York', startDate: '2019-07-15', active: true },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', department: 'Platform', age: 54, salary: 155000, city: 'Portland', startDate: '2020-01-20', active: false },\n { id: 4, name: 'Radia Perlman', email: 'radia@example.com', department: 'Networking', age: 49, salary: 161000, city: 'Seattle', startDate: '2022-09-05', active: true },\n { id: 5, name: 'Barbara Liskov', email: 'barbara@example.com', department: 'Platform', age: 52, salary: 172000, city: 'Boston', startDate: '2018-11-11', active: true },\n ]\n\n let rows = $state<Person[]>(people)\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```ts\nawait api.exportCsv({ filename: 'orders' }) // orders.csv (BOM + Excel-friendly)\nawait api.exportTsv() // grid.tsv\nawait api.exportJson({ rows: 'selected' }) // grid.json, checked rows only\nawait api.copyToClipboard({ format: 'tsv' }) // paste straight into Excel / Sheets\n```\n\nEvery method:\n\n- defaults to the **current view** (`rows: 'selected' | 'all'` to change),\n- formats values **as shown on screen** (currency, dates) - pass\n `rawValues: true` for the underlying values,\n- accepts a `columns: string[]` field subset (in that order),\n- runs on a chunked, cancelable loop (`signal`, `onProgress`) so a 100k-row\n export never freezes the tab,\n- returns the serialized text; pass `download: false` to skip the download\n and just get the string.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, type SvGridApi } from '@svgrid/grid'\n let api = $state<SvGridApi<any, Row> | null>(null)\n</script>\n\n<button onclick={() => api?.exportCsv({ filename: 'orders' })}>Export CSV</button>\n<button onclick={() => api?.copyToClipboard()}>Copy for Excel</button>\n<SvGrid data={rows} columns={columns} features={features} onApiReady={(a) => (api = a)} />\n```\n\nThe rest of this page covers the **Enterprise** formats.\n\nTry the export bar below - downloads run in your browser; the bundled\nlicense key removes the unlicensed watermark:\n\n<div data-docs-demo=\"21-export-and-print\" data-height=\"500\"></div>\n\n\n## What it is\n\n`@svgrid/enterprise` augments the `SvGridApi` you already get from\n`<SvGrid onApiReady>` with two methods:\n\n- `api.exportData({ format, filename?, columns?, rows?, pageOrientation? })`\n- `api.print({ title?, columns?, rows?, orientation? })`\n\nBoth methods default to **the currently displayed rows** - sort, filter,\nor paginate the grid, and the export reflects that view automatically.\n\n## When to use it\n\n- Reporting flows where users want to take the grid offline (spreadsheets,\n emailed PDFs).\n- Compliance / audit trails that require a printable artifact.\n- Quick CSV/TSV pulls for downstream pipelines.\n\nIf you only need machine-readable data, prefer CSV / TSV - they have no\npeer dependencies and produce the smallest files. Use xlsx / PDF only\nwhen the recipient expects formatted documents.\n\n## Minimal example\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature, type SvGridApi, type ColumnDef } from '@svgrid/grid'\n import { installEnterprise, setLicenseKey, type EnterpriseGridApi } from '@svgrid/enterprise'\n\n // Set the license key once at startup. Without a key, the feature still\n // works but the grid shows an \"unlicensed\" watermark and the console\n // emits a one-time nudge directing users to the pricing page.\n setLicenseKey('SVENTERPRISE-XXXX-XXXX-XXXX')\n\n const features = tableFeatures({ rowSortingFeature })\n\n type Order = { company: string; product: string; price: number }\n const rows: Order[] = [\n { company: 'ACME', product: 'Widget', price: 19.95 },\n { company: 'Globex', product: 'Gadget', price: 49.00 },\n ]\n const columns: ColumnDef<typeof features, Order>[] = [\n { field: 'company', header: 'Company' },\n { field: 'product', header: 'Product' },\n { field: 'price', header: 'Price', format: { type: 'currency', currency: 'USD' } },\n ]\n\n let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\n\n function onReady(next: SvGridApi<typeof features, Order>) {\n api = installEnterprise(next)\n }\n</script>\n\n<button onclick={() => api?.exportData({ format: 'xlsx', filename: 'orders' })}>\n Export Excel\n</button>\n<button onclick={() => api?.exportData({ format: 'pdf', filename: 'orders', pageOrientation: 'landscape' })}>\n Export PDF\n</button>\n<button onclick={() => api?.print({ title: 'Orders' })}>\n Print\n</button>\n\n<SvGrid data={rows} columns={columns} features={features} onApiReady={onReady} />\n```\n\n## Install\n\n```bash\npnpm add @svgrid/enterprise\n# Optional - install only the peers you actually use:\npnpm add jszip # required for xlsx\npnpm add pdfmake # required for pdf\n```\n\nCSV, TSV, HTML, and Print have **no extra dependencies**. The peer\ndependencies are lazy-loaded only when you call the format that needs\nthem.\n\n## Licensing\n\n`@svgrid/enterprise` has a tiered license gate:\n\n| Key state | Behavior |\n| ------------------------------------------ | -------- |\n| No key set (`setLicenseKey()` not called) | Feature works. Grid shows an unlicensed watermark linking to jqwidgets.com; console.log emits a one-time nudge. |\n| Key doesn't start with `SVENTERPRISE-` | Throws - programmer error. |\n| Key is in the revoked list | Throws - contact support for a replacement. |\n| `SVENTERPRISE-DEV-...` or `SVENTERPRISE-EVAL-...` | Works. One-time console.info notice. No watermark. |\n| Any other `SVENTERPRISE-...` | Works silently. |\n\nBuy a production key at <https://svgrid.com/pricing/> ($599 / developer /\nyear). `SVENTERPRISE-DEV-...` and `SVENTERPRISE-EVAL-...` keys cover local development\nand 30-day trials respectively.\n\n## Reference\n\n### `setLicenseKey(key: string): void`\n\nStores the key in module state. Call once at app startup (e.g. in\n`main.ts`). Subsequent calls overwrite.\n\n### `clearLicenseKey(): void` · `hasValidLicense(): boolean` · `dismissUnlicensedNudge(): void`\n\nProgrammatic helpers. `hasValidLicense()` is useful when you want UI to\nbranch on license status. `dismissUnlicensedNudge()` removes the\nwatermark and stops the MutationObserver - call it after setting a\nvalid key if you loaded the module without one during testing.\n\n### `installEnterprise(api): EnterpriseGridApi`\n\nMutates the given `SvGridApi` to add `exportData` and `print`. Returns\nthe same object with the augmented type, so existing references keep\nworking.\n\n### `api.exportData(opts)` - `Promise<void>`\n\n| Option | Type | Default | Notes |\n| ----------------- | ----------------------------------------------------- | -------------------- | ----- |\n| `format` | `'xlsx' \\| '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. |\n| `pdf` | `PdfExportOptions` | - | PDF layout: page size, margins, title / subtitle / logo, theme colors, column widths, repeated header, page numbers. See \"PDF options\". |\n| `conditionalFormats` | `ConditionalFormat[]` | - | Same array as `<SvGrid conditionalFormats>`; carried into pdf / xlsx / html. See \"Conditional formatting in exports\". |\n| `freezeHeader` | `boolean` | `true` | Freeze the header row (xlsx / xls). |\n| `freezeColumns` | `number` | pinned cols | Freeze this many leading columns (xlsx / xls). Defaults to the grid's left-pinned columns. |\n| `autoFitColumns` | `boolean` | `true` | Size columns to content (xlsx / xls); a column's explicit `width` wins. |\n| `precisionSafe` | `boolean` | `false` | Export long integer IDs (> 15 digits) as text so Excel's float precision doesn't round them (xlsx / xls). |\n| `excelTable` | `boolean \\| { totalsRow?; style? }` | - | Wrap single-sheet xlsx in a native Excel Table (filter dropdowns, banded rows; `{ totalsRow: true }` adds SUM formulas). |\n| `download` | `boolean` | `true` | When false, return the {@link ExportResult} without downloading. |\n| `filename` | `string` | `\"grid\"` | Extension is appended if missing. |\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. |\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. |\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). |\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. |\n| `csv` | `{ delimiter?; eol?; bom? }` | `{ ',', '\\r\\n', BOM }` | CSV / TSV tuning. UTF-8 BOM is on by default so Excel opens it without mojibake. |\n| `onProgress` | `(p) => void` | - | Progress for large exports: `{ phase, ratio, row?, total? }`, `phase` = `'project' \\| 'serialize' \\| 'write'`. |\n| `signal` | `AbortSignal` | - | Cancel an in-flight export (checked between chunks). Rejects with `AbortError`. |\n| `pageOrientation` | `'portrait' \\| 'landscape'` | `\"portrait\"` | PDF only. |\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`. |\n\n### Faithful values (what you see is what you export)\n\nBy default the export renders each cell the way the grid does on screen: a\ncolumn with `format: { type: 'currency', currency: 'USD' }` exports `$19.95`,\na date column exports its formatted date, a percent column exports `42%`. This\nholds for **every** format (csv / tsv / html / pdf / xlsx). Pass\n`rawValues: true` to write the underlying values instead (e.g. feeding a data\npipeline that wants the numeric `19.95`).\n\nFor columns whose on-screen value comes from a **custom cell renderer**, a\n`fieldFn`, or a lookup, give the column an `exportValue` hook - it takes\nprecedence over `format` and the raw field:\n\n```ts\nawait api.exportData({\n format: 'xlsx',\n columns: [\n { field: 'company', header: 'Company' },\n // a column rendered with a custom snippet on screen:\n { field: 'status', header: 'Status', exportValue: (row) => STATUS_LABELS[row.status] },\n ],\n})\n```\n\n### Export only what's selected\n\n```ts\n// current view (default)\nawait api.exportData({ format: 'xlsx' })\n// only the checked rows\nawait api.exportData({ format: 'xlsx', rows: 'selected' })\n// the entire underlying dataset, ignoring filters/paging\nawait api.exportData({ format: 'csv', rows: 'all' })\n```\n\n### Large exports: progress + cancel\n\nCSV / TSV / HTML are serialized natively (no peer dependency) and stream in\nchunks, yielding to the event loop so a big export doesn't freeze the tab:\n\n```ts\nconst controller = new AbortController()\nawait api.exportData({\n format: 'csv',\n rows: 'all',\n signal: controller.signal,\n onProgress: ({ ratio }) => updateProgressBar(ratio),\n})\n// controller.abort() rejects the export with an AbortError\n```\n\n### Excel-native numbers (xlsx)\n\nA single-sheet `xlsx` export writes numeric, currency, percent, and date\ncolumns as **real typed cells** with an Excel number format - so Excel can sum\nand sort them, and they still display formatted (currency symbol, thousands,\n`%`, date pattern) - via SvGrid's own OOXML writer. Set `rawValues: true` for\nunformatted raw values. The grouped-outline, multi-sheet, image, and merged-cell\npaths use the bundled writer (formatted-string cells). The legacy `xls` format\nis always typed.\n\n### Excel fidelity: freeze, auto-fit, hyperlinks\n\n`xlsx` and `xls` exports polish the spreadsheet automatically:\n\n- **Freeze header** - the header row stays visible when scrolling. On by\n default; `freezeHeader: false` to disable, `freezeColumns: n` to also freeze\n leading columns.\n- **Auto-fit column widths** - columns are sized to their content. On by\n default (`autoFitColumns: false` to disable); a column's explicit `width`\n (pixels) always wins.\n- **Hyperlinks** - give a column a `link(row)` hook to render clickable links.\n Works in `xlsx`, `xls`, `html`, `pdf`, and `print`.\n\nSingle-sheet `xlsx` is written by SvGrid's own OOXML writer, so numbers/dates\nstay typed with number formats, conditional-format colors are **real per-cell\nfills**, and freeze/widths/hyperlinks all apply. (Grouped/outline, multi-sheet,\nembedded-image, and merged-cell exports use the bundled writer.)\n\n```ts\nawait api.exportData({\n format: 'xlsx',\n freezeHeader: true, // default\n columns: [\n { field: 'name', header: 'Company', width: 220,\n link: (row) => `https://crm.example.com/accounts/${row.id}` },\n { field: 'revenue', header: 'Revenue', format: { type: 'currency', currency: 'USD' } },\n ],\n})\n```\n\nConditional-format colors (data bars / color scales) already render as real\ncell fills in `xlsx`, which is higher fidelity than the writer's built-in\nrule-only conditional formatting - so we keep the per-cell approach.\n\n### Get the file instead of downloading (`download: false`)\n\n`exportData` returns an **`ExportResult`** for the dependency-free paths\n(csv/tsv/html/json/xml/md, xls, pdf, single-sheet xlsx) so you can preview,\nupload, email, or attach the file. With `download: false` it builds and returns\nthe file **without** triggering a browser download:\n\n```ts\nconst res = await api.exportData({ format: 'xlsx', download: false })\n// res: { blob, filename, mime, rowCount, byteSize }\nawait uploadToStorage(res.blob, res.filename)\n```\n\nThe grouped / multi-sheet / image / blanket-styled xlsx paths use the vendored\nwriter, which downloads directly and returns `undefined` (and throws on\n`download: false`).\n\n### Excel Table (filter dropdowns + totals row)\n\nWrap a single-sheet xlsx in a native **Excel Table** - the user gets column\nfilter dropdowns, banded rows, structured references, and (optionally) a totals\nrow with live `SUM()` formulas:\n\n```ts\nawait api.exportData({ format: 'xlsx', excelTable: { totalsRow: true } })\n// or just `excelTable: true` for the table without a totals row\n```\n\nColumn widths auto-fit and leading **pinned columns are frozen automatically**\n(override with `freezeColumns`). For long IDs, `precisionSafe: true` writes them\nas text so Excel's 15-digit float limit doesn't silently round `1002000300040005`.\n\n### JSON / XML / Markdown\n\n```ts\nawait api.exportData({ format: 'json' }) // array of { field: value } - defaults to raw values\nawait api.exportData({ format: 'xml' }) // <rows><row><field>value</field>…\nawait api.exportData({ format: 'md' }) // GitHub-flavored Markdown table (great for docs / LLMs)\n```\n\nJSON defaults to **raw** values (real numbers/dates) since it's a data format;\npass `rawValues: false` for formatted strings. XML and Markdown use the\nformatted display values.\n\n### Copy to clipboard\n\n`api.copyExport(opts)` serializes to the clipboard instead of downloading a\nfile - handy for \"copy these rows into a spreadsheet or an email\":\n\n```ts\nawait api.copyExport({ format: 'tsv' }) // pastes into Excel / Sheets as columns (default)\nawait api.copyExport({ format: 'md', rows: 'selected' }) // Markdown table of the checked rows\nawait api.copyExport({ format: 'html' }) // rich text/html - keeps the table when pasted\n```\n\nSupported formats: `csv`, `tsv`, `html`, `json`, `xml`, `md`. `html` writes\nboth `text/html` (rich paste) and a `text/plain` fallback. Needs a secure\ncontext and a user gesture (call it from a click).\n\n### Conditional formatting in exports\n\nPass the same `conditionalFormats` array you give `<SvGrid>` to carry the grid's\ncolor scales, data bars, icon sets, and predicate rules into the **styled**\nformats - PDF, xlsx, and HTML (and `print()`):\n\n```ts\nconst conditionalFormats = [\n { type: 'colorScale', columns: ['score'], min: '#dcfce7', max: '#fee2e2' },\n { type: 'rule', columns: ['status'], when: ({ value }) => value === 'Overdue', background: '#fecaca' },\n]\n\nawait api.exportData({ format: 'pdf', conditionalFormats })\nawait api.exportData({ format: 'xlsx', conditionalFormats }) // real cell fills in Excel\nawait api.print({ conditionalFormats })\n```\n\nCell background, text color, and bold carry through as **real cell fills** in\n`xlsx` (single-sheet), `xls`, `html`, and `pdf`; icon sets prepend their glyph\nto the cell text.\n\nIn **xlsx**, **data bars** and **color scales** become **native Excel\nconditional formatting** (`<conditionalFormatting>` rules) - Excel draws and\nrecomputes them itself, so they're interactive, not a static snapshot.\nPredicate `rule` formats stay as computed per-cell fills. In the other styled\nformats (`xls` / `html` / `pdf`) a data bar degrades to a light fill of the bar\ncolor (cells can't hold a partial-width bar). The data formats (csv / tsv /\njson / xml / md) are unstyled, so conditional formatting is ignored there.\nGrouped PDF exports skip conditional formatting (the group/subtotal rows own the\nlayout).\n\n### PDF options\n\nThe PDF is built from a full pdfmake document you control via `pdf`:\n\n```ts\nawait api.exportData({\n format: 'pdf',\n filename: 'orders',\n pdf: {\n pageSize: 'A4', // 'A4' | 'A3' | 'A5' | 'LETTER' | 'LEGAL'\n pageOrientation: 'landscape',\n title: 'Q3 Orders',\n subtitle: 'Generated by Ops',\n logo: logoDataUrl, // top-left image (data URL)\n headerColor: '#334155', // header row fill\n zebra: true, // striped rows\n columnWidths: '*', // '*' fill, 'auto', or a per-column pt array\n // repeatHeader (default true), showPageNumbers (default true) also available\n },\n})\n```\n\nThe header row repeats on every page, each page gets a `Page X of Y` +\ndate footer, per-column alignment is honored, and wide grids auto-switch to\nlandscape (override with `pdf.pageOrientation`).\n\nThe PDF looks like the grid it came from. With `pdf.matchTheme` (default\ntrue) the exporter reads the mounted grid's computed colours - header fill\nand text, body text, the zebra stripe, the lines, a group row's fill - so a\ngrid on the Ember theme or a Theme Builder palette prints in those colours\nrather than the slate defaults. A dark theme lends only its header: the\npage is white, so its body text and stripes would not read. The order of\nprecedence is explicit `pdf` colours (`headerColor`, `headerTextColor`,\n`textColor`, `zebraColor`, `borderColor`, `groupColor`, ...), then\n`styles`, then the theme, then the defaults. `styles.headerRow` /\n`styles.rows` / `styles.rowAlternate` / `styles.cells` (A1 references, row\n1 being the header) apply to the PDF table the way they do to xlsx and\nhtml, `header` / `footer` lines print above and below the table, and\n`merges` span data cells.\n\nWhen the grid is grouped, the PDF carries the grouping: a bold **group header**\nper cluster (nested for multi-level grouping) and a **subtotal row** summing the\nnumber / currency columns - matching the xlsx outline export. It's on by\ndefault (`autoGroup`); pass `groupBy` to override or `autoGroup: false` to get a\nflat table.\n\n#### Charts and a KPI strip\n\nA report is rarely just the table. `pdf.charts` prints charts with it: hand\nover the rendered chart's element (the `SvChart` wrapper or its `<svg>`) and\nthe export rasterises it at 2x through the chart's own PNG path, so the page\ngets the picture on screen, theme and all; or pass an image data URL you\nalready have. Each takes a `title`, a `caption` and a `width` in pt\n(default: the content width). `pdf.kpis` prints a strip of headline numbers\nabove the table, each with a label, the value and an optional `delta` line\nin its own colour:\n\n```svelte\n<div bind:this={chartEl}><SvChart {spec} /></div>\n\n<button onclick={() => api.exportData({\n format: 'pdf',\n filename: 'q3-report',\n pdf: {\n title: 'Q3 Orders',\n kpis: [\n { label: 'Revenue', value: '$1.24M', delta: '+4.2% vs Q2', color: '#16a34a' },\n { label: 'Orders', value: '842' },\n { label: 'Avg order', value: '$1,473', delta: '-1.1%', color: '#ef4444' },\n ],\n charts: [{ element: chartEl, title: 'Revenue by region', caption: 'Displayed rows, grouped by region' }],\n chartsPosition: 'above', // or 'below' the table\n },\n})}>Export report</button>\n```\n\nA chart block never splits across a page break. `resolvePdfCharts` and\n`buildPdfDocDefinition` are exported for a document you assemble yourself.\n\n#### Merged cells\n\nPass `merges` to write real merged regions into the sheet. Row / column\nindices are zero-based over the exported **body** (the header row is not\ncounted), and the shape lines up with the grid's own `MergeSpec`:\n\n```ts\n// Merge the first two data rows of column 0, and span a 3-column banner\nawait api.exportData({\n format: 'xlsx',\n merges: [\n { row: 0, col: 0, rowSpan: 2 }, // vertical merge (e.g. a repeated group key)\n { row: 5, col: 0, colSpan: 3 }, // horizontal merge (a section banner)\n ],\n})\n```\n\nTo export the merges you already show in the grid, take your `MergeSpec[]`\n(`{ rowIndex, columnId, rowspan, colspan }`) and map `columnId` to its column\nindex: `{ row: m.rowIndex, col: colIndex(m.columnId), rowSpan: m.rowspan, colSpan: m.colspan }`.\n\nThrows on missing peer (`jszip` / `pdfmake`), revoked / malformed\nlicense, or empty result set. With no license set, it runs but the\ngrid is watermarked.\n\n### `api.print(opts?)` - `Promise<void>`\n\nOpens a new window with a paginated, printable rendering of the grid and\ntriggers the browser print dialog. This is the **zero-dependency \"Save as PDF\"**\nroute: it uses the browser's own engine, so it has excellent font + CSS\nfidelity (including CJK / RTL) and needs no `pdfmake`. The header row repeats on\nevery page, values print **formatted** (matching the grid), and columns keep\ntheir alignment.\n\n| Option | Type | Default |\n| ------------- | -------------------------------------- | ----------- |\n| `title` | `string` | `\"Grid\"` |\n| `subtitle` | `string` | - |\n| `logo` | `string` (data URL) | - |\n| `columns` | `{ field; header?; format?; align? }[]`| grid's own columns |\n| `rows` | `ReadonlyArray<TData> \\| 'displayed' \\| 'selected' \\| 'all'` | `'displayed'` |\n| `rawValues` | `boolean` | `false` |\n| `orientation` | `'portrait' \\| 'landscape'` | `\"portrait\"` |\n| `pageSize` | `string` (e.g. `'A4'`, `'Letter'`) | browser default |\n| `margin` | `string` (e.g. `'14mm'`) | `'14mm'` |\n| `zebra` | `boolean` | `true` |\n| `headerColor` | `string` | `'#f1f5f9'` |\n\nBrowsers may block the popup unless `print()` is called from a user\ngesture (a click handler is fine - automatic on-load print is not). Page\nnumbers come from the browser's own print header/footer.\n\n**`exportData({ format: 'pdf' })` vs `print()`** - use PDF export for a\nprogrammatic, silent file download with full layout control (see \"PDF\noptions\"); use `print()` when you want best-fidelity output and the user to\npick print or \"Save as PDF\" from the dialog.\n\n## Drop-in Export menu (`SvExportMenu`)\n\nRather than wiring your own toolbar, drop in the bundled `SvExportMenu`\ncomponent. It renders an \"Export\" button with a format picker, a row-scope\ntoggle (current view / selected / all), and a built-in progress bar + Cancel\nfor large exports:\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, type SvGridApi } from '@svgrid/grid'\n import { installEnterprise, SvExportMenu, type EnterpriseGridApi } from '@svgrid/enterprise'\n\n let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\n</script>\n\n<div class=\"toolbar\">\n <SvExportMenu {api} filename=\"orders\" />\n</div>\n\n<SvGrid data={rows} columns={columns} features={features}\n onApiReady={(a) => (api = installEnterprise(a))} />\n```\n\n| Prop | Type | Default | Notes |\n| ------------ | ------------------------------------------------ | --------------------------- | ----- |\n| `api` | `EnterpriseGridApi \\| null` | required | Button is disabled until the api is ready. |\n| `filename` | `string` | `'grid'` | Base name, no extension. |\n| `formats` | `ExportFormat[]` | xlsx/xls/pdf/csv/tsv/html | Which formats to offer, in order. |\n| `allowScope` | `boolean` | `true` | Show the current-view / selected / all toggle. |\n| `allowColumns` | `boolean` | `true` | Show per-column include checkboxes (choose which columns to export). |\n| `allowCopy` | `boolean` | `true` | Show \"Copy for Excel\" (tsv) + \"Copy Markdown\" clipboard actions. |\n| `allowPrint` | `boolean` | `false` | Show a \"Print…\" action (`api.print`). |\n| `scope` | `'displayed' \\| 'selected' \\| 'all'` | `'displayed'` | Initial row scope. |\n| `label` | `string` | `'Export'` | Button text. |\n| `conditionalFormats` | `ConditionalFormat[]` | - | Carried into pdf / xlsx / html exports. |\n\nThe menu is sectioned into **Rows** (scope), **Columns** (a picker), and\n**Download** (each format shown with an icon + one-line description), plus\n**copy-to-clipboard** and optional **print** actions. It's **keyboard\naccessible** (arrow keys move between items, Tab is trapped inside, Esc closes\nand returns focus to the button, with `role=\"menu\"` / `menuitem`), handles\nerrors inline (missing peer dependency, empty grid), and wires `onProgress` +\nan `AbortController` for you.\n\nFor large exports, the single-sheet xlsx writer builds in **chunks that yield to\nthe event loop** (and zips asynchronously), so the tab stays responsive and\n`onProgress` ticks; pass a `signal` to cancel.\n\n## Gotchas\n\n- **Empty grids** - both `exportData` and `print` throw if there are no\n displayed rows. Catch the error and show a notice in the UI.\n- **Column ordering** - if you don't pass `columns`, the export uses\n `Object.keys(rows[0])` order. Pass `columns` explicitly when the row\n shape doesn't match the column order you want.\n- **Cell formatters** - column-level format hints (date, number, currency,\n percent) are applied to the exported value by default, so the file matches\n the grid. Custom snippet renderers are **not** auto-serialized - give those\n columns an `exportValue` hook (see \"Faithful values\" above), or set\n `rawValues: true` if you want the raw underlying values.\n- **Print popup blocked** - `print()` resolves but the browser silently\n blocks the new window. Always trigger from a user click, and surface\n the thrown error.\n- **Bundle size** - the vendored exporter is ~50 KB minified. It is\n loaded lazily on first call so it does not bloat the initial bundle\n for users who never export.\n\n## Frequently asked questions\n\n### How do I export a Svelte data grid to Excel?\n\nInstall `@svgrid/enterprise`, call `installEnterprise(api)`, then call the export helper with\n`format: 'xlsx'`. The exporter writes a real OOXML workbook in the browser - no\nserver round-trip. CSV, TSV, PDF, and HTML use the same call with a different\nformat.\n\n### What's the difference between `xls` and `xlsx`?\n\n`xlsx` is the modern OOXML workbook (Excel 2007+); it needs the `jszip` peer\ndependency and supports the richest output (styles, images, multi-sheet,\nformulas). `xls` is the **legacy Excel 2003 XML Spreadsheet** format: pick it\nfor maximum compatibility with old Excel installs or systems that only accept\n`.xls`. It has **no peer dependency**, streams in the browser, and keeps\nnumbers and dates typed (so sums and sorting still work in Excel) with number\nformats applied. It's single-sheet - use `xlsx` for multi-tab workbooks.\n\n```ts\nawait api.exportData({ format: 'xls', filename: 'orders' })\n```\n\n### Is export part of the free Community package?\n\nNo. Export and printing ship in the paid `@svgrid/enterprise` add-on. The free\n`@svgrid/grid` package covers the full grid (sorting, filtering, grouping,\nediting, virtualization) but not export/print/pivot/import.\n\n### Does exporting bloat my bundle?\n\nNo. The ~50 KB exporter is lazy-loaded on the first export call, so users who\nnever export never download it.\n\n## More examples\n\n### Password-protected export\n\nPBKDF2 (100k iters) + AES-GCM 256 client-side. Strength meter, encrypt + download, in-page decrypt tool to verify the round-trip. Enterprise pack maps to ECMA-376 Agile encryption.\n\n<div data-docs-demo=\"93-password-protected-export\" data-height=\"460\"></div>\n\n### Formulas preserved in xlsx\n\nBuilds a real OOXML workbook in the browser via JSZip; computed columns export as <f>...</f> formula cells. Open in Excel and the math recomputes when you edit a number.\n\n<div data-docs-demo=\"101-formulas-in-xlsx\" data-height=\"460\"></div>\n\n### Export grouped grid to Excel\n\nA flat sales grid (Region → Country) exported via api.exportData({ format: \"xlsx\", groupBy }) which uses Smart's NATIVE Excel row outline grouping. Opens in Excel with +/- buttons in the row header gutter for every group level.\n\n<div data-docs-demo=\"126-export-grouped-grid\" data-height=\"460\"></div>\n\n### Export pivot grid to Excel\n\ncreatePivotModel leaves projected into an xlsx via api.exportData with groupBy: [\"region\"] - each region becomes an Excel outline group. Engine column ids (\"pv__Q1__m0\") translate to readable headers (\"Q1 · Revenue\").\n\n<div data-docs-demo=\"127-export-pivot-grid\" data-height=\"460\"></div>\n\n### Export - Formatting, scope + XLS\n\nFaithful export: currency / date / percent render as shown (toggle raw values), pick a row scope (view / selected / all), legacy .xls (Excel 2003 XML, no jszip), an exportValue hook, and the drop-in SvExportMenu.\n\n<div data-docs-demo=\"201-export-formatting-and-xls\" data-height=\"460\"></div>\n\n### Export - Grouped PDF + Print\n\nGroup the grid and export: the PDF + xlsx carry bold group headers and per-cluster subtotal rows (auto-carried from grouping state). Polished Print is the zero-dependency \"Save as PDF\" route with a repeated header and title.\n\n<div data-docs-demo=\"202-export-pdf-grouped-and-print\" data-height=\"460\"></div>\n\n## Try the export API\n\nEvery exporter is on the grid api and takes the same\n`GridExportOptions`. `rows: 'displayed'` is the default and is almost always\nwhat a user means by \"export this\" - it follows their filter and sort.\n\n```svelte\n<script lang=\"ts\">\n let api = $state<SvGridApi<{}, Person> | null>(null)\n</script>\n\n<div>\n <button type=\"button\" onclick={() => api?.exportCsv({ filename: 'people' })}>CSV</button>\n <button type=\"button\" onclick={() => api?.exportTsv({ filename: 'people' })}>TSV</button>\n <button type=\"button\" onclick={() => api?.exportJson({ filename: 'people' })}>JSON</button>\n <button type=\"button\" onclick={() => api?.print()}>Print</button>\n</div>\n\n<SvGrid data={people} {columns} sortable filterable onApiReady={(next) => (api = next)} />\n```\n\nSort or filter first, then export: the file follows the view.\n\n## Exporting a subset\n\n`columns` restricts the fields and fixes their order, and `rawValues` decides\nwhether the file carries what the screen shows or what the row holds. The\nsalary column is the one to watch - formatted it is `$142,000`, raw it is\n`142000`, and a spreadsheet can only do arithmetic on the second.\n\n```svelte\n<script lang=\"ts\">\n let api = $state<SvGridApi<{}, Person> | null>(null)\n</script>\n\n<div>\n <button type=\"button\" onclick={() => api?.exportCsv({ filename: 'formatted', columns: ['name', 'salary'] })}>\n Name + salary, as displayed\n </button>\n <button type=\"button\" onclick={() => api?.exportCsv({ filename: 'raw', columns: ['name', 'salary'], rawValues: true })}>\n Name + salary, raw values\n </button>\n</div>\n\n<SvGrid data={people} {columns} onApiReady={(next) => (api = next)} />\n```\n"
|
|
4485
|
+
"markdown": "# Data export and printing\n\n**CSV / TSV / JSON export and copy-to-clipboard are free** in the community\n**[@svgrid/grid](https://www.npmjs.com/package/@svgrid/grid)** - the commodity\n\"data out\" every grid needs. The richer formats - **Excel (xlsx)**, **legacy\nExcel (xls)**, **PDF**, styled **HTML**, **XML**, **Markdown**, multi-sheet\nworkbooks, password protection, conditional-format export, and the drop-in\nexport menu - ship in the paid\n**[@svgrid/enterprise](https://www.npmjs.com/package/@svgrid/enterprise)** add-on,\nwhich reuses the exact same serializers so the two tiers feel like one product.\n\nFor the report built step by step, with a live grid under most steps, see [Export: a report to Excel and PDF](./export/report.md); this page is the reference.\n\n\n\n| Capability | Package |\n| ---------- | ------- |\n| CSV / TSV / JSON export, copy-to-clipboard (TSV / CSV / Markdown) | **Free** (`@svgrid/grid`) |\n| Excel `.xlsx` (typed cells, styles, conditional formatting, tables, multi-sheet) | Enterprise |\n| PDF, styled HTML, XML, legacy `.xls`, password-protected export | Enterprise |\n| `exportValue` hook, conditional-format export, `SvExportMenu`, `print()` | Enterprise |\n\n## Free: CSV / TSV / JSON + clipboard\n\nThe community grid's `SvGridApi` carries four zero-dependency methods - no\nlicense, no peer deps:\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, tableFeatures, rowSortingFeature, columnFilteringFeature } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n department: string\n age: number\n salary: number\n city: string\n startDate: string\n active: boolean\n }\n\n type Order = {\n id: string\n customer: string\n product: string\n quantity: number\n total: number\n status: 'pending' | 'shipped' | 'delivered'\n orderedAt: string\n }\n\n type Row = Person\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering', age: 36, salary: 142000, city: 'London', startDate: '2021-03-01', active: true },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', department: 'Engineering', age: 45, salary: 168000, city: 'New York', startDate: '2019-07-15', active: true },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', department: 'Platform', age: 54, salary: 155000, city: 'Portland', startDate: '2020-01-20', active: false },\n { id: 4, name: 'Radia Perlman', email: 'radia@example.com', department: 'Networking', age: 49, salary: 161000, city: 'Seattle', startDate: '2022-09-05', active: true },\n { id: 5, name: 'Barbara Liskov', email: 'barbara@example.com', department: 'Platform', age: 52, salary: 172000, city: 'Boston', startDate: '2018-11-11', active: true },\n ]\n\n let rows = $state<Person[]>(people)\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```ts\nawait api.exportCsv({ filename: 'orders' }) // orders.csv (BOM + Excel-friendly)\nawait api.exportTsv() // grid.tsv\nawait api.exportJson({ rows: 'selected' }) // grid.json, checked rows only\nawait api.copyToClipboard({ format: 'tsv' }) // paste straight into Excel / Sheets\n```\n\nEvery method:\n\n- defaults to the **current view** (`rows: 'selected' | 'all'` to change),\n- formats values **as shown on screen** (currency, dates) - pass\n `rawValues: true` for the underlying values,\n- accepts a `columns: string[]` field subset (in that order),\n- runs on a chunked, cancelable loop (`signal`, `onProgress`) so a 100k-row\n export never freezes the tab,\n- returns the serialized text; pass `download: false` to skip the download\n and just get the string.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, type SvGridApi } from '@svgrid/grid'\n let api = $state<SvGridApi<any, Row> | null>(null)\n</script>\n\n<button onclick={() => api?.exportCsv({ filename: 'orders' })}>Export CSV</button>\n<button onclick={() => api?.copyToClipboard()}>Copy for Excel</button>\n<SvGrid data={rows} columns={columns} features={features} onApiReady={(a) => (api = a)} />\n```\n\nThe rest of this page covers the **Enterprise** formats.\n\nTry the export bar below - downloads run in your browser; the bundled\nlicense key removes the unlicensed watermark:\n\n<div data-docs-demo=\"21-export-and-print\" data-height=\"500\"></div>\n\n\n## What it is\n\n`@svgrid/enterprise` augments the `SvGridApi` you already get from\n`<SvGrid onApiReady>` with two methods:\n\n- `api.exportData({ format, filename?, columns?, rows?, pageOrientation? })`\n- `api.print({ title?, columns?, rows?, orientation? })`\n\nBoth methods default to **the currently displayed rows** - sort, filter,\nor paginate the grid, and the export reflects that view automatically.\n\n## When to use it\n\n- Reporting flows where users want to take the grid offline (spreadsheets,\n emailed PDFs).\n- Compliance / audit trails that require a printable artifact.\n- Quick CSV/TSV pulls for downstream pipelines.\n\nIf you only need machine-readable data, prefer CSV / TSV - they have no\npeer dependencies and produce the smallest files. Use xlsx / PDF only\nwhen the recipient expects formatted documents.\n\n## Minimal example\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature, type SvGridApi, type ColumnDef } from '@svgrid/grid'\n import { installEnterprise, setLicenseKey, type EnterpriseGridApi } from '@svgrid/enterprise'\n\n // Set the license key once at startup. Without a key, the feature still\n // works but the grid shows an \"unlicensed\" watermark and the console\n // emits a one-time nudge directing users to the pricing page.\n setLicenseKey('SVENTERPRISE-XXXX-XXXX-XXXX')\n\n const features = tableFeatures({ rowSortingFeature })\n\n type Order = { company: string; product: string; price: number }\n const rows: Order[] = [\n { company: 'ACME', product: 'Widget', price: 19.95 },\n { company: 'Globex', product: 'Gadget', price: 49.00 },\n ]\n const columns: ColumnDef<typeof features, Order>[] = [\n { field: 'company', header: 'Company' },\n { field: 'product', header: 'Product' },\n { field: 'price', header: 'Price', format: { type: 'currency', currency: 'USD' } },\n ]\n\n let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\n\n function onReady(next: SvGridApi<typeof features, Order>) {\n api = installEnterprise(next)\n }\n</script>\n\n<button onclick={() => api?.exportData({ format: 'xlsx', filename: 'orders' })}>\n Export Excel\n</button>\n<button onclick={() => api?.exportData({ format: 'pdf', filename: 'orders', pageOrientation: 'landscape' })}>\n Export PDF\n</button>\n<button onclick={() => api?.print({ title: 'Orders' })}>\n Print\n</button>\n\n<SvGrid data={rows} columns={columns} features={features} onApiReady={onReady} />\n```\n\n## Install\n\n```bash\npnpm add @svgrid/enterprise\n# Optional - install only the peers you actually use:\npnpm add jszip # required for xlsx\npnpm add pdfmake # required for pdf\n```\n\nCSV, TSV, HTML, and Print have **no extra dependencies**. The peer\ndependencies are lazy-loaded only when you call the format that needs\nthem.\n\n## Licensing\n\n`@svgrid/enterprise` has a tiered license gate:\n\n| Key state | Behavior |\n| ------------------------------------------ | -------- |\n| No key set (`setLicenseKey()` not called) | Feature works. Grid shows an unlicensed watermark linking to jqwidgets.com; console.log emits a one-time nudge. |\n| Key doesn't start with `SVENTERPRISE-` | Throws - programmer error. |\n| Key is in the revoked list | Throws - contact support for a replacement. |\n| `SVENTERPRISE-DEV-...` or `SVENTERPRISE-EVAL-...` | Works. One-time console.info notice. No watermark. |\n| Any other `SVENTERPRISE-...` | Works silently. |\n\nBuy a production key at <https://svgrid.com/pricing/> ($599 / developer /\nyear). `SVENTERPRISE-DEV-...` keys cover local development. To evaluate, you\nneed no key at all: everything runs with a watermark.\n\n## Reference\n\n### `setLicenseKey(key: string): void`\n\nStores the key in module state. Call once at app startup (e.g. in\n`main.ts`). Subsequent calls overwrite.\n\n### `clearLicenseKey(): void` · `hasValidLicense(): boolean` · `dismissUnlicensedNudge(): void`\n\nProgrammatic helpers. `hasValidLicense()` is useful when you want UI to\nbranch on license status. `dismissUnlicensedNudge()` removes the\nwatermark and stops the MutationObserver - call it after setting a\nvalid key if you loaded the module without one during testing.\n\n### `installEnterprise(api): EnterpriseGridApi`\n\nMutates the given `SvGridApi` to add `exportData` and `print`. Returns\nthe same object with the augmented type, so existing references keep\nworking.\n\n### `api.exportData(opts)` - `Promise<void>`\n\n| Option | Type | Default | Notes |\n| ----------------- | ----------------------------------------------------- | -------------------- | ----- |\n| `format` | `'xlsx' \\| '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. |\n| `pdf` | `PdfExportOptions` | - | PDF layout: page size, margins, title / subtitle / logo, theme colors, column widths, repeated header, page numbers. See \"PDF options\". |\n| `conditionalFormats` | `ConditionalFormat[]` | - | Same array as `<SvGrid conditionalFormats>`; carried into pdf / xlsx / html. See \"Conditional formatting in exports\". |\n| `freezeHeader` | `boolean` | `true` | Freeze the header row (xlsx / xls). |\n| `freezeColumns` | `number` | pinned cols | Freeze this many leading columns (xlsx / xls). Defaults to the grid's left-pinned columns. |\n| `autoFitColumns` | `boolean` | `true` | Size columns to content (xlsx / xls); a column's explicit `width` wins. |\n| `precisionSafe` | `boolean` | `false` | Export long integer IDs (> 15 digits) as text so Excel's float precision doesn't round them (xlsx / xls). |\n| `excelTable` | `boolean \\| { totalsRow?; style? }` | - | Wrap single-sheet xlsx in a native Excel Table (filter dropdowns, banded rows; `{ totalsRow: true }` adds SUM formulas). |\n| `download` | `boolean` | `true` | When false, return the {@link ExportResult} without downloading. |\n| `filename` | `string` | `\"grid\"` | Extension is appended if missing. |\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. |\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. |\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). |\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. |\n| `csv` | `{ delimiter?; eol?; bom? }` | `{ ',', '\\r\\n', BOM }` | CSV / TSV tuning. UTF-8 BOM is on by default so Excel opens it without mojibake. |\n| `onProgress` | `(p) => void` | - | Progress for large exports: `{ phase, ratio, row?, total? }`, `phase` = `'project' \\| 'serialize' \\| 'write'`. |\n| `signal` | `AbortSignal` | - | Cancel an in-flight export (checked between chunks). Rejects with `AbortError`. |\n| `pageOrientation` | `'portrait' \\| 'landscape'` | `\"portrait\"` | PDF only. |\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`. |\n\n### Faithful values (what you see is what you export)\n\nBy default the export renders each cell the way the grid does on screen: a\ncolumn with `format: { type: 'currency', currency: 'USD' }` exports `$19.95`,\na date column exports its formatted date, a percent column exports `42%`. This\nholds for **every** format (csv / tsv / html / pdf / xlsx). Pass\n`rawValues: true` to write the underlying values instead (e.g. feeding a data\npipeline that wants the numeric `19.95`).\n\nFor columns whose on-screen value comes from a **custom cell renderer**, a\n`fieldFn`, or a lookup, give the column an `exportValue` hook - it takes\nprecedence over `format` and the raw field:\n\n```ts\nawait api.exportData({\n format: 'xlsx',\n columns: [\n { field: 'company', header: 'Company' },\n // a column rendered with a custom snippet on screen:\n { field: 'status', header: 'Status', exportValue: (row) => STATUS_LABELS[row.status] },\n ],\n})\n```\n\n### Export only what's selected\n\n```ts\n// current view (default)\nawait api.exportData({ format: 'xlsx' })\n// only the checked rows\nawait api.exportData({ format: 'xlsx', rows: 'selected' })\n// the entire underlying dataset, ignoring filters/paging\nawait api.exportData({ format: 'csv', rows: 'all' })\n```\n\n### Large exports: progress + cancel\n\nCSV / TSV / HTML are serialized natively (no peer dependency) and stream in\nchunks, yielding to the event loop so a big export doesn't freeze the tab:\n\n```ts\nconst controller = new AbortController()\nawait api.exportData({\n format: 'csv',\n rows: 'all',\n signal: controller.signal,\n onProgress: ({ ratio }) => updateProgressBar(ratio),\n})\n// controller.abort() rejects the export with an AbortError\n```\n\n### Excel-native numbers (xlsx)\n\nA single-sheet `xlsx` export writes numeric, currency, percent, and date\ncolumns as **real typed cells** with an Excel number format - so Excel can sum\nand sort them, and they still display formatted (currency symbol, thousands,\n`%`, date pattern) - via SvGrid's own OOXML writer. Set `rawValues: true` for\nunformatted raw values. The grouped-outline, multi-sheet, image, and merged-cell\npaths use the bundled writer (formatted-string cells). The legacy `xls` format\nis always typed.\n\n### Excel fidelity: freeze, auto-fit, hyperlinks\n\n`xlsx` and `xls` exports polish the spreadsheet automatically:\n\n- **Freeze header** - the header row stays visible when scrolling. On by\n default; `freezeHeader: false` to disable, `freezeColumns: n` to also freeze\n leading columns.\n- **Auto-fit column widths** - columns are sized to their content. On by\n default (`autoFitColumns: false` to disable); a column's explicit `width`\n (pixels) always wins.\n- **Hyperlinks** - give a column a `link(row)` hook to render clickable links.\n Works in `xlsx`, `xls`, `html`, `pdf`, and `print`.\n\nSingle-sheet `xlsx` is written by SvGrid's own OOXML writer, so numbers/dates\nstay typed with number formats, conditional-format colors are **real per-cell\nfills**, and freeze/widths/hyperlinks all apply. (Grouped/outline, multi-sheet,\nembedded-image, and merged-cell exports use the bundled writer.)\n\n```ts\nawait api.exportData({\n format: 'xlsx',\n freezeHeader: true, // default\n columns: [\n { field: 'name', header: 'Company', width: 220,\n link: (row) => `https://crm.example.com/accounts/${row.id}` },\n { field: 'revenue', header: 'Revenue', format: { type: 'currency', currency: 'USD' } },\n ],\n})\n```\n\nConditional-format colors (data bars / color scales) already render as real\ncell fills in `xlsx`, which is higher fidelity than the writer's built-in\nrule-only conditional formatting - so we keep the per-cell approach.\n\n### Get the file instead of downloading (`download: false`)\n\n`exportData` returns an **`ExportResult`** for the dependency-free paths\n(csv/tsv/html/json/xml/md, xls, pdf, single-sheet xlsx) so you can preview,\nupload, email, or attach the file. With `download: false` it builds and returns\nthe file **without** triggering a browser download:\n\n```ts\nconst res = await api.exportData({ format: 'xlsx', download: false })\n// res: { blob, filename, mime, rowCount, byteSize }\nawait uploadToStorage(res.blob, res.filename)\n```\n\nThe grouped / multi-sheet / image / blanket-styled xlsx paths use the vendored\nwriter, which downloads directly and returns `undefined` (and throws on\n`download: false`).\n\n### Excel Table (filter dropdowns + totals row)\n\nWrap a single-sheet xlsx in a native **Excel Table** - the user gets column\nfilter dropdowns, banded rows, structured references, and (optionally) a totals\nrow with live `SUM()` formulas:\n\n```ts\nawait api.exportData({ format: 'xlsx', excelTable: { totalsRow: true } })\n// or just `excelTable: true` for the table without a totals row\n```\n\nColumn widths auto-fit and leading **pinned columns are frozen automatically**\n(override with `freezeColumns`). For long IDs, `precisionSafe: true` writes them\nas text so Excel's 15-digit float limit doesn't silently round `1002000300040005`.\n\n### JSON / XML / Markdown\n\n```ts\nawait api.exportData({ format: 'json' }) // array of { field: value } - defaults to raw values\nawait api.exportData({ format: 'xml' }) // <rows><row><field>value</field>…\nawait api.exportData({ format: 'md' }) // GitHub-flavored Markdown table (great for docs / LLMs)\n```\n\nJSON defaults to **raw** values (real numbers/dates) since it's a data format;\npass `rawValues: false` for formatted strings. XML and Markdown use the\nformatted display values.\n\n### Copy to clipboard\n\n`api.copyExport(opts)` serializes to the clipboard instead of downloading a\nfile - handy for \"copy these rows into a spreadsheet or an email\":\n\n```ts\nawait api.copyExport({ format: 'tsv' }) // pastes into Excel / Sheets as columns (default)\nawait api.copyExport({ format: 'md', rows: 'selected' }) // Markdown table of the checked rows\nawait api.copyExport({ format: 'html' }) // rich text/html - keeps the table when pasted\n```\n\nSupported formats: `csv`, `tsv`, `html`, `json`, `xml`, `md`. `html` writes\nboth `text/html` (rich paste) and a `text/plain` fallback. Needs a secure\ncontext and a user gesture (call it from a click).\n\n### Conditional formatting in exports\n\nPass the same `conditionalFormats` array you give `<SvGrid>` to carry the grid's\ncolor scales, data bars, icon sets, and predicate rules into the **styled**\nformats - PDF, xlsx, and HTML (and `print()`):\n\n```ts\nconst conditionalFormats = [\n { type: 'colorScale', columns: ['score'], min: '#dcfce7', max: '#fee2e2' },\n { type: 'rule', columns: ['status'], when: ({ value }) => value === 'Overdue', background: '#fecaca' },\n]\n\nawait api.exportData({ format: 'pdf', conditionalFormats })\nawait api.exportData({ format: 'xlsx', conditionalFormats }) // real cell fills in Excel\nawait api.print({ conditionalFormats })\n```\n\nCell background, text color, and bold carry through as **real cell fills** in\n`xlsx` (single-sheet), `xls`, `html`, and `pdf`; icon sets prepend their glyph\nto the cell text.\n\nIn **xlsx**, **data bars** and **color scales** become **native Excel\nconditional formatting** (`<conditionalFormatting>` rules) - Excel draws and\nrecomputes them itself, so they're interactive, not a static snapshot.\nPredicate `rule` formats stay as computed per-cell fills. In the other styled\nformats (`xls` / `html` / `pdf`) a data bar degrades to a light fill of the bar\ncolor (cells can't hold a partial-width bar). The data formats (csv / tsv /\njson / xml / md) are unstyled, so conditional formatting is ignored there.\nGrouped PDF exports skip conditional formatting (the group/subtotal rows own the\nlayout).\n\n### PDF options\n\nThe PDF is built from a full pdfmake document you control via `pdf`:\n\n```ts\nawait api.exportData({\n format: 'pdf',\n filename: 'orders',\n pdf: {\n pageSize: 'A4', // 'A4' | 'A3' | 'A5' | 'LETTER' | 'LEGAL'\n pageOrientation: 'landscape',\n title: 'Q3 Orders',\n subtitle: 'Generated by Ops',\n logo: logoDataUrl, // top-left image (data URL)\n headerColor: '#334155', // header row fill\n zebra: true, // striped rows\n columnWidths: '*', // '*' fill, 'auto', or a per-column pt array\n // repeatHeader (default true), showPageNumbers (default true) also available\n },\n})\n```\n\nThe header row repeats on every page, each page gets a `Page X of Y` +\ndate footer, per-column alignment is honored, and wide grids auto-switch to\nlandscape (override with `pdf.pageOrientation`).\n\nThe PDF looks like the grid it came from. With `pdf.matchTheme` (default\ntrue) the exporter reads the mounted grid's computed colours - header fill\nand text, body text, the zebra stripe, the lines, a group row's fill - so a\ngrid on the Ember theme or a Theme Builder palette prints in those colours\nrather than the slate defaults. A dark theme lends only its header: the\npage is white, so its body text and stripes would not read. The order of\nprecedence is explicit `pdf` colours (`headerColor`, `headerTextColor`,\n`textColor`, `zebraColor`, `borderColor`, `groupColor`, ...), then\n`styles`, then the theme, then the defaults. `styles.headerRow` /\n`styles.rows` / `styles.rowAlternate` / `styles.cells` (A1 references, row\n1 being the header) apply to the PDF table the way they do to xlsx and\nhtml, `header` / `footer` lines print above and below the table, and\n`merges` span data cells.\n\nWhen the grid is grouped, the PDF carries the grouping: a bold **group header**\nper cluster (nested for multi-level grouping) and a **subtotal row** summing the\nnumber / currency columns - matching the xlsx outline export. It's on by\ndefault (`autoGroup`); pass `groupBy` to override or `autoGroup: false` to get a\nflat table.\n\n#### Charts and a KPI strip\n\nA report is rarely just the table. `pdf.charts` prints charts with it: hand\nover the rendered chart's element (the `SvChart` wrapper or its `<svg>`) and\nthe export rasterises it at 2x through the chart's own PNG path, so the page\ngets the picture on screen, theme and all; or pass an image data URL you\nalready have. Each takes a `title`, a `caption` and a `width` in pt\n(default: the content width). `pdf.kpis` prints a strip of headline numbers\nabove the table, each with a label, the value and an optional `delta` line\nin its own colour:\n\n```svelte\n<div bind:this={chartEl}><SvChart {spec} /></div>\n\n<button onclick={() => api.exportData({\n format: 'pdf',\n filename: 'q3-report',\n pdf: {\n title: 'Q3 Orders',\n kpis: [\n { label: 'Revenue', value: '$1.24M', delta: '+4.2% vs Q2', color: '#16a34a' },\n { label: 'Orders', value: '842' },\n { label: 'Avg order', value: '$1,473', delta: '-1.1%', color: '#ef4444' },\n ],\n charts: [{ element: chartEl, title: 'Revenue by region', caption: 'Displayed rows, grouped by region' }],\n chartsPosition: 'above', // or 'below' the table\n },\n})}>Export report</button>\n```\n\nA chart block never splits across a page break. `resolvePdfCharts` and\n`buildPdfDocDefinition` are exported for a document you assemble yourself.\n\n#### Merged cells\n\nPass `merges` to write real merged regions into the sheet. Row / column\nindices are zero-based over the exported **body** (the header row is not\ncounted), and the shape lines up with the grid's own `MergeSpec`:\n\n```ts\n// Merge the first two data rows of column 0, and span a 3-column banner\nawait api.exportData({\n format: 'xlsx',\n merges: [\n { row: 0, col: 0, rowSpan: 2 }, // vertical merge (e.g. a repeated group key)\n { row: 5, col: 0, colSpan: 3 }, // horizontal merge (a section banner)\n ],\n})\n```\n\nTo export the merges you already show in the grid, take your `MergeSpec[]`\n(`{ rowIndex, columnId, rowspan, colspan }`) and map `columnId` to its column\nindex: `{ row: m.rowIndex, col: colIndex(m.columnId), rowSpan: m.rowspan, colSpan: m.colspan }`.\n\nThrows on missing peer (`jszip` / `pdfmake`), revoked / malformed\nlicense, or empty result set. With no license set, it runs but the\ngrid is watermarked.\n\n### `api.print(opts?)` - `Promise<void>`\n\nOpens a new window with a paginated, printable rendering of the grid and\ntriggers the browser print dialog. This is the **zero-dependency \"Save as PDF\"**\nroute: it uses the browser's own engine, so it has excellent font + CSS\nfidelity (including CJK / RTL) and needs no `pdfmake`. The header row repeats on\nevery page, values print **formatted** (matching the grid), and columns keep\ntheir alignment.\n\n| Option | Type | Default |\n| ------------- | -------------------------------------- | ----------- |\n| `title` | `string` | `\"Grid\"` |\n| `subtitle` | `string` | - |\n| `logo` | `string` (data URL) | - |\n| `columns` | `{ field; header?; format?; align? }[]`| grid's own columns |\n| `rows` | `ReadonlyArray<TData> \\| 'displayed' \\| 'selected' \\| 'all'` | `'displayed'` |\n| `rawValues` | `boolean` | `false` |\n| `orientation` | `'portrait' \\| 'landscape'` | `\"portrait\"` |\n| `pageSize` | `string` (e.g. `'A4'`, `'Letter'`) | browser default |\n| `margin` | `string` (e.g. `'14mm'`) | `'14mm'` |\n| `zebra` | `boolean` | `true` |\n| `headerColor` | `string` | `'#f1f5f9'` |\n\nBrowsers may block the popup unless `print()` is called from a user\ngesture (a click handler is fine - automatic on-load print is not). Page\nnumbers come from the browser's own print header/footer.\n\n**`exportData({ format: 'pdf' })` vs `print()`** - use PDF export for a\nprogrammatic, silent file download with full layout control (see \"PDF\noptions\"); use `print()` when you want best-fidelity output and the user to\npick print or \"Save as PDF\" from the dialog.\n\n## Drop-in Export menu (`SvExportMenu`)\n\nRather than wiring your own toolbar, drop in the bundled `SvExportMenu`\ncomponent. It renders an \"Export\" button with a format picker, a row-scope\ntoggle (current view / selected / all), and a built-in progress bar + Cancel\nfor large exports:\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, type SvGridApi } from '@svgrid/grid'\n import { installEnterprise, SvExportMenu, type EnterpriseGridApi } from '@svgrid/enterprise'\n\n let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\n</script>\n\n<div class=\"toolbar\">\n <SvExportMenu {api} filename=\"orders\" />\n</div>\n\n<SvGrid data={rows} columns={columns} features={features}\n onApiReady={(a) => (api = installEnterprise(a))} />\n```\n\n| Prop | Type | Default | Notes |\n| ------------ | ------------------------------------------------ | --------------------------- | ----- |\n| `api` | `EnterpriseGridApi \\| null` | required | Button is disabled until the api is ready. |\n| `filename` | `string` | `'grid'` | Base name, no extension. |\n| `formats` | `ExportFormat[]` | xlsx/xls/pdf/csv/tsv/html | Which formats to offer, in order. |\n| `allowScope` | `boolean` | `true` | Show the current-view / selected / all toggle. |\n| `allowColumns` | `boolean` | `true` | Show per-column include checkboxes (choose which columns to export). |\n| `allowCopy` | `boolean` | `true` | Show \"Copy for Excel\" (tsv) + \"Copy Markdown\" clipboard actions. |\n| `allowPrint` | `boolean` | `false` | Show a \"Print…\" action (`api.print`). |\n| `scope` | `'displayed' \\| 'selected' \\| 'all'` | `'displayed'` | Initial row scope. |\n| `label` | `string` | `'Export'` | Button text. |\n| `conditionalFormats` | `ConditionalFormat[]` | - | Carried into pdf / xlsx / html exports. |\n\nThe menu is sectioned into **Rows** (scope), **Columns** (a picker), and\n**Download** (each format shown with an icon + one-line description), plus\n**copy-to-clipboard** and optional **print** actions. It's **keyboard\naccessible** (arrow keys move between items, Tab is trapped inside, Esc closes\nand returns focus to the button, with `role=\"menu\"` / `menuitem`), handles\nerrors inline (missing peer dependency, empty grid), and wires `onProgress` +\nan `AbortController` for you.\n\nFor large exports, the single-sheet xlsx writer builds in **chunks that yield to\nthe event loop** (and zips asynchronously), so the tab stays responsive and\n`onProgress` ticks; pass a `signal` to cancel.\n\n## Gotchas\n\n- **Empty grids** - both `exportData` and `print` throw if there are no\n displayed rows. Catch the error and show a notice in the UI.\n- **Column ordering** - if you don't pass `columns`, the export uses\n `Object.keys(rows[0])` order. Pass `columns` explicitly when the row\n shape doesn't match the column order you want.\n- **Cell formatters** - column-level format hints (date, number, currency,\n percent) are applied to the exported value by default, so the file matches\n the grid. Custom snippet renderers are **not** auto-serialized - give those\n columns an `exportValue` hook (see \"Faithful values\" above), or set\n `rawValues: true` if you want the raw underlying values.\n- **Print popup blocked** - `print()` resolves but the browser silently\n blocks the new window. Always trigger from a user click, and surface\n the thrown error.\n- **Bundle size** - the vendored exporter is ~50 KB minified. It is\n loaded lazily on first call so it does not bloat the initial bundle\n for users who never export.\n\n## Frequently asked questions\n\n### How do I export a Svelte data grid to Excel?\n\nInstall `@svgrid/enterprise`, call `installEnterprise(api)`, then call the export helper with\n`format: 'xlsx'`. The exporter writes a real OOXML workbook in the browser - no\nserver round-trip. CSV, TSV, PDF, and HTML use the same call with a different\nformat.\n\n### What's the difference between `xls` and `xlsx`?\n\n`xlsx` is the modern OOXML workbook (Excel 2007+); it needs the `jszip` peer\ndependency and supports the richest output (styles, images, multi-sheet,\nformulas). `xls` is the **legacy Excel 2003 XML Spreadsheet** format: pick it\nfor maximum compatibility with old Excel installs or systems that only accept\n`.xls`. It has **no peer dependency**, streams in the browser, and keeps\nnumbers and dates typed (so sums and sorting still work in Excel) with number\nformats applied. It's single-sheet - use `xlsx` for multi-tab workbooks.\n\n```ts\nawait api.exportData({ format: 'xls', filename: 'orders' })\n```\n\n### Is export part of the free Community package?\n\nNo. Export and printing ship in the paid `@svgrid/enterprise` add-on. The free\n`@svgrid/grid` package covers the full grid (sorting, filtering, grouping,\nediting, virtualization) but not export/print/pivot/import.\n\n### Does exporting bloat my bundle?\n\nNo. The ~50 KB exporter is lazy-loaded on the first export call, so users who\nnever export never download it.\n\n## More examples\n\n### Password-protected export\n\nPBKDF2 (100k iters) + AES-GCM 256 client-side. Strength meter, encrypt + download, in-page decrypt tool to verify the round-trip. Enterprise pack maps to ECMA-376 Agile encryption.\n\n<div data-docs-demo=\"93-password-protected-export\" data-height=\"460\"></div>\n\n### Formulas preserved in xlsx\n\nBuilds a real OOXML workbook in the browser via JSZip; computed columns export as <f>...</f> formula cells. Open in Excel and the math recomputes when you edit a number.\n\n<div data-docs-demo=\"101-formulas-in-xlsx\" data-height=\"460\"></div>\n\n### Export grouped grid to Excel\n\nA flat sales grid (Region → Country) exported via api.exportData({ format: \"xlsx\", groupBy }) which uses Smart's NATIVE Excel row outline grouping. Opens in Excel with +/- buttons in the row header gutter for every group level.\n\n<div data-docs-demo=\"126-export-grouped-grid\" data-height=\"460\"></div>\n\n### Export pivot grid to Excel\n\ncreatePivotModel leaves projected into an xlsx via api.exportData with groupBy: [\"region\"] - each region becomes an Excel outline group. Engine column ids (\"pv__Q1__m0\") translate to readable headers (\"Q1 · Revenue\").\n\n<div data-docs-demo=\"127-export-pivot-grid\" data-height=\"460\"></div>\n\n### Export - Formatting, scope + XLS\n\nFaithful export: currency / date / percent render as shown (toggle raw values), pick a row scope (view / selected / all), legacy .xls (Excel 2003 XML, no jszip), an exportValue hook, and the drop-in SvExportMenu.\n\n<div data-docs-demo=\"201-export-formatting-and-xls\" data-height=\"460\"></div>\n\n### Export - Grouped PDF + Print\n\nGroup the grid and export: the PDF + xlsx carry bold group headers and per-cluster subtotal rows (auto-carried from grouping state). Polished Print is the zero-dependency \"Save as PDF\" route with a repeated header and title.\n\n<div data-docs-demo=\"202-export-pdf-grouped-and-print\" data-height=\"460\"></div>\n\n## Try the export API\n\nEvery exporter is on the grid api and takes the same\n`GridExportOptions`. `rows: 'displayed'` is the default and is almost always\nwhat a user means by \"export this\" - it follows their filter and sort.\n\n```svelte\n<script lang=\"ts\">\n let api = $state<SvGridApi<{}, Person> | null>(null)\n</script>\n\n<div>\n <button type=\"button\" onclick={() => api?.exportCsv({ filename: 'people' })}>CSV</button>\n <button type=\"button\" onclick={() => api?.exportTsv({ filename: 'people' })}>TSV</button>\n <button type=\"button\" onclick={() => api?.exportJson({ filename: 'people' })}>JSON</button>\n <button type=\"button\" onclick={() => api?.print()}>Print</button>\n</div>\n\n<SvGrid data={people} {columns} sortable filterable onApiReady={(next) => (api = next)} />\n```\n\nSort or filter first, then export: the file follows the view.\n\n## Exporting a subset\n\n`columns` restricts the fields and fixes their order, and `rawValues` decides\nwhether the file carries what the screen shows or what the row holds. The\nsalary column is the one to watch - formatted it is `$142,000`, raw it is\n`142000`, and a spreadsheet can only do arithmetic on the second.\n\n```svelte\n<script lang=\"ts\">\n let api = $state<SvGridApi<{}, Person> | null>(null)\n</script>\n\n<div>\n <button type=\"button\" onclick={() => api?.exportCsv({ filename: 'formatted', columns: ['name', 'salary'] })}>\n Name + salary, as displayed\n </button>\n <button type=\"button\" onclick={() => api?.exportCsv({ filename: 'raw', columns: ['name', 'salary'], rawValues: true })}>\n Name + salary, raw values\n </button>\n</div>\n\n<SvGrid data={people} {columns} onApiReady={(next) => (api = next)} />\n```\n"
|
|
4462
4486
|
},
|
|
4463
4487
|
{
|
|
4464
4488
|
"slug": "help/export/import-spreadsheet",
|
|
@@ -4563,14 +4587,14 @@ export const docs = [
|
|
|
4563
4587
|
"path": "docs/help/glossary.md",
|
|
4564
4588
|
"title": "Glossary",
|
|
4565
4589
|
"section": "Help",
|
|
4566
|
-
"markdown": "# Glossary\n\nTerminology used across the docs and the source. Sorted A-Z. If a term\nis unclear in a topic page and isn't on this list, please file an\nissue.\n\n## A\n\n**Accessor.** How a column gets its value out of a row. There are two\nforms: `field`, a key on the row (`row[field]`, no dot paths), and\n`fieldFn`, a function that computes the value. `getValue()` applies\nwhichever the column declares. Reach for `fieldFn` when the value is\nnested or derived - it is what pivot tables and computed columns use,\nand a `fieldFn` column needs an explicit `id`, since there is no\n`field` to name it after.\n\n**Active cell.** The cell with focus. Tracked through every keyboard\nmove + click; exposed via `onActiveCellChange`. At most one cell is\nactive per grid. Has `tabindex=\"0\"`; every other cell has\n`tabindex=\"-1\"` (roving-tabindex pattern).\n\n**Aggregator.** A function that reduces a group's values to a single\ncell value (sum, avg, count, min, max, custom). Used by\n`columnGroupingFeature` and the pivot engine.\n\n**API (`SvGridApi`).** The imperative interface exposed via\n`<SvGrid onApiReady>`. Methods like `setSort`, `setFilter`, `addRow`,\n`getDisplayedRows`. See [API reference](https://svgrid.com/api/).\n\n## C\n\n**Cell context.** The `ctx` object passed to `cell`, `editable`, and\n`formatter` callbacks. Contains `cell`, `row`, `column`, `table`,\n`getValue`. Used inside custom cell snippets to access surrounding\nstate.\n\n**Column definition (`ColumnDef`).** The plain object that describes\none column: how to read its value (`field` / `fieldFn`), how to\nrender it (`cell`, `header`), and which features apply (`editable`,\n`format`, `editorType`). See [Column definitions](./columns/column-definitions.md).\n\n**Column group.** A `ColumnDef` whose `columns` array contains child\ncolumn defs. The grid emits one header row per nesting depth with\nproper `colSpan`. See [Column groups](./columns/column-groups.md).\n\n**Controlled vs uncontrolled state.** *Controlled*: the consumer owns\nthe state (a `$state` in your component) and listens to change events.\n*Uncontrolled*: the engine owns the state internally. Most grid state\nis uncontrollable-by-default; opt in via the `onXxxChange` props.\n\n## D\n\n**Density.** The vertical compaction of rows. Controlled by the\n`rowHeight` prop (not a CSS token - the virtualizer needs the height as\na number). Default is 30 px; 28 px is \"compact\", 48 px is \"loose\".\n\n**Display rows.** The rows the grid is currently showing AFTER the\npipeline runs (filter -> sort -> group -> page). Accessible via\n`api.getDisplayedRows()`. NOT the same as the raw `data` prop.\n\n## E\n\n**Editor type.** A string on the `ColumnDef` that picks which built-in\neditor the grid uses when a user edits the cell. Values:\n`'text'` / `'number'` / `'date'` / `'datetime'` / `'checkbox'` /\n`'list'` / `'chips'`. A column without `editorType` is read-only even\nwhen `enableInlineEditing` is true.\n\n**Engine.** Layer 2 in the [architecture](./architecture.md). The\npure-function row-and-column model pipeline. Lives in\n`packages/grid/src/core.ts` + the `row-models/` folder.\n\n## F\n\n**Feature.** A bundle of row-model + state + behaviour you register\nvia `tableFeatures({...})`. Examples: `rowSortingFeature`,\n`columnFilteringFeature`. Features compose - registering five of them\nis normal.\n\n**Field.** The row property a column reads + writes by default. A\nshortcut for `fieldFn: (row) => row[field]`. When you can use\n`field`, prefer it - the engine has a fast path for property-keyed\ncolumns.\n\n**Filter mode.** A single prop on `<SvGrid>` that picks which filter\nUI the grid renders: `'menu'` (icon in each header), `'row'` (input\nunder each header), `'global'` (one search box), or `'none'`.\n\n**Format / formatter.** `format` is a declarative config (`{ type:\n'currency', currency: 'USD' }`) that the grid hands to `Intl`.\n`formatter` is a free-form callback that returns a string. Use\n`format` for standard types; `formatter` for custom output.\n\n## G\n\n**Group by.** A list of column ids whose unique values become rollup\ngroup rows. Set via `api.setGroupBy([...])` or the column menu's \"Group\nby this column\" entry. Drives `columnGroupingFeature`.\n\n## H\n\n**Header context.** The `ctx` object passed to `header` and `footer`\ntemplates. Contains `header`, `column`, `table`. Used inside custom\nheader snippets.\n\n**Headless.** Layer 2 without the renderer. `createSvGrid(...)`\nreturns a headless instance that emits the same row model + state but\ndoesn't paint anything. See [Why headless?](../why-headless.md).\n\n## I\n\n**Imperative API.** See *API (`SvGridApi`)*.\n\n**Inline editing.** Editing that happens inside the cell itself, vs\nin a side form. Toggled via `enableInlineEditing` on `<SvGrid>` +\n`editorType` on each column.\n\n## L\n\n**Layer 1, 2, 3.** See [Architecture overview](./architecture.md).\nLayer 1 is your data; Layer 2 is the engine; Layer 3 is the\n`<SvGrid>` renderer.\n\n**License key.** A string starting with `SVENTERPRISE-` set via\n`setLicenseKey(...)`. Removes the unlicensed watermark + console\nnudge. See [API stability](./api-stability.md) for license-related\nerrors.\n\n## P\n\n**Pinned column.** A column sticky-positioned to the left or right\nedge while the rest scroll. Set via the column menu, the\n`initialColumnPinning` prop, or\n`api.setColumnPinning({ left: [...], right: [...] })`.\n\n**Pipeline.** The chain of row-model transformations: core ->\nfiltered -> sorted -> grouped -> expanded -> paginated. Runs once per\nstate change, NOT per scroll frame.\n\n**Pivot.** A reshape of facts into a grid of intersections - row\ndimensions cross column dimensions, with measures (aggregated values)\nat each intersection. SvGrid doesn't have a `pivot` prop; the pivot\nengine in [pivot.md](./pivot.md) builds the pivoted data + nested\nColumnDef tree the renderer needs.\n\n**Placeholder row.** A frozen \"stand-in\" row used by sparse infinite\nscroll. Renders skeleton cells until the real row loads. See\n[Server-side data](./server-side-data.md).\n\n**Enterprise.** The paid `@svgrid/enterprise` companion package adding export,\nimport, print, pivot helpers, and the AI assistant. Installed\nseparately; activated by `installEnterprise(api)`.\n\n## R\n\n**Render template.** A `cell` / `header` / `footer` value that\nreturns a Svelte snippet via `renderSnippet(SnippetRef, props)`. The\ngrid mounts the snippet inside the cell.\n\n**Roving tabindex.** The pattern of giving only the active cell\n`tabindex=\"0\"` while every other cell has `tabindex=\"-1\"`. Puts the\ngrid in the tab order exactly once. See\n[Accessibility](./accessibility.md).\n\n**Row data.** The `data` prop. Anything assignable to `RowData[]`\n(which is `Record<string, unknown>[]`). Plain objects; the grid never\nintrospects beyond `field` reads.\n\n**Row model.** A pipeline stage that takes rows in and emits rows\nout. Examples: `createSortedRowModel`, `createFilteredRowModel`. Pipe\nthem in via `tableFeatures(...)`. See [Architecture](./architecture.md).\n\n## S\n\n**Selection range.** A rectangular cell selection (anchor + focus\ncells), enabled by `enableCellSelection={true}`. Copy + paste (TSV)\nand the fill handle both operate on this range.\n\n**Snippet.** A Svelte 5 `{#snippet Foo(props)}` block. Used by the\ngrid as the render template format for cells, headers, and editors.\n`renderSnippet(Foo, props)` is the wrapper the column passes to\n`cell`.\n\n**Evaluation period.** The Enterprise package without a license key:\nevery feature still runs, and loading the module displays a small\nwatermark and a console message about the Enterprise package.
|
|
4590
|
+
"markdown": "# Glossary\n\nTerminology used across the docs and the source. Sorted A-Z. If a term\nis unclear in a topic page and isn't on this list, please file an\nissue.\n\n## A\n\n**Accessor.** How a column gets its value out of a row. There are two\nforms: `field`, a key on the row (`row[field]`, no dot paths), and\n`fieldFn`, a function that computes the value. `getValue()` applies\nwhichever the column declares. Reach for `fieldFn` when the value is\nnested or derived - it is what pivot tables and computed columns use,\nand a `fieldFn` column needs an explicit `id`, since there is no\n`field` to name it after.\n\n**Active cell.** The cell with focus. Tracked through every keyboard\nmove + click; exposed via `onActiveCellChange`. At most one cell is\nactive per grid. Has `tabindex=\"0\"`; every other cell has\n`tabindex=\"-1\"` (roving-tabindex pattern).\n\n**Aggregator.** A function that reduces a group's values to a single\ncell value (sum, avg, count, min, max, custom). Used by\n`columnGroupingFeature` and the pivot engine.\n\n**API (`SvGridApi`).** The imperative interface exposed via\n`<SvGrid onApiReady>`. Methods like `setSort`, `setFilter`, `addRow`,\n`getDisplayedRows`. See [API reference](https://svgrid.com/api/).\n\n## C\n\n**Cell context.** The `ctx` object passed to `cell`, `editable`, and\n`formatter` callbacks. Contains `cell`, `row`, `column`, `table`,\n`getValue`. Used inside custom cell snippets to access surrounding\nstate.\n\n**Column definition (`ColumnDef`).** The plain object that describes\none column: how to read its value (`field` / `fieldFn`), how to\nrender it (`cell`, `header`), and which features apply (`editable`,\n`format`, `editorType`). See [Column definitions](./columns/column-definitions.md).\n\n**Column group.** A `ColumnDef` whose `columns` array contains child\ncolumn defs. The grid emits one header row per nesting depth with\nproper `colSpan`. See [Column groups](./columns/column-groups.md).\n\n**Controlled vs uncontrolled state.** *Controlled*: the consumer owns\nthe state (a `$state` in your component) and listens to change events.\n*Uncontrolled*: the engine owns the state internally. Most grid state\nis uncontrollable-by-default; opt in via the `onXxxChange` props.\n\n## D\n\n**Density.** The vertical compaction of rows. Controlled by the\n`rowHeight` prop (not a CSS token - the virtualizer needs the height as\na number). Default is 30 px; 28 px is \"compact\", 48 px is \"loose\".\n\n**Display rows.** The rows the grid is currently showing AFTER the\npipeline runs (filter -> sort -> group -> page). Accessible via\n`api.getDisplayedRows()`. NOT the same as the raw `data` prop.\n\n## E\n\n**Editor type.** A string on the `ColumnDef` that picks which built-in\neditor the grid uses when a user edits the cell. Values:\n`'text'` / `'number'` / `'date'` / `'datetime'` / `'checkbox'` /\n`'list'` / `'chips'`. A column without `editorType` is read-only even\nwhen `enableInlineEditing` is true.\n\n**Engine.** Layer 2 in the [architecture](./architecture.md). The\npure-function row-and-column model pipeline. Lives in\n`packages/grid/src/core.ts` + the `row-models/` folder.\n\n## F\n\n**Feature.** A bundle of row-model + state + behaviour you register\nvia `tableFeatures({...})`. Examples: `rowSortingFeature`,\n`columnFilteringFeature`. Features compose - registering five of them\nis normal.\n\n**Field.** The row property a column reads + writes by default. A\nshortcut for `fieldFn: (row) => row[field]`. When you can use\n`field`, prefer it - the engine has a fast path for property-keyed\ncolumns.\n\n**Filter mode.** A single prop on `<SvGrid>` that picks which filter\nUI the grid renders: `'menu'` (icon in each header), `'row'` (input\nunder each header), `'global'` (one search box), or `'none'`.\n\n**Format / formatter.** `format` is a declarative config (`{ type:\n'currency', currency: 'USD' }`) that the grid hands to `Intl`.\n`formatter` is a free-form callback that returns a string. Use\n`format` for standard types; `formatter` for custom output.\n\n## G\n\n**Group by.** A list of column ids whose unique values become rollup\ngroup rows. Set via `api.setGroupBy([...])` or the column menu's \"Group\nby this column\" entry. Drives `columnGroupingFeature`.\n\n## H\n\n**Header context.** The `ctx` object passed to `header` and `footer`\ntemplates. Contains `header`, `column`, `table`. Used inside custom\nheader snippets.\n\n**Headless.** Layer 2 without the renderer. `createSvGrid(...)`\nreturns a headless instance that emits the same row model + state but\ndoesn't paint anything. See [Why headless?](../why-headless.md).\n\n## I\n\n**Imperative API.** See *API (`SvGridApi`)*.\n\n**Inline editing.** Editing that happens inside the cell itself, vs\nin a side form. Toggled via `enableInlineEditing` on `<SvGrid>` +\n`editorType` on each column.\n\n## L\n\n**Layer 1, 2, 3.** See [Architecture overview](./architecture.md).\nLayer 1 is your data; Layer 2 is the engine; Layer 3 is the\n`<SvGrid>` renderer.\n\n**License key.** A string starting with `SVENTERPRISE-` set via\n`setLicenseKey(...)`. Removes the unlicensed watermark + console\nnudge. See [API stability](./api-stability.md) for license-related\nerrors.\n\n## P\n\n**Pinned column.** A column sticky-positioned to the left or right\nedge while the rest scroll. Set via the column menu, the\n`initialColumnPinning` prop, or\n`api.setColumnPinning({ left: [...], right: [...] })`.\n\n**Pipeline.** The chain of row-model transformations: core ->\nfiltered -> sorted -> grouped -> expanded -> paginated. Runs once per\nstate change, NOT per scroll frame.\n\n**Pivot.** A reshape of facts into a grid of intersections - row\ndimensions cross column dimensions, with measures (aggregated values)\nat each intersection. SvGrid doesn't have a `pivot` prop; the pivot\nengine in [pivot.md](./pivot.md) builds the pivoted data + nested\nColumnDef tree the renderer needs.\n\n**Placeholder row.** A frozen \"stand-in\" row used by sparse infinite\nscroll. Renders skeleton cells until the real row loads. See\n[Server-side data](./server-side-data.md).\n\n**Enterprise.** The paid `@svgrid/enterprise` companion package adding export,\nimport, print, pivot helpers, and the AI assistant. Installed\nseparately; activated by `installEnterprise(api)`.\n\n## R\n\n**Render template.** A `cell` / `header` / `footer` value that\nreturns a Svelte snippet via `renderSnippet(SnippetRef, props)`. The\ngrid mounts the snippet inside the cell.\n\n**Roving tabindex.** The pattern of giving only the active cell\n`tabindex=\"0\"` while every other cell has `tabindex=\"-1\"`. Puts the\ngrid in the tab order exactly once. See\n[Accessibility](./accessibility.md).\n\n**Row data.** The `data` prop. Anything assignable to `RowData[]`\n(which is `Record<string, unknown>[]`). Plain objects; the grid never\nintrospects beyond `field` reads.\n\n**Row model.** A pipeline stage that takes rows in and emits rows\nout. Examples: `createSortedRowModel`, `createFilteredRowModel`. Pipe\nthem in via `tableFeatures(...)`. See [Architecture](./architecture.md).\n\n## S\n\n**Selection range.** A rectangular cell selection (anchor + focus\ncells), enabled by `enableCellSelection={true}`. Copy + paste (TSV)\nand the fill handle both operate on this range.\n\n**Snippet.** A Svelte 5 `{#snippet Foo(props)}` block. Used by the\ngrid as the render template format for cells, headers, and editors.\n`renderSnippet(Foo, props)` is the wrapper the column passes to\n`cell`.\n\n**Evaluation period.** The Enterprise package without a license key:\nevery feature still runs, and loading the module displays a small\nwatermark and a console message about the Enterprise package. No key is\nneeded to evaluate; a license key removes them\n(`setLicenseKey('SVENTERPRISE-...')`).\n\n## T\n\n**Table features.** The bag returned by `tableFeatures({...})` -\na typed object identifying which row models + state slices the grid\nshould enable. Pass to `<SvGrid features={...}>` and use as the first\ngeneric of `ColumnDef<...>`.\n\n**Tree row.** A row with a `depth` field + `childIds` (or\nequivalent). Renders with indented chevron + connector lines. The grid\ndoesn't have a `treeData` prop; you derive `visibleRows` from\n`allRows` + `expanded`. See [Tree rows](./rows/tree-rows.md).\n\n## V\n\n**Virtualization.** Rendering only the rows + columns currently in\nview + a small overscan buffer. Enabled by default for any grid with\nmore than a few hundred rows. Bypassed in jsdom (zero layout metrics)\n- test against Playwright for virtualization behaviour.\n\n**Visible rows.** Same as *Display rows*.\n\n## W\n\n**Watermark.** The \"Unlicensed @svgrid/enterprise\" badge that appears\nbottom-right when a Enterprise feature runs without a valid license. Removed\nby `setLicenseKey('SVENTERPRISE-...')` or `dismissUnlicensedNudge()` (the\nnudge only).\n\n## See also\n\n- [Architecture overview](./architecture.md) - where each piece\n above sits.\n- [API reference](https://svgrid.com/api/) - every export with its tier\n badge.\n"
|
|
4567
4591
|
},
|
|
4568
4592
|
{
|
|
4569
4593
|
"slug": "help/grouping-aggregation",
|
|
4570
4594
|
"path": "docs/help/grouping-aggregation.md",
|
|
4571
4595
|
"title": "Grouping & aggregation",
|
|
4572
4596
|
"section": "Help",
|
|
4573
|
-
"markdown": "# Grouping & aggregation\n\nRoll rows up by one or more columns and compute aggregates (sum, avg,\ncount, min, max, custom) at each group level. Powered by\n`columnGroupingFeature` plus per-column `aggregate` config.\n\n\n\nTry it: drag a column into the group-by lane, then change aggregators\nper column:\n\n<div data-docs-demo=\"07-grouping-aggregation\" data-height=\"500\"></div>\n\n## Minimal example\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n SvGrid, tableFeatures, rowSortingFeature, columnGroupingFeature,\n type ColumnDef,\n } from '@svgrid/grid'\n\n type Employee = {\n id: number; name: string; department: string;\n salary: number; performance: number\n }\n\n const features = tableFeatures({ rowSortingFeature, columnGroupingFeature })\n\n const columns: ColumnDef<typeof features, Employee>[] = [\n { field: 'department', header: 'Department' },\n { field: 'name', header: 'Name' },\n { field: 'salary', header: 'Salary',\n aggregate: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n { field: 'performance', header: 'Performance',\n aggregate: 'avg' },\n ]\n\n const rows: Employee[] = [\n { id: 1, name: 'Ada', department: 'Engineering', salary: 180_000, performance: 4.8 },\n { id: 2, name: 'Linus', department: 'Engineering', salary: 195_000, performance: 4.6 },\n { id: 3, name: 'Grace', department: 'Operations', salary: 165_000, performance: 4.9 },\n ]\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n groupBy={['department']}\n/>\n```\n\nThe grid emits one group row per unique department value, with the\ngroup cell showing the rolled-up salary sum + average performance.\nClick the chevron on a group row to expand its children.\n\n## Setting the group-by\n\nThree ways, ranked by ergonomic order:\n\n1. **The column menu.** When the user opens a header's menu, \"Group\n by this column\" toggles that column in/out of the group-by list.\n2. **The `groupBy` prop.** Seeds the group-by list and re-applies\n whenever the prop's own value changes, so it works both as initial\n state and as a controlled value. A group-by set from the menu or the\n API is not clobbered while the prop stays put.\n3. **The imperative API.** `api.setGroupBy(['department', 'role'])`\n for toolbars / saved views.\n\n`groupBy` is ignored when `treeData` is set - a row cannot be both a\nhierarchy node and bucketed under a group banner.\n\nThe group order is significant - `['region', 'country']` rolls up\ncountry inside region; reverse the array to flip the hierarchy.\n\n## Built-in aggregators\n\n| Aggregator | Returns | Behaviour on empty groups |\n| ---------- | ---------------------------------------------------- | ------------------------- |\n| `'sum'` | Sum of numeric cell values | `0` |\n| `'avg'` | Arithmetic mean (with safe divide-by-zero) | `null` |\n| `'count'` | Number of leaf rows | `0` |\n| `'min'` | Smallest value (numeric or `Intl.Collator`-comparable) | `null` |\n| `'max'` | Largest value | `null` |\n\n`'sum'` / `'avg'` / `'min'` / `'max'` cast values to `Number`. If the\ncolumn has non-numeric values mixed in, those rows are skipped.\n\n## Custom aggregator\n\nPass a function instead of a string for any group-aware computation:\n\n```ts\n{\n field: 'orders',\n header: 'Top customer',\n aggregate: (rows) => {\n const top = rows.reduce<Employee | null>(\n (acc, r) => !acc || r.orders > acc.orders ? r : acc,\n null,\n )\n return top?.name ?? '-'\n },\n}\n```\n\nThe callback gets every leaf row in the group (already filtered).\nReturn whatever the cell should display - string, number, or a\nformatted value.\n\n## Custom group cell rendering\n\nBy default the group cell shows `key (n)` - e.g. \"Engineering (12)\".\nOverride via the column's `cell` template:\n\n```svelte\n{#snippet GroupCell(props: { row: GroupRow<Employee> })}\n <span class=\"font-semibold\">\n {props.row.groupKey}\n <span class=\"text-sm opacity-60\">({props.row.subRows.length} reports)</span>\n </span>\n{/snippet}\n```\n\nThe `row.groupKey` is the unique group value (the department name in\nthe example). `row.subRows` is the children. `row.depth` is the\nnesting level (useful for indentation when you group by multiple\ncolumns).\n\n## Aggregating string columns\n\nStrings work with `'count'`, `'min'`, `'max'`, and any custom\naggregator. For sum / avg you'll get `NaN` because the cast to\n`Number` fails - the grid renders this as `-` by default.\n\nA useful custom aggregator for strings:\n\n```ts\n{\n field: 'tags',\n aggregate: (rows) => {\n const set = new Set<string>()\n for (const r of rows) for (const t of r.tags) set.add(t)\n return Array.from(set).join(', ')\n },\n}\n```\n\n## Performance\n\nAggregation runs once per group-by change, NOT per scroll frame. The\ncost is O(n) for `count` / `sum` / `avg`, O(n log n) for `min` / `max`\nbecause the engine sorts to find the extreme.\n\nFor a 100k-row dataset grouped by two columns with three aggregators,\nthe pipeline adds ~36 ms to the initial paint (see\n[Performance benchmarks](./benchmarks.md)). After that, scroll is\nunaffected - the renderer hands each visible group its precomputed\nvalue.\n\n## Group expansion state\n\n`expanded` is owned by the engine by default; you can hoist it for\nsaved-views purposes:\n\n```svelte\n<SvGrid\n ...\n expanded={controlledExpanded}\n onExpandedChange={(next) => (controlledExpanded = next)}\n/>\n```\n\n`onExpandedChange` fires for every path that changes expansion: a click\non a group banner, `api.setRowExpanded()`, and\n`api.expandAllGroups()` / `api.collapseAllGroups()`. It receives the\nfull next map, so writing it straight back into `expanded` (as above) is\nsafe and will not loop.\n\nThe shape is `Record<rowId, boolean>`. Group row ids are built from the\ngrouping path rather than the display label - grouping by `department`\ngives `group_department_Engineering`, and adding `role` beneath it gives\n`group_department_Engineering_role_Senior`. Tree rows key off the\nengine's row id instead, so set `getRowId` if you want those keys to be\nyour own ids. Capture the map from `onExpandedChange` rather than\nhand-building the keys.\n\n## Sticky group rows\n\n`stickyGroupRows` keeps the banner of the group a row belongs to (and the\nbanners of the groups above it) under the header while the group's rows\nscroll past, so a screen deep inside a large group still says where it\nis. Under virtualization the band shows a copy of each ancestor banner,\nclickable like the original; without virtualization the banners\nthemselves stick. Off by default; the same prop serves tree data and the\n[server-side row model](./server/server-grouping.md#sticky-group-rows).\n\n## Group sort vs leaf sort\n\nThe sort UI sorts within the active sort scope:\n\n- When grouping is OFF, sort applies to all rows.\n- When grouping is ON, sort applies WITHIN each group - groups stay\n in alphabetical (or group-aggregator) order; only the leaves inside\n each group reorder.\n\nTo sort the groups themselves by their rolled-up value, set the sort\non the aggregated column AFTER setting the group-by. The grid\nrecognises that the column is aggregated and sorts the group rows\ninstead of the leaves.\n\n## Filtering vs grouping\n\nFilters run BEFORE grouping (see [Architecture](./architecture.md) for\nthe pipeline order). The aggregator only sees rows that passed the\nfilter. This is what makes \"department salary sum, filtered to active\nemployees only\" work without any extra config.\n\n## Pivot vs group-by\n\nWhen the question is \"group by row dimensions, also group by column\ndimensions, also pick aggregators per measure\" - that's a pivot. The\n[pivot helpers](./pivot.md) build a different data structure\noptimised for that shape. Use group-by when you only roll up rows;\nuse pivot when you also roll up columns.\n\n## Display modes\n\n`groupDisplayMode` decides where group state is drawn:\n\n| Mode | Result |\n| --- | --- |\n| `groupRows` (default) | A full-width banner row per group. Unchanged behaviour. |\n| `singleColumn` | One synthetic **Group** column holding every level, indented by depth. |\n| `multipleColumns` | One synthetic column per grouped field. |\n\n```svelte\n<SvGrid {data} {columns} groupable groupDisplayMode=\"singleColumn\" />\n```\n\nBoth column modes hide the grouped **source** columns, because their values\nmove into the auto column - showing both would just duplicate them. They also\nrender the group row as an ordinary row, which is the real reason to use them:\nits aggregate cells then line up under the columns they belong to instead of\nsitting in a full-width strip.\n\nTune the combined column with `autoGroupColumnHeader` (default `\"Group\"`) and\n`autoGroupColumnWidth` (default `220`). In `multipleColumns` each column takes\nits name from the source column's header.\n\n## Group footers (subtotal rows)\n\n`groupFooters` closes each group with a subtotal row:\n\n```svelte\n<SvGrid {data} {columns} groupable groupFooters />\n```\n\nThe footer is a clone of the group banner, so it already carries that group's\naggregates and renders through the normal cell path - each total lands under\nits own column instead of in a full-width strip. It is not expandable and has\nno expander.\n\nOnly columns with an `aggregate` produce a value, the same ones that populate\nthe banner.\n\n## Grand total row\n\n`grandTotalRow` appends a single totals row for the whole filtered set:\n\n```svelte\n<SvGrid {data} {columns} grandTotalRow />\n```\n\nIt is independent of `groupFooters` - use it on a flat grid for a bottom totals\nline, or together for subtotals *and* a total:\n\n```svelte\n<SvGrid {data} {columns} groupable groupFooters grandTotalRow />\n```\n\nThree things to know:\n\n- It aggregates the **leaf** rows, so turning grouping on does not double-count\n (the group banners already carry subtotals).\n- It follows the **filtered** set, not the raw data - filter the grid and the\n total moves with it.\n- With `pageable`, it is appended only on the **last** page, so a total never\n appears mid-dataset. The value still covers every row, not just that page.\n\nColumns without an `aggregate` render blank, and if no column declares one the\nrow is skipped entirely.\n\n## Grouping with pagination\n\n`pageSize` budgets **data** rows. Group banners and footers do not count\nagainst it:\n\n```svelte\n<SvGrid {data} {columns} groupable groupFooters pageable pageSize={10} />\n```\n\nA page holds `pageSize` real rows and reprints the banners those rows sit\nunder, so a group split across a page boundary is labelled on both pages - the\nway a spreadsheet repeats group headers across a page break. Footers are\ninserted after paging, so switching them on never pushes data onto the next\npage.\n\nA collapsed group is the visible unit and takes one page slot itself; an\nexpanded one is a header and takes none.\n\n## Frequently asked questions\n\n### How do I group rows in SvGrid?\n\nRegister `columnGroupingFeature` and group by one or more columns. Each group\nrenders a collapsible header row, and you attach an `aggregate` per column to\ncompute sum, avg, count, min, max, or a custom reducer at every group level.\n\n### What aggregation functions does SvGrid support?\n\nBuilt-in `sum`, `avg`, `count`, `min`, and `max`, plus custom aggregators -\nany function that reduces a group's rows to a single value. Aggregates compute\nat each group level and at the grand-total footer.\n\n### Is grouping the same as a pivot table?\n\nNo. Grouping rolls rows up along the row axis. A pivot table also spreads a\nfield across the column axis with nested headers - that is the `@svgrid/enterprise`\npivot model. See [Pivot tables](./pivot.md) for the column-axis version.\n\n## More examples\n\n### Group display modes + footers\n\nSwitch between groupRows banners, a single combined Group column, and one column per grouped field. groupFooters closes each group with a subtotal row under the real columns. Paging counts DATA rows, so pageSize means what it says: a page reprints the banners its rows sit under, and footers never eat the budget.\n\n<div data-docs-demo=\"427-group-footers\" data-height=\"460\"></div>\n\n\n### Group panel (drag & drop)\n\nDevExpress / Kendo-style Group Panel: drag chips into the panel to group, drag inside to reorder grouping levels, × to ungroup. Drives api.setGroupBy() under the hood.\n\n<div data-docs-demo=\"89-group-panel\" data-height=\"460\"></div>\n\n## Try it\n\nGrouping is a prop, and the aggregate shown on each group header comes from the\ncolumn. Add `summary` and the same reducers total the whole filtered set in the\nfooter.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const agg: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80, aggregate: 'avg', summary: 'avg' },\n { field: 'salary', header: 'Salary', width: 130, aggregate: 'sum', summary: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={people} columns={agg} groupBy={['department']} groupable summary sortable />\n```\n\n## Two levels deep\n\n`groupBy` is an array, and the order is the nesting order. Every level gets its\nown aggregate row, computed over the leaf rows beneath it rather than over the\nlevel above.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const nested: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'age', header: 'Age', width: 80, aggregate: 'avg' },\n { field: 'salary', header: 'Salary', width: 130, aggregate: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={people} columns={nested} groupBy={['department', 'city']} groupable summary />\n```\n\n## See also\n\n- [Architecture overview](./architecture.md) - where grouping sits in\n the pipeline.\n- [Pivot tables](./pivot.md) - the column-axis version.\n- [Row pagination](./rows/row-pagination.md) - the paging stage runs\n AFTER grouping, so group rows count toward the page size.\n- [Demo #07 Grouping + aggregation](https://svgrid.com/demos/07-grouping-aggregation/)\n - the source for the example above.\n"
|
|
4597
|
+
"markdown": "# Grouping & aggregation\n\nRoll rows up by one or more columns and compute aggregates (sum, avg,\ncount, min, max, custom) at each group level. Powered by\n`columnGroupingFeature` plus per-column `aggregate` config.\n\n\n\nTry it: drag a column into the group-by lane, then change aggregators\nper column:\n\n<div data-docs-demo=\"07-grouping-aggregation\" data-height=\"500\"></div>\n\n## Minimal example\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n SvGrid, tableFeatures, rowSortingFeature, columnGroupingFeature,\n type ColumnDef,\n } from '@svgrid/grid'\n\n type Employee = {\n id: number; name: string; department: string;\n salary: number; performance: number\n }\n\n const features = tableFeatures({ rowSortingFeature, columnGroupingFeature })\n\n const columns: ColumnDef<typeof features, Employee>[] = [\n { field: 'department', header: 'Department' },\n { field: 'name', header: 'Name' },\n { field: 'salary', header: 'Salary',\n aggregate: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n { field: 'performance', header: 'Performance',\n aggregate: 'avg' },\n ]\n\n const rows: Employee[] = [\n { id: 1, name: 'Ada', department: 'Engineering', salary: 180_000, performance: 4.8 },\n { id: 2, name: 'Linus', department: 'Engineering', salary: 195_000, performance: 4.6 },\n { id: 3, name: 'Grace', department: 'Operations', salary: 165_000, performance: 4.9 },\n ]\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n groupBy={['department']}\n/>\n```\n\nThe grid emits one group row per unique department value, with the\ngroup cell showing the rolled-up salary sum + average performance.\nClick the chevron on a group row to expand its children.\n\n## Setting the group-by\n\nThree ways, ranked by ergonomic order:\n\n1. **The column menu.** When the user opens a header's menu, \"Group\n by this column\" toggles that column in/out of the group-by list.\n2. **The `groupBy` prop.** Seeds the group-by list and re-applies\n whenever the prop's own value changes, so it works both as initial\n state and as a controlled value. A group-by set from the menu or the\n API is not clobbered while the prop stays put.\n3. **The imperative API.** `api.setGroupBy(['department', 'role'])`\n for toolbars / saved views.\n\n`groupBy` is ignored when `treeData` is set - a row cannot be both a\nhierarchy node and bucketed under a group banner.\n\nThe group order is significant - `['region', 'country']` rolls up\ncountry inside region; reverse the array to flip the hierarchy.\n\n## Built-in aggregators\n\n| Aggregator | Returns | Behaviour on empty groups |\n| ---------- | ---------------------------------------------------- | ------------------------- |\n| `'sum'` | Sum of numeric cell values | `0` |\n| `'avg'` | Arithmetic mean (with safe divide-by-zero) | `null` |\n| `'count'` | Number of leaf rows | `0` |\n| `'min'` | Smallest value (numeric or `Intl.Collator`-comparable) | `null` |\n| `'max'` | Largest value | `null` |\n\n`'sum'` / `'avg'` / `'min'` / `'max'` cast values to `Number`. If the\ncolumn has non-numeric values mixed in, those rows are skipped.\n\n## Custom aggregator\n\nPass a function instead of a string for any group-aware computation:\n\n```ts\n{\n field: 'orders',\n header: 'Top customer',\n aggregate: (rows) => {\n const top = rows.reduce<Employee | null>(\n (acc, r) => !acc || r.orders > acc.orders ? r : acc,\n null,\n )\n return top?.name ?? '-'\n },\n}\n```\n\nThe callback gets every leaf row in the group (already filtered).\nReturn whatever the cell should display - string, number, or a\nformatted value.\n\n## Custom group cell rendering\n\nBy default the group cell shows `key (n)` - e.g. \"Engineering (12)\".\nOverride via the column's `cell` template:\n\n```svelte\n{#snippet GroupCell(props: { row: GroupRow<Employee> })}\n <span class=\"font-semibold\">\n {props.row.groupKey}\n <span class=\"text-sm opacity-60\">({props.row.subRows.length} reports)</span>\n </span>\n{/snippet}\n```\n\nThe `row.groupKey` is the unique group value (the department name in\nthe example). `row.subRows` is the children. `row.depth` is the\nnesting level (useful for indentation when you group by multiple\ncolumns).\n\n## Aggregating string columns\n\nStrings work with `'count'`, `'min'`, `'max'`, and any custom\naggregator. For sum / avg you'll get `NaN` because the cast to\n`Number` fails - the grid renders this as `-` by default.\n\nA useful custom aggregator for strings:\n\n```ts\n{\n field: 'tags',\n aggregate: (rows) => {\n const set = new Set<string>()\n for (const r of rows) for (const t of r.tags) set.add(t)\n return Array.from(set).join(', ')\n },\n}\n```\n\n## Performance\n\nAggregation runs once per group-by change, NOT per scroll frame. The\ncost is O(n) for `count` / `sum` / `avg`, O(n log n) for `min` / `max`\nbecause the engine sorts to find the extreme.\n\nFor a 100k-row dataset grouped by two columns with three aggregators,\nthe pipeline adds ~36 ms to the initial paint (see\n[Performance benchmarks](./benchmarks.md)). After that, scroll is\nunaffected - the renderer hands each visible group its precomputed\nvalue.\n\n## Group expansion state\n\n`expanded` is owned by the engine by default; you can hoist it for\nsaved-views purposes:\n\n```svelte\n<SvGrid\n ...\n expanded={controlledExpanded}\n onExpandedChange={(next) => (controlledExpanded = next)}\n/>\n```\n\n`onExpandedChange` fires for every path that changes expansion: a click\non a group banner, `api.setRowExpanded()`, and\n`api.expandAllGroups()` / `api.collapseAllGroups()`. It receives the\nfull next map, so writing it straight back into `expanded` (as above) is\nsafe and will not loop.\n\nThe shape is `Record<rowId, boolean>`. Group row ids are built from the\ngrouping path rather than the display label - grouping by `department`\ngives `group_department_Engineering`, and adding `role` beneath it gives\n`group_department_Engineering_role_Senior`. Tree rows key off the\nengine's row id instead, so set `getRowId` if you want those keys to be\nyour own ids. Capture the map from `onExpandedChange` rather than\nhand-building the keys.\n\n## Sticky group rows\n\n`stickyGroupRows` keeps the banner of the group a row belongs to (and the\nbanners of the groups above it) under the header while the group's rows\nscroll past, so a screen deep inside a large group still says where it\nis. Under virtualization the band shows a copy of each ancestor banner,\nclickable like the original; without virtualization the banners\nthemselves stick. Off by default; the same prop serves tree data and the\n[server-side row model](./server/server-grouping.md#sticky-group-rows).\n\n## Group sort vs leaf sort\n\nThe sort UI sorts within the active sort scope:\n\n- When grouping is OFF, sort applies to all rows.\n- When grouping is ON, sort applies WITHIN each group - groups stay\n in alphabetical (or group-aggregator) order; only the leaves inside\n each group reorder.\n\nTo sort the groups themselves by their rolled-up value, set the sort\non the aggregated column AFTER setting the group-by. The grid\nrecognises that the column is aggregated and sorts the group rows\ninstead of the leaves.\n\n## Filtering vs grouping\n\nFilters run BEFORE grouping (see [Architecture](./architecture.md) for\nthe pipeline order). The aggregator only sees rows that passed the\nfilter. This is what makes \"department salary sum, filtered to active\nemployees only\" work without any extra config.\n\n## Pivot vs group-by\n\nWhen the question is \"group by row dimensions, also group by column\ndimensions, also pick aggregators per measure\" - that's a pivot. The\n[pivot helpers](./pivot.md) build a different data structure\noptimised for that shape. Use group-by when you only roll up rows;\nuse pivot when you also roll up columns.\n\n## Display modes\n\n`groupDisplayMode` decides where group state is drawn:\n\n| Mode | Result |\n| --- | --- |\n| `groupRows` (default) | A full-width banner row per group. Unchanged behaviour. |\n| `singleColumn` | One synthetic **Group** column holding every level, indented by depth. |\n| `multipleColumns` | One synthetic column per grouped field. |\n\n```svelte\n<SvGrid {data} {columns} groupable groupDisplayMode=\"singleColumn\" />\n```\n\nBoth column modes hide the grouped **source** columns, because their values\nmove into the auto column - showing both would just duplicate them. They also\nrender the group row as an ordinary row, which is the real reason to use them:\nits aggregate cells then line up under the columns they belong to instead of\nsitting in a full-width strip.\n\nTune the combined column with `autoGroupColumnHeader` (default `\"Group\"`) and\n`autoGroupColumnWidth` (default `220`). In `multipleColumns` each column takes\nits name from the source column's header.\n\n## Group footers (subtotal rows)\n\n`groupFooters` closes each group with a subtotal row:\n\n```svelte\n<SvGrid {data} {columns} groupable groupFooters />\n```\n\nThe footer is a clone of the group banner, so it already carries that group's\naggregates and renders through the normal cell path - each total lands under\nits own column instead of in a full-width strip. It is not expandable and has\nno expander.\n\nOnly columns with an `aggregate` produce a value, the same ones that populate\nthe banner.\n\n## Grand total row\n\n`grandTotalRow` appends a single totals row for the whole filtered set:\n\n```svelte\n<SvGrid {data} {columns} grandTotalRow />\n```\n\nIt is independent of `groupFooters` - use it on a flat grid for a bottom totals\nline, or together for subtotals *and* a total:\n\n```svelte\n<SvGrid {data} {columns} groupable groupFooters grandTotalRow />\n```\n\nThree things to know:\n\n- It aggregates the **leaf** rows, so turning grouping on does not double-count\n (the group banners already carry subtotals).\n- It follows the **filtered** set, not the raw data - filter the grid and the\n total moves with it.\n- With `pageable`, it is appended only on the **last** page, so a total never\n appears mid-dataset. The value still covers every row, not just that page.\n\nColumns without an `aggregate` render blank, and if no column declares one the\nrow is skipped entirely.\n\n## Grouping with pagination\n\n`pageSize` budgets **data** rows. Group banners and footers do not count\nagainst it:\n\n```svelte\n<SvGrid {data} {columns} groupable groupFooters pageable pageSize={10} />\n```\n\nA page holds `pageSize` real rows and reprints the banners those rows sit\nunder, so a group split across a page boundary is labelled on both pages - the\nway a spreadsheet repeats group headers across a page break. Footers are\ninserted after paging, so switching them on never pushes data onto the next\npage.\n\nA collapsed group is the visible unit and takes one page slot itself; an\nexpanded one is a header and takes none.\n\n## Frequently asked questions\n\n### How do I group rows in SvGrid?\n\nRegister `columnGroupingFeature` and group by one or more columns. Each group\nrenders a collapsible header row, and you attach an `aggregate` per column to\ncompute sum, avg, count, min, max, or a custom reducer at every group level.\n\n### What aggregation functions does SvGrid support?\n\nBuilt-in `sum`, `avg`, `count`, `min`, and `max`, plus custom aggregators -\nany function that reduces a group's rows to a single value. Aggregates compute\nat each group level and at the grand-total footer.\n\n### Is grouping the same as a pivot table?\n\nNo. Grouping rolls rows up along the row axis. A pivot table also spreads a\nfield across the column axis with nested headers - that is the `@svgrid/enterprise`\npivot model. See [Pivot tables](./pivot.md) for the column-axis version.\n\n## More examples\n\n### Group display modes + footers\n\nSwitch between groupRows banners, a single combined Group column, and one column per grouped field. groupFooters closes each group with a subtotal row under the real columns. Paging counts DATA rows, so pageSize means what it says: a page reprints the banners its rows sit under, and footers never eat the budget.\n\n<div data-docs-demo=\"427-group-footers\" data-height=\"460\"></div>\n\n\n### Group panel (drag & drop)\n\nDevExpress / Kendo-style Group Panel: drag chips into the panel to group, drag inside to reorder grouping levels, × to ungroup. Drives api.setGroupBy() under the hood.\n\n<div data-docs-demo=\"89-group-panel\" data-height=\"460\"></div>\n\n## Try it\n\nGrouping is a prop, and the aggregate shown on each group header comes from the\ncolumn. Add `summary` and the same reducers total the whole filtered set in the\nfooter.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const agg: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80, aggregate: 'avg', summary: 'avg' },\n { field: 'salary', header: 'Salary', width: 130, aggregate: 'sum', summary: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={people} columns={agg} groupBy={['department']} groupable summary sortable />\n```\n\n## Two levels deep\n\n`groupBy` is an array, and the order is the nesting order. Every level gets its\nown aggregate row, computed over the leaf rows beneath it rather than over the\nlevel above.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const nested: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'age', header: 'Age', width: 80, aggregate: 'avg' },\n { field: 'salary', header: 'Salary', width: 130, aggregate: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={people} columns={nested} groupBy={['department', 'city']} groupable summary />\n```\n\n<!-- tutorial:learn-6-grouping -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-6-grouping\" data-docs-tutorial=\"learn-6-grouping\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-6-grouping.mp4\" poster=\"/tutorials/learn-6-grouping.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 6: grouping and totals, 94 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-6-grouping.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-6-grouping.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 6: grouping and totals</strong> (1:34, silent). <a href=\"https://www.youtube.com/watch?v=U39CKHyrdqQ\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Once a table has more than a screenful of rows, people stop reading it and start asking it questions. Grouping and totals are how it answers.</p>\n<p>The same people as before, with a department and a city to group by, and a salary worth adding up.</p>\n<p>Two options do the work here. Aggregate says what a group header shows for that column: an average age, a summed salary. Summary does the same thing for the whole table at the bottom. Both read the raw value, so a formatted currency still adds up correctly.</p>\n<p>Then groupBy on the component names the starting grouping, and groupable lets the user change it later.</p>\n<p>Read it back whole: the same people, one column list with the aggregate and summary options on it, and groupBy on the component. That is everything this needs.</p>\n<p>Every department is a group header carrying its own totals, and the row at the bottom totals the table. Open one and the people inside are ordinary rows: still sortable, still editable, still yours.</p>\n<p>Grouping is part of the row model rather than a display trick, which is why the same thing works over server data: the server returns the groups and the grid asks for a group's rows only when someone opens it. Lesson seven makes all of this look like your product.</p>\n<p>The file is on the page below. See you in lesson seven.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-6-grouping -->\n\n## See also\n\n- [Architecture overview](./architecture.md) - where grouping sits in\n the pipeline.\n- [Pivot tables](./pivot.md) - the column-axis version.\n- [Row pagination](./rows/row-pagination.md) - the paging stage runs\n AFTER grouping, so group rows count toward the page size.\n- [Demo #07 Grouping + aggregation](https://svgrid.com/demos/07-grouping-aggregation/)\n - the source for the example above.\n"
|
|
4574
4598
|
},
|
|
4575
4599
|
{
|
|
4576
4600
|
"slug": "help/grouping/aggregators",
|
|
@@ -4584,42 +4608,42 @@ export const docs = [
|
|
|
4584
4608
|
"path": "docs/help/headless/build-a-table.md",
|
|
4585
4609
|
"title": "Build a table from scratch",
|
|
4586
4610
|
"section": "Headless",
|
|
4587
|
-
"markdown": "# Build a table from scratch\n\nThis is the whole point of headless: the engine computes the rows, **you** emit\nthe markup. Here's a complete, sortable, filterable `<table>` in one Svelte\ncomponent - no `<SvGrid>`, no grid CSS.\n\n<div data-docs-demo=\"186-headless-table\" data-height=\"520\"></div>\n\n## 1. Wire the engine\n\nWhen you bypass `<SvGrid>` you opt into the row models you want. `<SvGrid>` does\nthis for you; here you do it explicitly.\n\n```svelte\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n createFilteredRowModel,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Name' },\n { field: 'lang', header: 'Lang' },\n { field: 'stars', header: 'Stars' },\n ]\n const data: Repo[] = [\n { name: 'svelte', lang: 'TypeScript', stars: 79000 },\n { name: 'kit', lang: 'TypeScript', stars: 18000 },\n { name: 'vite', lang: 'TypeScript', stars: 67000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n ]\n\n // Controlled state - Svelte 5 $state lifts the engine into reactivity.\n type Sort = { id: string; desc: boolean }\n type Filter = { id: string; value: unknown }\n let sorting = $state<Sort[]>([])\n let columnFilters = $state<Filter[]>([])\n let query = $state('')\n\n $effect(() => {\n columnFilters = query ? [{ id: 'name', value: query }] : []\n })\n\n // Rebuild when data / state changes. The engine is cheap to recreate.\n const table = $derived.by(() =>\n createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n filteredRowModel: createFilteredRowModel<Repo>(),\n sortedRowModel: createSortedRowModel<Repo>(),\n },\n data,\n columns,\n state: { sorting, columnFilters },\n // `u` is an Updater: either the next value or a function producing it.\n onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),\n onColumnFiltersChange: (u) => (columnFilters = typeof u === 'function' ? u(columnFilters) : u),\n }),\n )\n\n const headerGroups = $derived(table.getHeaderGroups())\n const rows = $derived(table.getRowModel().rows)\n\n function toggleSort(id: string) {\n const cur = sorting[0]\n sorting =\n cur?.id !== id ? [{ id, desc: false }]\n : cur.desc ? []\n : [{ id, desc: true }]\n }\n const indicator = (id: string) =>\n sorting[0]?.id === id ? (sorting[0]!.desc ? ' ▼' : ' ▲') : ''\n</script>\n```\n\n## 2. Render it - any markup you like\n\n```svelte\n<input placeholder=\"Filter by name…\" bind:value={query} />\n\n<table>\n <thead>\n {#each headerGroups as hg (hg.id)}\n <tr>\n {#each hg.headers as h (h.id)}\n <th onclick={() => toggleSort(h.column.id)} style=\"cursor:pointer\">\n {h.column.columnDef.header}{indicator(h.column.id)}\n </th>\n {/each}\n </tr>\n {/each}\n </thead>\n <tbody>\n {#each rows as r (r.id)}\n <tr>\n {#each columns as col (col.field)}\n <td>{(r.original as Repo)[col.field as keyof Repo] ?? ''}</td>\n {/each}\n </tr>\n {/each}\n </tbody>\n</table>\n```\n\nThat's a fully working sortable + filterable grid - the engine did the sorting\nand filtering, your 20 lines of template did the rendering. Style the `<table>`\nhowever you want; the engine has no opinion.\n\n## What just happened\n\n- **`_rowModels`** declared the pipeline: core → filtered → sorted. Skipping\n grouping and pagination means that code never ships.\n- **`state` in, `onXxxChange` out**: clicking a header calls `toggleSort`, which\n reassigns `sorting`; the `$derived` table re-reads it and `getRowModel()`\n re-sorts.\n- **`getHeaderGroups()`** produced the header tree (a single level here; nested\n `columns: [...]` would produce more rows).\n\n\n## The smallest thing that works\n\nTwo imports, one row model, one `{#each}`. Nothing here is a grid component -\n`<table>`, `<tr>` and `<td>` are yours, and so is every class on them.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { createSvGrid, createCoreRowModel, tableFeatures, type ColumnDef } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n { name: 'tinygo', lang: 'Go', stars: 15000 },\n { name: 'bun', lang: 'Zig', stars: 74000 },\n { name: 'zig', lang: 'Zig', stars: 35000 },\n ]\n\n const features = tableFeatures({})\n\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'lang', header: 'Language' },\n { field: 'stars', header: 'Stars' },\n ]\n\n const table = createSvGrid({\n _features: features,\n _rowModels: { coreRowModel: createCoreRowModel<Repo>() },\n data,\n columns,\n })\n\n const rows = $derived(table.getRowModel().rows)\n</script>\n\n<table>\n <thead>\n {#each table.getHeaderGroups() as hg (hg.id)}\n <tr>\n {#each hg.headers as h (h.id)}\n <th>{h.column.columnDef.header}</th>\n {/each}\n </tr>\n {/each}\n </thead>\n <tbody>\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <tr>\n <td>{repo.name}</td>\n <td>{repo.lang}</td>\n <td>{repo.stars.toLocaleString()}</td>\n </tr>\n {/each}\n </tbody>\n</table>\n```\n\n## Now make the headers sort\n\nAdd the feature, add the row model, and hang\n`h.column.getToggleSortingHandler()` on the header. The indicator is markup you\nwrite, because the engine has no opinion about what a sorted header looks like.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n { name: 'tinygo', lang: 'Go', stars: 15000 },\n { name: 'bun', lang: 'Zig', stars: 74000 },\n { name: 'zig', lang: 'Zig', stars: 35000 },\n ]\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'lang', header: 'Language' },\n { field: 'stars', header: 'Stars' },\n ]\n\n let sorting = $state([{ id: 'stars', desc: true }])\n\n const table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n sortedRowModel: createSortedRowModel<Repo>(),\n },\n data,\n columns,\n state: { sorting },\n onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),\n })\n\n const rows = $derived(table.getRowModel().rows)\n const arrow = (id: string) =>\n sorting[0]?.id === id ? (sorting[0].desc ? ' v' : ' ^') : ''\n</script>\n\n<table>\n <thead>\n {#each table.getHeaderGroups() as hg (hg.id)}\n <tr>\n {#each hg.headers as h (h.id)}\n <th style=\"cursor: pointer;\" onclick={h.column.getToggleSortingHandler()}>\n {h.column.columnDef.header}{arrow(h.column.id)}\n </th>\n {/each}\n </tr>\n {/each}\n </thead>\n <tbody>\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <tr>\n <td>{repo.name}</td>\n <td>{repo.lang}</td>\n <td>{repo.stars.toLocaleString()}</td>\n </tr>\n {/each}\n </tbody>\n</table>\n```\n\n## See also\n\n- [Row models](./row-models.md) - add pagination, grouping, expansion\n- [Controlled state](./controlled-state.md) - lift state out of the component\n- [Styling a headless table](./styling.md) - make it look good, your way\n- [Headless virtualization](./virtualization.md) - do this for 100k rows\n- [Cell components](../cells/cell-components.md) - render snippet/component cells in your own table\n"
|
|
4611
|
+
"markdown": "# Build a table from scratch\n\nThis is the whole point of headless: the engine computes the rows, **you** emit\nthe markup. Here's a complete, sortable, filterable `<table>` in one Svelte\ncomponent - no `<SvGrid>`, no grid CSS.\n\n<div data-docs-demo=\"186-headless-table\" data-height=\"520\"></div>\n\n## 1. Wire the engine\n\nWhen you bypass `<SvGrid>` you opt into the row models you want. `<SvGrid>` does\nthis for you; here you do it explicitly.\n\n```svelte\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n createFilteredRowModel,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Name' },\n { field: 'lang', header: 'Lang' },\n { field: 'stars', header: 'Stars' },\n ]\n const data: Repo[] = [\n { name: 'svelte', lang: 'TypeScript', stars: 79000 },\n { name: 'kit', lang: 'TypeScript', stars: 18000 },\n { name: 'vite', lang: 'TypeScript', stars: 67000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n ]\n\n // Controlled state - Svelte 5 $state lifts the engine into reactivity.\n type Sort = { id: string; desc: boolean }\n type Filter = { id: string; value: unknown }\n let sorting = $state<Sort[]>([])\n let columnFilters = $state<Filter[]>([])\n let query = $state('')\n\n $effect(() => {\n columnFilters = query ? [{ id: 'name', value: query }] : []\n })\n\n // Rebuild when data / state changes. The engine is cheap to recreate.\n const table = $derived.by(() =>\n createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n filteredRowModel: createFilteredRowModel<Repo>(),\n sortedRowModel: createSortedRowModel<Repo>(),\n },\n data,\n columns,\n state: { sorting, columnFilters },\n // `u` is an Updater: either the next value or a function producing it.\n onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),\n onColumnFiltersChange: (u) => (columnFilters = typeof u === 'function' ? u(columnFilters) : u),\n }),\n )\n\n const headerGroups = $derived(table.getHeaderGroups())\n // Touch the state this component owns so the derived re-runs:\n // the engine's store is framework-free and not a rune.\n const rows = $derived.by(() => {\n sorting\n columnFilters\n query\n return table.getRowModel().rows\n })\n\n function toggleSort(id: string) {\n const cur = sorting[0]\n sorting =\n cur?.id !== id ? [{ id, desc: false }]\n : cur.desc ? []\n : [{ id, desc: true }]\n }\n const indicator = (id: string) =>\n sorting[0]?.id === id ? (sorting[0]!.desc ? ' ▼' : ' ▲') : ''\n</script>\n```\n\n## 2. Render it - any markup you like\n\n```svelte\n<input placeholder=\"Filter by name…\" bind:value={query} />\n\n<table>\n <thead>\n {#each headerGroups as hg (hg.id)}\n <tr>\n {#each hg.headers as h (h.id)}\n <th onclick={() => toggleSort(h.column.id)} style=\"cursor:pointer\">\n {h.column.columnDef.header}{indicator(h.column.id)}\n </th>\n {/each}\n </tr>\n {/each}\n </thead>\n <tbody>\n {#each rows as r (r.id)}\n <tr>\n {#each columns as col (col.field)}\n <td>{(r.original as Repo)[col.field as keyof Repo] ?? ''}</td>\n {/each}\n </tr>\n {/each}\n </tbody>\n</table>\n```\n\nThat's a fully working sortable + filterable grid - the engine did the sorting\nand filtering, your 20 lines of template did the rendering. Style the `<table>`\nhowever you want; the engine has no opinion.\n\n## What just happened\n\n- **`_rowModels`** declared the pipeline: core → filtered → sorted. Skipping\n grouping and pagination means that code never ships.\n- **`state` in, `onXxxChange` out**: clicking a header calls `toggleSort`, which\n reassigns `sorting`; the `$derived` table re-reads it and `getRowModel()`\n re-sorts.\n- **`getHeaderGroups()`** produced the header tree (a single level here; nested\n `columns: [...]` would produce more rows).\n\n\n## The smallest thing that works\n\nTwo imports, one row model, one `{#each}`. Nothing here is a grid component -\n`<table>`, `<tr>` and `<td>` are yours, and so is every class on them.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { createSvGrid, createCoreRowModel, tableFeatures, type ColumnDef } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n { name: 'tinygo', lang: 'Go', stars: 15000 },\n { name: 'bun', lang: 'Zig', stars: 74000 },\n { name: 'zig', lang: 'Zig', stars: 35000 },\n ]\n\n const features = tableFeatures({})\n\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'lang', header: 'Language' },\n { field: 'stars', header: 'Stars' },\n ]\n\n const table = createSvGrid({\n _features: features,\n _rowModels: { coreRowModel: createCoreRowModel<Repo>() },\n data,\n columns,\n })\n\n const rows = $derived(table.getRowModel().rows)\n</script>\n\n<table>\n <thead>\n {#each table.getHeaderGroups() as hg (hg.id)}\n <tr>\n {#each hg.headers as h (h.id)}\n <th>{h.column.columnDef.header}</th>\n {/each}\n </tr>\n {/each}\n </thead>\n <tbody>\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <tr>\n <td>{repo.name}</td>\n <td>{repo.lang}</td>\n <td>{repo.stars.toLocaleString()}</td>\n </tr>\n {/each}\n </tbody>\n</table>\n```\n\n## Now make the headers sort\n\nAdd the feature, add the row model, and hang\n`h.column.getToggleSortingHandler()` on the header. The indicator is markup you\nwrite, because the engine has no opinion about what a sorted header looks like.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n { name: 'tinygo', lang: 'Go', stars: 15000 },\n { name: 'bun', lang: 'Zig', stars: 74000 },\n { name: 'zig', lang: 'Zig', stars: 35000 },\n ]\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'lang', header: 'Language' },\n { field: 'stars', header: 'Stars' },\n ]\n\n let sorting = $state([{ id: 'stars', desc: true }])\n\n const table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n sortedRowModel: createSortedRowModel<Repo>(),\n },\n data,\n columns,\n state: { sorting },\n onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),\n })\n\n // Touch the state this component owns so the derived re-runs:\n // the engine's store is framework-free and not a rune.\n const rows = $derived.by(() => {\n sorting\n return table.getRowModel().rows\n })\n const arrow = (id: string) =>\n sorting[0]?.id === id ? (sorting[0].desc ? ' v' : ' ^') : ''\n</script>\n\n<table>\n <thead>\n {#each table.getHeaderGroups() as hg (hg.id)}\n <tr>\n {#each hg.headers as h (h.id)}\n <th style=\"cursor: pointer;\" onclick={h.column.getToggleSortingHandler()}>\n {h.column.columnDef.header}{arrow(h.column.id)}\n </th>\n {/each}\n </tr>\n {/each}\n </thead>\n <tbody>\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <tr>\n <td>{repo.name}</td>\n <td>{repo.lang}</td>\n <td>{repo.stars.toLocaleString()}</td>\n </tr>\n {/each}\n </tbody>\n</table>\n```\n\n## See also\n\n- [Row models](./row-models.md) - add pagination, grouping, expansion\n- [Controlled state](./controlled-state.md) - lift state out of the component\n- [Styling a headless table](./styling.md) - make it look good, your way\n- [Headless virtualization](./virtualization.md) - do this for 100k rows\n- [Cell components](../cells/cell-components.md) - render snippet/component cells in your own table\n"
|
|
4588
4612
|
},
|
|
4589
4613
|
{
|
|
4590
4614
|
"slug": "help/headless/controlled-state",
|
|
4591
4615
|
"path": "docs/help/headless/controlled-state.md",
|
|
4592
4616
|
"title": "Controlled state",
|
|
4593
4617
|
"section": "Headless",
|
|
4594
|
-
"markdown": "# Controlled state\n\nThe engine never owns its state - **you do**. You pass `state` in and the engine\ncalls your `onXxxChange` handlers with the next value. That one rule is what\nmakes headless SvGrid click with Svelte 5 `$state`, and it's what lets state\nsurvive an unmount or be shared across two grids.\n\n## The in/out contract\n\n```ts\nlet sorting = $state<{ id: string; desc: boolean }[]>([])\n\nconst table = $derived.by(() => createSvGrid({\n _features: features,\n _rowModels: { coreRowModel: createCoreRowModel<Row>(), sortedRowModel: createSortedRowModel<Row>() },\n data, columns,\n state: { sorting }, // ── in\n onSortingChange: (u) => // ── out\n (sorting = typeof u === 'function' ? u(sorting) : u),\n}))\n```\n\nThe handler receives either the next value **or** an updater function - always\nhandle both (`typeof u === 'function' ? u(prev) : u`). Because `sorting` is\n`$state` and `table` is `$derived`, changing it re-runs the pipeline.\n\nThe same shape applies to every state channel: `onColumnFiltersChange`,\n`onPaginationChange`, `onGroupingChange`, `onExpandedChange`,\n`onRowSelectionChange`, `onColumnVisibilityChange`.\n\n## `createGridState` - lift state out of the component\n\nSometimes you want a piece of state to live **outside** the grid: so it survives\nan unmount, persists to storage, or is shared by two grids. `createGridState`\nreturns a `[get, set]` tuple - a reactive store you own (`createSvGridState` is\nan alias).\n\n```ts\nimport { createGridState } from '@svgrid/grid/core'\n\n// [getter, setter] - the getter reads reactive $state, the setter takes a\n// value or an updater function.\nconst [getSorting, setSorting] = createGridState<Sort[]>([])\n\nsetSorting([{ id: 'stars', desc: true }]) // set a value\nsetSorting((prev) => [...prev]) // or an updater\nconsole.log(getSorting()) // read it\n```\n\nWire it into a grid via the `state` / `onXxxChange` channel:\n\n```ts\nconst table = $derived.by(() => createSvGrid({\n get data() { return rows },\n get columns() { return columns },\n state: { sorting: getSorting() },\n onSortingChange: setSorting, // the setter already handles updaters\n}))\n```\n\n### Share one store across two grids\n\nPass the **same** `[get, set]` pair to two engines and they stay in lockstep -\nsort in one, the other re-sorts, because both read the same reactive store.\n\n<div data-docs-demo=\"189-headless-shared-state\" data-height=\"460\"></div>\n\n```ts\nconst [getSorting, setSorting] = createGridState<Sort[]>([])\n\nconst makeGrid = () => $derived.by(() => createSvGrid({\n get data() { return rows }, get columns() { return columns },\n state: { sorting: getSorting() },\n onSortingChange: setSorting,\n}))\n\nconst gridA = makeGrid()\nconst gridB = makeGrid() // shares getSorting/setSorting -> stays in sync\n```\n\n## `subscribeGrid` - react outside a component\n\nInside a Svelte component, `$effect` is the natural way to react to grid\nchanges. Outside one - a Svelte action, an analytics hook, an integration test -\nuse `subscribeGrid` for a plain pub/sub interface (`subscribeSvGrid` is an\nalias).\n\n```ts\nimport { subscribeGrid } from '@svgrid/grid/core'\n\nconst unsub = subscribeGrid(table, (event) => {\n if (event.type === 'sorting') analytics.track('grid_sort', event.value)\n if (event.type === 'rowSelection') syncSelectionToUrl(event.value)\n})\n\n// later\nunsub()\n```\n\n## You own the state\n\nEvery slice you pass in `state` is yours: the engine reads it and reports\nchanges through the matching `on*Change`, but never writes it. That is what lets\nthe same value drive a URL, a saved view, or two grids at once.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n ]\n\n const features = tableFeatures({ rowSortingFeature })\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'stars', header: 'Stars' },\n ]\n\n let sorting = $state([{ id: 'stars', desc: true }])\n\n const table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n sortedRowModel: createSortedRowModel<Repo>(),\n },\n data,\n columns,\n state: { sorting },\n onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),\n })\n\n const rows = $derived(table.getRowModel().rows)\n</script>\n\n<button type=\"button\" onclick={() => (sorting = [{ id: 'name', desc: false }])}>\n Sort by name from outside\n</button>\n\n<p>State: <code>{JSON.stringify(sorting)}</code></p>\n\n<ol>\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <li>{repo.name} - {repo.stars.toLocaleString()}</li>\n {/each}\n</ol>\n```\n\n\n## The updater can be a function\n\nAn `on*Change` hands you either the next value or a function of the previous\none, the way `setState` does. Handling only the value silently drops the toggle\npath, and a header click stops working.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n ]\n\n const features = tableFeatures({ rowSortingFeature })\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'stars', header: 'Stars' },\n ]\n\n let sorting = $state<Array<{ id: string; desc: boolean }>>([])\n let updates = $state(0)\n\n const table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n sortedRowModel: createSortedRowModel<Repo>(),\n },\n data,\n columns,\n state: { sorting },\n onSortingChange: (u) => {\n // Both shapes. Dropping the function branch is the classic bug here.\n sorting = typeof u === 'function' ? u(sorting) : u\n updates += 1\n },\n })\n\n const rows = $derived(table.getRowModel().rows)\n</script>\n\n<table>\n <thead>\n {#each table.getHeaderGroups() as hg (hg.id)}\n <tr>\n {#each hg.headers as h (h.id)}\n <th onclick={h.column.getToggleSortingHandler()}>{h.column.columnDef.header}</th>\n {/each}\n </tr>\n {/each}\n </thead>\n <tbody>\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <tr><td>{repo.name}</td><td>{repo.stars.toLocaleString()}</td></tr>\n {/each}\n </tbody>\n</table>\n\n<p>{updates} sort update(s)</p>\n```\n\n## See also\n\n- [Row models](./row-models.md) - each channel feeds one pipeline step\n- [Build a table from scratch](./build-a-table.md)\n- [Named views](../state/named-views.md) - the `<SvGrid>` state save/restore equivalent\n"
|
|
4618
|
+
"markdown": "# Controlled state\n\nThe engine never owns its state - **you do**. You pass `state` in and the engine\ncalls your `onXxxChange` handlers with the next value. That one rule is what\nmakes headless SvGrid click with Svelte 5 `$state`, and it's what lets state\nsurvive an unmount or be shared across two grids.\n\n## The in/out contract\n\n```ts\nlet sorting = $state<{ id: string; desc: boolean }[]>([])\n\nconst table = $derived.by(() => createSvGrid({\n _features: features,\n _rowModels: { coreRowModel: createCoreRowModel<Row>(), sortedRowModel: createSortedRowModel<Row>() },\n data, columns,\n state: { sorting }, // ── in\n onSortingChange: (u) => // ── out\n (sorting = typeof u === 'function' ? u(sorting) : u),\n}))\n```\n\nThe handler receives either the next value **or** an updater function - always\nhandle both (`typeof u === 'function' ? u(prev) : u`). Because `sorting` is\n`$state` and `table` is `$derived`, changing it re-runs the pipeline.\n\nThe same shape applies to every state channel: `onColumnFiltersChange`,\n`onPaginationChange`, `onGroupingChange`, `onExpandedChange`,\n`onRowSelectionChange`, `onColumnVisibilityChange`.\n\n## `createGridState` - lift state out of the component\n\nSometimes you want a piece of state to live **outside** the grid: so it survives\nan unmount, persists to storage, or is shared by two grids. `createGridState`\nreturns a `[get, set]` tuple - a reactive store you own (`createSvGridState` is\nan alias).\n\n```ts\nimport { createGridState } from '@svgrid/grid/core'\n\n// [getter, setter] - the getter reads reactive $state, the setter takes a\n// value or an updater function.\nconst [getSorting, setSorting] = createGridState<Sort[]>([])\n\nsetSorting([{ id: 'stars', desc: true }]) // set a value\nsetSorting((prev) => [...prev]) // or an updater\nconsole.log(getSorting()) // read it\n```\n\nWire it into a grid via the `state` / `onXxxChange` channel:\n\n```ts\nconst table = $derived.by(() => createSvGrid({\n get data() { return rows },\n get columns() { return columns },\n state: { sorting: getSorting() },\n onSortingChange: setSorting, // the setter already handles updaters\n}))\n```\n\n### Share one store across two grids\n\nPass the **same** `[get, set]` pair to two engines and they stay in lockstep -\nsort in one, the other re-sorts, because both read the same reactive store.\n\n<div data-docs-demo=\"189-headless-shared-state\" data-height=\"460\"></div>\n\n```ts\nconst [getSorting, setSorting] = createGridState<Sort[]>([])\n\nconst makeGrid = () => $derived.by(() => createSvGrid({\n get data() { return rows }, get columns() { return columns },\n state: { sorting: getSorting() },\n onSortingChange: setSorting,\n}))\n\nconst gridA = makeGrid()\nconst gridB = makeGrid() // shares getSorting/setSorting -> stays in sync\n```\n\n## `subscribeGrid` - react outside a component\n\nInside a Svelte component, `$effect` is the natural way to react to grid\nchanges. Outside one - a Svelte action, an analytics hook, an integration test -\nuse `subscribeGrid` for a plain pub/sub interface (`subscribeSvGrid` is an\nalias).\n\n```ts\nimport { subscribeGrid } from '@svgrid/grid/core'\n\nconst unsub = subscribeGrid(table, (event) => {\n if (event.type === 'sorting') analytics.track('grid_sort', event.value)\n if (event.type === 'rowSelection') syncSelectionToUrl(event.value)\n})\n\n// later\nunsub()\n```\n\n## You own the state\n\nEvery slice you pass in `state` is yours: the engine reads it and reports\nchanges through the matching `on*Change`, but never writes it. That is what lets\nthe same value drive a URL, a saved view, or two grids at once.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n ]\n\n const features = tableFeatures({ rowSortingFeature })\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'stars', header: 'Stars' },\n ]\n\n let sorting = $state([{ id: 'stars', desc: true }])\n\n const table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n sortedRowModel: createSortedRowModel<Repo>(),\n },\n data,\n columns,\n state: { sorting },\n onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),\n })\n\n // Touch the state this component owns so the derived re-runs:\n // the engine's store is framework-free and not a rune.\n const rows = $derived.by(() => {\n sorting\n return table.getRowModel().rows\n })\n</script>\n\n<button type=\"button\" onclick={() => (sorting = [{ id: 'name', desc: false }])}>\n Sort by name from outside\n</button>\n\n<p>State: <code>{JSON.stringify(sorting)}</code></p>\n\n<ol>\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <li>{repo.name} - {repo.stars.toLocaleString()}</li>\n {/each}\n</ol>\n```\n\n\n## The updater can be a function\n\nAn `on*Change` hands you either the next value or a function of the previous\none, the way `setState` does. Handling only the value silently drops the toggle\npath, and a header click stops working.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n ]\n\n const features = tableFeatures({ rowSortingFeature })\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'stars', header: 'Stars' },\n ]\n\n let sorting = $state<Array<{ id: string; desc: boolean }>>([])\n let updates = $state(0)\n\n const table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n sortedRowModel: createSortedRowModel<Repo>(),\n },\n data,\n columns,\n state: { sorting },\n onSortingChange: (u) => {\n // Both shapes. Dropping the function branch is the classic bug here.\n sorting = typeof u === 'function' ? u(sorting) : u\n updates += 1\n },\n })\n\n // Touch the state this component owns so the derived re-runs:\n // the engine's store is framework-free and not a rune.\n const rows = $derived.by(() => {\n sorting\n updates\n return table.getRowModel().rows\n })\n</script>\n\n<table>\n <thead>\n {#each table.getHeaderGroups() as hg (hg.id)}\n <tr>\n {#each hg.headers as h (h.id)}\n <th onclick={h.column.getToggleSortingHandler()}>{h.column.columnDef.header}</th>\n {/each}\n </tr>\n {/each}\n </thead>\n <tbody>\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <tr><td>{repo.name}</td><td>{repo.stars.toLocaleString()}</td></tr>\n {/each}\n </tbody>\n</table>\n\n<p>{updates} sort update(s)</p>\n```\n\n## See also\n\n- [Row models](./row-models.md) - each channel feeds one pipeline step\n- [Build a table from scratch](./build-a-table.md)\n- [Named views](../state/named-views.md) - the `<SvGrid>` state save/restore equivalent\n"
|
|
4595
4619
|
},
|
|
4596
4620
|
{
|
|
4597
4621
|
"slug": "help/headless/overview",
|
|
4598
4622
|
"path": "docs/help/headless/overview.md",
|
|
4599
4623
|
"title": "Headless overview",
|
|
4600
4624
|
"section": "Headless",
|
|
4601
|
-
"markdown": "# Headless overview\n\n`<SvGrid>` is the **renderer**. `createSvGrid` is the **engine** that powers it.\nThey're independent: you can use the engine on its own to build a custom UI,\nrender a plain `<table>` for print or email, run the row pipeline in a Web\nWorker or on a server, or unit-test sort / filter / aggregation logic with no\nDOM at all.\n\n\n\n```\nyour data ─▶ createSvGrid (engine) ─▶ row model ─▶ your markup\n ▲ │\n controlled state ◀──── change events ──┘\n```\n\n## What the engine gives you\n\n`createSvGrid(options)` returns a table object with:\n\n- `getRowModel(): { rows: Row<TData>[] }` - the final, post-pipeline rows\n (filtered → sorted → grouped → expanded → paginated),\n- `getHeaderGroups(): HeaderGroup<TData>[]` - the multi-level column-header tree,\n- `getAllColumns(): Column<TData>[]` - every column with its metadata (id,\n visible, pinned, width),\n- imperative setters (`setColumnFilters`, `setPagination`, `setGrouping`,\n `setExpanded`, `setRowSelection`, `setActiveCell`) that push into the engine's\n store. Sorting has no setter - drive it from the `sorting` state you pass in,\n or from a header's `getToggleSortingHandler()`.\n\nNo DOM, no CSS, no virtualization - those live in the renderer. Here's the\nengine rendering a plain, hand-styled `<table>` (sort + filter are the engine's;\nthe markup is the demo's):\n\n<div data-docs-demo=\"186-headless-table\" data-height=\"500\"></div>\n\n## When to reach for headless\n\n| You want to… | Use |\n| --- | --- |\n| A rich grid in a Svelte app | `<SvGrid>` (start here) |\n| Render as a plain `<table>` (print / email / RSC) | Headless |\n| Drive a server-side row model from Node | Headless, via `createSvGridCore` |\n| Unit-test sort / filter / aggregator logic | Headless |\n| Build a custom virtualized renderer | Headless + the [virtualizer](./virtualization.md) exports |\n| Share one state object across two grids | Headless + [`createGridState`](./controlled-state.md) |\n\nFor the common case, use [`<SvGrid>`](../../getting-started/2-first-grid.md) - it wires all\nof this for you. Reach for the engine when you need a different renderer or to\nrun the pipeline where there is no DOM.\n\n## The three ideas\n\n1. **Row models are a pipeline.** You opt into the steps you need\n (`coreRowModel`, `filteredRowModel`, `sortedRowModel`, …); unused steps are\n tree-shaken. See [Row models](./row-models.md).\n2. **State is controlled.** You pass `state` in and get `onXxxChange` events\n out - the engine never mutates your state. This is what makes it click with\n Svelte 5 `$state`. See [Controlled state](./controlled-state.md).\n3. **Rendering is yours.** The engine hands you rows + header groups; you emit\n the markup. See [Build a table from scratch](./build-a-table.md).\n\n## `createSvGrid` vs `createSvGridCore`\n\nBoth build the same engine and expose the same `getRowModel()` /\n`getHeaderGroups()` surface. The difference is reactivity:\n\n| | `createSvGrid` | `createSvGridCore` |\n| --- | --- | --- |\n| State | Svelte 5 runes | plain objects |\n| Needs the Svelte compiler | Yes | No |\n| Runs under | Vite, SvelteKit, vitest | anywhere Node runs |\n\nInside a component, use `createSvGrid` - runes are what make `$derived` re-run\nthe pipeline when your state changes. Outside one - a Node service, a worker, a\nCLI, a plain unit test with no Svelte in the pipeline - use `createSvGridCore`\nand rebuild it yourself when the state changes:\n\n```js\n// plain node script.mjs - no bundler, no compiler\nimport {\n createSvGridCore,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n} from '@svgrid/grid/core'\n\nconst features = tableFeatures({ rowSortingFeature })\nconst table = createSvGridCore({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel(),\n sortedRowModel: createSortedRowModel(),\n },\n data,\n columns,\n state: { sorting: [{ id: 'salary', desc: true }] },\n onSortingChange: () => {},\n})\n\nconst rows = table.getRowModel().rows // sorted, no DOM involved\n```\n\n`createSvGrid` imported into a bare Node process throws\n`ReferenceError: $state is not defined` - that is the compiler missing, not a\nbug. Reach for the core function there.\n\n## The engine with no grid\n\nNo `<SvGrid>` anywhere. The engine owns sorting and hands back a row model;\nevery element below is markup you wrote, which is the whole proposition.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n ]\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'lang', header: 'Language' },\n { field: 'stars', header: 'Stars' },\n ]\n\n let sorting = $state([{ id: 'stars', desc: true }])\n\n const table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n sortedRowModel: createSortedRowModel<Repo>(),\n },\n data,\n columns,\n state: { sorting },\n onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),\n })\n\n const rows = $derived(table.getRowModel().rows)\n</script>\n\n<table>\n <thead>\n {#each table.getHeaderGroups() as hg (hg.id)}\n <tr>\n {#each hg.headers as h (h.id)}\n <th onclick={h.column.getToggleSortingHandler()}>{h.column.columnDef.header}</th>\n {/each}\n </tr>\n {/each}\n </thead>\n <tbody>\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <tr>\n <td>{repo.name}</td>\n <td>{repo.lang}</td>\n <td>{repo.stars.toLocaleString()}</td>\n </tr>\n {/each}\n </tbody>\n</table>\n```\n\n\n## Adding a stage\n\nEach row model is a pipeline stage you opt into. Register the filtered model\nand a filter starts applying; leave it out and the code for it never ships.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createFilteredRowModel,\n tableFeatures,\n columnFilteringFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n ]\n\n const features = tableFeatures({ columnFilteringFeature })\n\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'lang', header: 'Language' },\n ]\n\n let columnFilters = $state<Array<{ id: string; value: unknown }>>([])\n\n const table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n filteredRowModel: createFilteredRowModel<Repo>(),\n },\n data,\n columns,\n state: { columnFilters },\n onColumnFiltersChange: (u) =>\n (columnFilters = typeof u === 'function' ? u(columnFilters) : u),\n })\n\n const rows = $derived(table.getRowModel().rows)\n</script>\n\n<input\n placeholder=\"Filter language\"\n oninput={(e) => (columnFilters = [{ id: 'lang', value: e.currentTarget.value }])}\n/>\n\n<ul>\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <li>{repo.name} - {repo.lang}</li>\n {/each}\n</ul>\n```\n\n## See also\n\n- [Build a `<table>` from scratch](./build-a-table.md) - a complete 30-line renderer\n- [Styling a headless table](./styling.md) - three looks from one engine\n- [Row models](./row-models.md) - the pipeline, step by step\n- [Server-side data](./server-side.md) - paging, sorting, filtering, load on demand\n- [Controlled state](./controlled-state.md) - `createGridState` / `subscribeGrid`\n- [Headless virtualization](./virtualization.md) - render 100k rows yourself\n- [Why headless?](../../why-headless.md) - the design rationale\n- [Svelte headless table](https://svgrid.com/svelte/headless-table/) - the landing page, with the measured size of the engine\n- [Bundle size](../bundle-size.md) - every entry, measured on each release\n- [Architecture](../architecture.md) - the three-layer model\n"
|
|
4625
|
+
"markdown": "# Headless overview\n\n`<SvGrid>` is the **renderer**. `createSvGrid` is the **engine** that powers it.\nThey're independent: you can use the engine on its own to build a custom UI,\nrender a plain `<table>` for print or email, run the row pipeline in a Web\nWorker or on a server, or unit-test sort / filter / aggregation logic with no\nDOM at all.\n\n\n\n```\nyour data ─▶ createSvGrid (engine) ─▶ row model ─▶ your markup\n ▲ │\n controlled state ◀──── change events ──┘\n```\n\n## What the engine gives you\n\n`createSvGrid(options)` returns a table object with:\n\n- `getRowModel(): { rows: Row<TData>[] }` - the final, post-pipeline rows\n (filtered → sorted → grouped → expanded → paginated),\n- `getHeaderGroups(): HeaderGroup<TData>[]` - the multi-level column-header tree,\n- `getAllColumns(): Column<TData>[]` - every column with its metadata (id,\n visible, pinned, width),\n- imperative setters (`setColumnFilters`, `setPagination`, `setGrouping`,\n `setExpanded`, `setRowSelection`, `setActiveCell`) that push into the engine's\n store. Sorting has no setter - drive it from the `sorting` state you pass in,\n or from a header's `getToggleSortingHandler()`.\n\nNo DOM, no CSS, no virtualization - those live in the renderer. Here's the\nengine rendering a plain, hand-styled `<table>` (sort + filter are the engine's;\nthe markup is the demo's):\n\n<div data-docs-demo=\"186-headless-table\" data-height=\"500\"></div>\n\n## When to reach for headless\n\n| You want to… | Use |\n| --- | --- |\n| A rich grid in a Svelte app | `<SvGrid>` (start here) |\n| Render as a plain `<table>` (print / email / RSC) | Headless |\n| Drive a server-side row model from Node | Headless, via `createSvGridCore` |\n| Unit-test sort / filter / aggregator logic | Headless |\n| Build a custom virtualized renderer | Headless + the [virtualizer](./virtualization.md) exports |\n| Share one state object across two grids | Headless + [`createGridState`](./controlled-state.md) |\n\nFor the common case, use [`<SvGrid>`](../../getting-started/2-first-grid.md) - it wires all\nof this for you. Reach for the engine when you need a different renderer or to\nrun the pipeline where there is no DOM.\n\n## The three ideas\n\n1. **Row models are a pipeline.** You opt into the steps you need\n (`coreRowModel`, `filteredRowModel`, `sortedRowModel`, …); unused steps are\n tree-shaken. See [Row models](./row-models.md).\n2. **State is controlled.** You pass `state` in and get `onXxxChange` events\n out - the engine never mutates your state. This is what makes it click with\n Svelte 5 `$state`. See [Controlled state](./controlled-state.md).\n3. **Rendering is yours.** The engine hands you rows + header groups; you emit\n the markup. See [Build a table from scratch](./build-a-table.md).\n\n## `createSvGrid` vs `createSvGridCore`\n\nBoth build the same engine and expose the same `getRowModel()` /\n`getHeaderGroups()` surface. The difference is reactivity:\n\n| | `createSvGrid` | `createSvGridCore` |\n| --- | --- | --- |\n| State | Svelte 5 runes | plain objects |\n| Needs the Svelte compiler | Yes | No |\n| Runs under | Vite, SvelteKit, vitest | anywhere Node runs |\n\nInside a component, use `createSvGrid` - runes are what make `$derived` re-run\nthe pipeline when your state changes. Outside one - a Node service, a worker, a\nCLI, a plain unit test with no Svelte in the pipeline - use `createSvGridCore`\nand rebuild it yourself when the state changes:\n\n```js\n// plain node script.mjs - no bundler, no compiler\nimport {\n createSvGridCore,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n} from '@svgrid/grid/core'\n\nconst features = tableFeatures({ rowSortingFeature })\nconst table = createSvGridCore({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel(),\n sortedRowModel: createSortedRowModel(),\n },\n data,\n columns,\n state: { sorting: [{ id: 'salary', desc: true }] },\n onSortingChange: () => {},\n})\n\nconst rows = table.getRowModel().rows // sorted, no DOM involved\n```\n\n`createSvGrid` imported into a bare Node process throws\n`ReferenceError: $state is not defined` - that is the compiler missing, not a\nbug. Reach for the core function there.\n\n## The engine with no grid\n\nNo `<SvGrid>` anywhere. The engine owns sorting and hands back a row model;\nevery element below is markup you wrote, which is the whole proposition.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n ]\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'lang', header: 'Language' },\n { field: 'stars', header: 'Stars' },\n ]\n\n let sorting = $state([{ id: 'stars', desc: true }])\n\n const table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n sortedRowModel: createSortedRowModel<Repo>(),\n },\n data,\n columns,\n state: { sorting },\n onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),\n })\n\n // Touch the state this component owns so the derived re-runs:\n // the engine's store is framework-free and not a rune.\n const rows = $derived.by(() => {\n sorting\n return table.getRowModel().rows\n })\n</script>\n\n<table>\n <thead>\n {#each table.getHeaderGroups() as hg (hg.id)}\n <tr>\n {#each hg.headers as h (h.id)}\n <th onclick={h.column.getToggleSortingHandler()}>{h.column.columnDef.header}</th>\n {/each}\n </tr>\n {/each}\n </thead>\n <tbody>\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <tr>\n <td>{repo.name}</td>\n <td>{repo.lang}</td>\n <td>{repo.stars.toLocaleString()}</td>\n </tr>\n {/each}\n </tbody>\n</table>\n```\n\n\n## Adding a stage\n\nEach row model is a pipeline stage you opt into. Register the filtered model\nand a filter starts applying; leave it out and the code for it never ships.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createFilteredRowModel,\n tableFeatures,\n columnFilteringFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n ]\n\n const features = tableFeatures({ columnFilteringFeature })\n\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'lang', header: 'Language' },\n ]\n\n let columnFilters = $state<Array<{ id: string; value: unknown }>>([])\n\n const table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n filteredRowModel: createFilteredRowModel<Repo>(),\n },\n data,\n columns,\n state: { columnFilters },\n onColumnFiltersChange: (u) =>\n (columnFilters = typeof u === 'function' ? u(columnFilters) : u),\n })\n\n // Touch the state this component owns so the derived re-runs:\n // the engine's store is framework-free and not a rune.\n const rows = $derived.by(() => {\n columnFilters\n return table.getRowModel().rows\n })\n</script>\n\n<input\n placeholder=\"Filter language\"\n oninput={(e) => (columnFilters = [{ id: 'lang', value: e.currentTarget.value }])}\n/>\n\n<ul>\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <li>{repo.name} - {repo.lang}</li>\n {/each}\n</ul>\n```\n\n## See also\n\n- [Build a `<table>` from scratch](./build-a-table.md) - a complete 30-line renderer\n- [Styling a headless table](./styling.md) - three looks from one engine\n- [Row models](./row-models.md) - the pipeline, step by step\n- [Server-side data](./server-side.md) - paging, sorting, filtering, load on demand\n- [Controlled state](./controlled-state.md) - `createGridState` / `subscribeGrid`\n- [Headless virtualization](./virtualization.md) - render 100k rows yourself\n- [Why headless?](../../why-headless.md) - the design rationale\n- [Svelte headless table](https://svgrid.com/svelte/headless-table/) - the landing page, with the measured size of the engine\n- [Bundle size](../bundle-size.md) - every entry, measured on each release\n- [Architecture](../architecture.md) - the three-layer model\n"
|
|
4602
4626
|
},
|
|
4603
4627
|
{
|
|
4604
4628
|
"slug": "help/headless/row-models",
|
|
4605
4629
|
"path": "docs/help/headless/row-models.md",
|
|
4606
4630
|
"title": "Row models",
|
|
4607
4631
|
"section": "Headless",
|
|
4608
|
-
"markdown": "# Row models\n\nThe engine turns your raw `data` into the rows you render by running it through\na **pipeline** of row models. You opt into the steps you need; everything else\nis tree-shaken out.\n\n```\ndata\n └─▶ coreRowModel wrap each item as a Row\n └─▶ filteredRowModel drop rows that fail the column filters\n └─▶ sortedRowModel order by the sort state\n └─▶ groupedRowModel build group parent rows + aggregates\n └─▶ expandedRowModel flatten only expanded groups\n └─▶ paginatedRowModel slice to the current page\n```\n\nEach step is lazy and reads from the [controlled state](./controlled-state.md)\n(`sorting`, `columnFilters`, `grouping`, `expanded`, `pagination`).\n\nFlip the group-by control below and watch the pipeline change shape - the same\nengine goes from a flat list to `grouped -> expanded` with per-group `sum`\naggregates, and the markup stays a plain hand-styled `<table>`:\n\n<div data-docs-demo=\"190-headless-row-models\" data-height=\"440\"></div>\n\n## Opt in per step\n\nPass only the models you want into `_rowModels`. `createCoreRowModel` is always\nrequired (it's the entry point); the rest are optional.\n\n```ts\nimport {\n createSvGrid,\n createCoreRowModel,\n createFilteredRowModel,\n createSortedRowModel,\n createPaginatedRowModel,\n createGroupedRowModel,\n createExpandedRowModel,\n tableFeatures,\n rowPaginationFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n} from '@svgrid/grid/core'\n\nconst table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Row>(),\n filteredRowModel: createFilteredRowModel<Row>(),\n sortedRowModel: createSortedRowModel<Row>(),\n paginatedRowModel: createPaginatedRowModel<Row>(),\n },\n data,\n columns,\n state,\n // ...change handlers\n})\n\nconst rows = table.getRowModel().rows // filtered → sorted → paged\n```\n\nSkip `paginatedRowModel` and `getRowModel().rows` returns every matching row.\nSkip `filteredRowModel` and the `columnFilters` state is simply ignored.\n\n## Pagination\n\nAdd `createPaginatedRowModel` and drive it with `pagination` state:\n\n```ts\nlet pagination = $state({ pageIndex: 0, pageSize: 20 })\n\nconst features = tableFeatures({ rowPaginationFeature })\n\nconst table = $derived.by(() => createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Row>(),\n paginatedRowModel: createPaginatedRowModel<Row>(),\n },\n data, columns,\n state: { pagination },\n onPaginationChange: (u) =>\n (pagination = typeof u === 'function' ? u(pagination) : u),\n}))\n\n// getRowModel().rows is now just the current page\nfunction nextPage() { pagination = { ...pagination, pageIndex: pagination.pageIndex + 1 } }\n```\n\n## Grouping + aggregation\n\n`groupedRowModel` builds parent rows with aggregated values; `expandedRowModel`\nthen emits only the expanded children. A grouped row's `getIsGrouped()` is true\nand its aggregates come from each column's `aggregate` setting.\n\n```ts\nlet grouping = $state<string[]>(['lang'])\nlet expanded = $state<Record<string, boolean>>({})\n\nconst features = tableFeatures({ columnGroupingFeature, rowExpandingFeature })\n\nconst table = $derived.by(() => createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n groupedRowModel: createGroupedRowModel<Repo>(),\n expandedRowModel: createExpandedRowModel<Repo>(),\n },\n data,\n columns: [\n { field: 'lang', header: 'Lang' },\n { field: 'stars', header: 'Stars', aggregate: 'sum' }, // rolled up per group\n ],\n state: { grouping, expanded },\n onExpandedChange: (u) => (expanded = typeof u === 'function' ? u(expanded) : u),\n}))\n\nconst rows = $derived(table.getRowModel().rows)\n// each row: row.getIsGrouped(), row.getIsExpanded(), row.toggleExpanded()\n```\n\n## Server-side: skip the local pipeline\n\nWhen the server does the sorting / filtering / paging, feed the engine only the\npage it returned and leave those models out - the engine just wraps and renders\nwhat you give it:\n\n```ts\nconst table = createSvGrid({\n _features: tableFeatures({}), // no features - the server did the work\n _rowModels: { coreRowModel: createCoreRowModel<Row>() }, // core only\n data: serverPage.rows, // already sorted/filtered/paged by the API\n columns,\n state,\n})\n```\n\nTrack the sort/filter state via the change handlers and re-fetch on change - see\n[Server-side data (load on demand)](./server-side.md) for a complete, runnable\nexample with paging, sorting, filtering and a race-safe fetch.\n\n\n## Pagination, running\n\n`paginatedRowModel` is the last stage, so `getRowModel().rows` is already the\ncurrent page - there is no slicing left for you to do. Page state is yours, which\nis why the buttons below just assign to it.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createPaginatedRowModel,\n tableFeatures,\n rowPaginationFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n { name: 'tinygo', lang: 'Go', stars: 15000 },\n { name: 'bun', lang: 'Zig', stars: 74000 },\n { name: 'zig', lang: 'Zig', stars: 35000 },\n ]\n\n const features = tableFeatures({ rowPaginationFeature })\n\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'lang', header: 'Language' },\n { field: 'stars', header: 'Stars' },\n ]\n\n let pagination = $state({ pageIndex: 0, pageSize: 3 })\n\n const table = $derived.by(() =>\n createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n paginatedRowModel: createPaginatedRowModel<Repo>(),\n },\n data,\n columns,\n state: { pagination },\n onPaginationChange: (u) => (pagination = typeof u === 'function' ? u(pagination) : u),\n }),\n )\n\n const rows = $derived(table.getRowModel().rows)\n const pages = $derived(Math.ceil(data.length / pagination.pageSize))\n\n function go(delta: number) {\n const next = Math.min(pages - 1, Math.max(0, pagination.pageIndex + delta))\n pagination = { ...pagination, pageIndex: next }\n }\n</script>\n\n<div>\n <button type=\"button\" onclick={() => go(-1)} disabled={pagination.pageIndex === 0}>Prev</button>\n <span>Page {pagination.pageIndex + 1} of {pages}</span>\n <button type=\"button\" onclick={() => go(1)} disabled={pagination.pageIndex >= pages - 1}>Next</button>\n</div>\n\n<ul>\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <li>{repo.name} - {repo.lang} - {repo.stars.toLocaleString()}</li>\n {/each}\n</ul>\n```\n\n## Grouping, running\n\n`groupedRowModel` inserts parent rows; `expandedRowModel` decides which\nchildren survive. A parent is the row where `getCanExpand()` is true, and its\naggregated cells come from the column's `aggregate` setting - so a group row and\na leaf row are two different shapes in the same list, and your markup has to\nbranch on it.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createGroupedRowModel,\n createExpandedRowModel,\n tableFeatures,\n columnGroupingFeature,\n rowExpandingFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n { name: 'tinygo', lang: 'Go', stars: 15000 },\n { name: 'bun', lang: 'Zig', stars: 74000 },\n { name: 'zig', lang: 'Zig', stars: 35000 },\n ]\n\n const features = tableFeatures({ columnGroupingFeature, rowExpandingFeature })\n\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'lang', header: 'Language' },\n { field: 'stars', header: 'Stars', aggregate: 'sum' },\n ]\n\n let grouping = $state<string[]>(['lang'])\n let expanded = $state<Record<string, boolean>>({})\n\n const table = $derived.by(() =>\n createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n groupedRowModel: createGroupedRowModel<Repo>(),\n expandedRowModel: createExpandedRowModel<Repo>(),\n },\n data,\n columns,\n state: { grouping, expanded },\n onExpandedChange: (u) => (expanded = typeof u === 'function' ? u(expanded) : u),\n }),\n )\n\n const rows = $derived(table.getRowModel().rows)\n\n // Feature-conditional members: present because rowExpandingFeature is on.\n const isGroup = (r: any) => typeof r.getCanExpand === 'function' && r.getCanExpand()\n</script>\n\n<table>\n <tbody>\n {#each rows as r (r.id)}\n {#if isGroup(r)}\n <tr onclick={() => (r as any).toggleExpanded?.()} style=\"cursor: pointer; font-weight: 600;\">\n <td colspan=\"2\">\n {(r as any).getIsExpanded?.() ? '- ' : '+ '}{(r as any).getCellValueByColumnId('lang')}\n </td>\n <td>{Number((r as any).getCellValueByColumnId('stars') ?? 0).toLocaleString()}</td>\n </tr>\n {:else}\n {@const repo = r.original as Repo}\n <tr>\n <td style=\"padding-left: 18px;\">{repo.name}</td>\n <td>{repo.lang}</td>\n <td>{repo.stars.toLocaleString()}</td>\n </tr>\n {/if}\n {/each}\n </tbody>\n</table>\n```\n\n## See also\n\n- [Build a table from scratch](./build-a-table.md)\n- [Server-side data (load on demand)](./server-side.md)\n- [Controlled state](./controlled-state.md)\n- [Grouping & aggregation](../grouping-aggregation.md)\n"
|
|
4632
|
+
"markdown": "# Row models\n\nThe engine turns your raw `data` into the rows you render by running it through\na **pipeline** of row models. You opt into the steps you need; everything else\nis tree-shaken out.\n\n```\ndata\n └─▶ coreRowModel wrap each item as a Row\n └─▶ filteredRowModel drop rows that fail the column filters\n └─▶ sortedRowModel order by the sort state\n └─▶ groupedRowModel build group parent rows + aggregates\n └─▶ expandedRowModel flatten only expanded groups\n └─▶ paginatedRowModel slice to the current page\n```\n\nEach step is lazy and reads from the [controlled state](./controlled-state.md)\n(`sorting`, `columnFilters`, `grouping`, `expanded`, `pagination`).\n\nFlip the group-by control below and watch the pipeline change shape - the same\nengine goes from a flat list to `grouped -> expanded` with per-group `sum`\naggregates, and the markup stays a plain hand-styled `<table>`:\n\n<div data-docs-demo=\"190-headless-row-models\" data-height=\"440\"></div>\n\n## Opt in per step\n\nPass only the models you want into `_rowModels`. `createCoreRowModel` is always\nrequired (it's the entry point); the rest are optional.\n\n```ts\nimport {\n createSvGrid,\n createCoreRowModel,\n createFilteredRowModel,\n createSortedRowModel,\n createPaginatedRowModel,\n createGroupedRowModel,\n createExpandedRowModel,\n tableFeatures,\n rowPaginationFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n} from '@svgrid/grid/core'\n\nconst table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Row>(),\n filteredRowModel: createFilteredRowModel<Row>(),\n sortedRowModel: createSortedRowModel<Row>(),\n paginatedRowModel: createPaginatedRowModel<Row>(),\n },\n data,\n columns,\n state,\n // ...change handlers\n})\n\nconst rows = table.getRowModel().rows // filtered → sorted → paged\n```\n\nSkip `paginatedRowModel` and `getRowModel().rows` returns every matching row.\nSkip `filteredRowModel` and the `columnFilters` state is simply ignored.\n\n## Pagination\n\nAdd `createPaginatedRowModel` and drive it with `pagination` state:\n\n```ts\nlet pagination = $state({ pageIndex: 0, pageSize: 20 })\n\nconst features = tableFeatures({ rowPaginationFeature })\n\nconst table = $derived.by(() => createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Row>(),\n paginatedRowModel: createPaginatedRowModel<Row>(),\n },\n data, columns,\n state: { pagination },\n onPaginationChange: (u) =>\n (pagination = typeof u === 'function' ? u(pagination) : u),\n}))\n\n// getRowModel().rows is now just the current page\nfunction nextPage() { pagination = { ...pagination, pageIndex: pagination.pageIndex + 1 } }\n```\n\n## Grouping + aggregation\n\n`groupedRowModel` builds parent rows with aggregated values; `expandedRowModel`\nthen emits only the expanded children. A grouped row's `getIsGrouped()` is true\nand its aggregates come from each column's `aggregate` setting.\n\n```ts\nlet grouping = $state<string[]>(['lang'])\nlet expanded = $state<Record<string, boolean>>({})\n\nconst features = tableFeatures({ columnGroupingFeature, rowExpandingFeature })\n\nconst table = $derived.by(() => createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n groupedRowModel: createGroupedRowModel<Repo>(),\n expandedRowModel: createExpandedRowModel<Repo>(),\n },\n data,\n columns: [\n { field: 'lang', header: 'Lang' },\n { field: 'stars', header: 'Stars', aggregate: 'sum' }, // rolled up per group\n ],\n state: { grouping, expanded },\n onExpandedChange: (u) => (expanded = typeof u === 'function' ? u(expanded) : u),\n}))\n\n// Touch the state this component owns so the derived re-runs:\n// the engine's store is framework-free and not a rune.\nconst rows = $derived.by(() => {\n grouping\n expanded\n return table.getRowModel().rows\n})\n// each row: row.getIsGrouped(), row.getIsExpanded(), row.toggleExpanded()\n```\n\n## Server-side: skip the local pipeline\n\nWhen the server does the sorting / filtering / paging, feed the engine only the\npage it returned and leave those models out - the engine just wraps and renders\nwhat you give it:\n\n```ts\nconst table = createSvGrid({\n _features: tableFeatures({}), // no features - the server did the work\n _rowModels: { coreRowModel: createCoreRowModel<Row>() }, // core only\n data: serverPage.rows, // already sorted/filtered/paged by the API\n columns,\n state,\n})\n```\n\nTrack the sort/filter state via the change handlers and re-fetch on change - see\n[Server-side data (load on demand)](./server-side.md) for a complete, runnable\nexample with paging, sorting, filtering and a race-safe fetch.\n\n\n## Pagination, running\n\n`paginatedRowModel` is the last stage, so `getRowModel().rows` is already the\ncurrent page - there is no slicing left for you to do. Page state is yours, which\nis why the buttons below just assign to it.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createPaginatedRowModel,\n tableFeatures,\n rowPaginationFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n { name: 'tinygo', lang: 'Go', stars: 15000 },\n { name: 'bun', lang: 'Zig', stars: 74000 },\n { name: 'zig', lang: 'Zig', stars: 35000 },\n ]\n\n const features = tableFeatures({ rowPaginationFeature })\n\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'lang', header: 'Language' },\n { field: 'stars', header: 'Stars' },\n ]\n\n let pagination = $state({ pageIndex: 0, pageSize: 3 })\n\n const table = $derived.by(() =>\n createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n paginatedRowModel: createPaginatedRowModel<Repo>(),\n },\n data,\n columns,\n state: { pagination },\n onPaginationChange: (u) => (pagination = typeof u === 'function' ? u(pagination) : u),\n }),\n )\n\n // Touch the state this component owns so the derived re-runs:\n // the engine's store is framework-free and not a rune.\n const rows = $derived.by(() => {\n pagination\n return table.getRowModel().rows\n })\n const pages = $derived(Math.ceil(data.length / pagination.pageSize))\n\n function go(delta: number) {\n const next = Math.min(pages - 1, Math.max(0, pagination.pageIndex + delta))\n pagination = { ...pagination, pageIndex: next }\n }\n</script>\n\n<div>\n <button type=\"button\" onclick={() => go(-1)} disabled={pagination.pageIndex === 0}>Prev</button>\n <span>Page {pagination.pageIndex + 1} of {pages}</span>\n <button type=\"button\" onclick={() => go(1)} disabled={pagination.pageIndex >= pages - 1}>Next</button>\n</div>\n\n<ul>\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <li>{repo.name} - {repo.lang} - {repo.stars.toLocaleString()}</li>\n {/each}\n</ul>\n```\n\n## Grouping, running\n\n`groupedRowModel` inserts parent rows; `expandedRowModel` decides which\nchildren survive. A parent is the row where `getCanExpand()` is true, and its\naggregated cells come from the column's `aggregate` setting - so a group row and\na leaf row are two different shapes in the same list, and your markup has to\nbranch on it.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createGroupedRowModel,\n createExpandedRowModel,\n tableFeatures,\n columnGroupingFeature,\n rowExpandingFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n { name: 'tinygo', lang: 'Go', stars: 15000 },\n { name: 'bun', lang: 'Zig', stars: 74000 },\n { name: 'zig', lang: 'Zig', stars: 35000 },\n ]\n\n const features = tableFeatures({ columnGroupingFeature, rowExpandingFeature })\n\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'lang', header: 'Language' },\n { field: 'stars', header: 'Stars', aggregate: 'sum' },\n ]\n\n let grouping = $state<string[]>(['lang'])\n let expanded = $state<Record<string, boolean>>({})\n\n const table = $derived.by(() =>\n createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n groupedRowModel: createGroupedRowModel<Repo>(),\n expandedRowModel: createExpandedRowModel<Repo>(),\n },\n data,\n columns,\n state: { grouping, expanded },\n onExpandedChange: (u) => (expanded = typeof u === 'function' ? u(expanded) : u),\n }),\n )\n\n // Touch the state this component owns so the derived re-runs:\n // the engine's store is framework-free and not a rune.\n const rows = $derived.by(() => {\n grouping\n expanded\n return table.getRowModel().rows\n })\n\n // Feature-conditional members: present because rowExpandingFeature is on.\n const isGroup = (r: any) => typeof r.getCanExpand === 'function' && r.getCanExpand()\n</script>\n\n<table>\n <tbody>\n {#each rows as r (r.id)}\n {#if isGroup(r)}\n <tr onclick={() => (r as any).toggleExpanded?.()} style=\"cursor: pointer; font-weight: 600;\">\n <td colspan=\"2\">\n {(r as any).getIsExpanded?.() ? '- ' : '+ '}{(r as any).getCellValueByColumnId('lang')}\n </td>\n <td>{Number((r as any).getCellValueByColumnId('stars') ?? 0).toLocaleString()}</td>\n </tr>\n {:else}\n {@const repo = r.original as Repo}\n <tr>\n <td style=\"padding-left: 18px;\">{repo.name}</td>\n <td>{repo.lang}</td>\n <td>{repo.stars.toLocaleString()}</td>\n </tr>\n {/if}\n {/each}\n </tbody>\n</table>\n```\n\n## See also\n\n- [Build a table from scratch](./build-a-table.md)\n- [Server-side data (load on demand)](./server-side.md)\n- [Controlled state](./controlled-state.md)\n- [Grouping & aggregation](../grouping-aggregation.md)\n"
|
|
4609
4633
|
},
|
|
4610
4634
|
{
|
|
4611
4635
|
"slug": "help/headless/server-side",
|
|
4612
4636
|
"path": "docs/help/headless/server-side.md",
|
|
4613
4637
|
"title": "Server-side data (load on demand)",
|
|
4614
4638
|
"section": "Headless",
|
|
4615
|
-
"markdown": "# Server-side data (load on demand)\n\nWhen the rows live behind an API and there are too many to ship to the browser,\nthe server does the work: **sorting**, **filtering**, and **paging** become query\nparams, and it returns only the current page plus a total count. The headless\nengine wraps that page and nothing else - it never sees the rest of the dataset.\n\nTry it: search, click a header to sort, page through. Each change fires exactly\none request (watch the counter climb):\n\n<div data-docs-demo=\"191-headless-server-side\" data-height=\"520\"></div>\n\n## The shape of it\n\nThere is no local pipeline here - just `coreRowModel`. You own three things:\n\n1. **The query state** (`pageIndex`, `pageSize`, `sort`, `desc`, `q`) - the\n inputs your API takes.\n2. **A fetch that re-runs when that state changes** - an `$effect` is enough.\n3. **The engine, wrapping only the page you got back.**\n\n```svelte\n<script lang=\"ts\">\n import { createSvGrid, createCoreRowModel, tableFeatures, type ColumnDef } from '@svgrid/grid/core'\n\n type Row = { id: number; name: string; dept: string; salary: number }\n\n // 1. Query state - the inputs your endpoint takes.\n const pageSize = 8\n let pageIndex = $state(0)\n let sort = $state<'name' | 'dept' | 'salary'>('name')\n let desc = $state(false)\n let q = $state('')\n\n // Server response.\n let pageRows = $state<Row[]>([])\n let total = $state(0)\n let loading = $state(false)\n let reqSeq = 0\n\n // 2. Re-fetch whenever any query input changes. The sequence guard drops\n // stale responses if a newer request resolves first (race-safe).\n $effect(() => {\n const query = { pageIndex, pageSize, sort, desc, q }\n const mine = ++reqSeq\n loading = true\n fetch(`/api/employees?${new URLSearchParams(query as never)}`)\n .then((r) => r.json())\n .then((res: { rows: Row[]; total: number }) => {\n if (mine !== reqSeq) return\n pageRows = res.rows\n total = res.total\n loading = false\n })\n })\n\n // 3. The engine wraps ONLY the returned page - no filtered/sorted/paginated\n // row model, because the server already did all of that.\n const features = tableFeatures({}) // the server sorts, filters and pages\n const columns: ColumnDef<typeof features, Row>[] = [\n { field: 'name', header: 'Name' },\n { field: 'dept', header: 'Department' },\n { field: 'salary', header: 'Salary' },\n ]\n const table = $derived.by(() =>\n createSvGrid({\n _features: features,\n _rowModels: { coreRowModel: createCoreRowModel<Row>() },\n data: pageRows,\n columns,\n }),\n )\n const rows = $derived(table.getRowModel().rows)\n const pageCount = $derived(Math.max(1, Math.ceil(total / pageSize)))\n</script>\n```\n\nRender `rows` however you like, and drive the query state from your controls:\n\n```svelte\n<input value={q} oninput={(e) => { q = e.currentTarget.value; pageIndex = 0 }} />\n\n<table>\n <thead>\n <tr>\n {#each columns as col (col.field)}\n <th onclick={() => {\n if (sort === col.field) desc = !desc\n else { sort = col.field; desc = false }\n pageIndex = 0\n }}>\n {col.header}{sort === col.field ? (desc ? ' ▼' : ' ▲') : ''}\n </th>\n {/each}\n </tr>\n </thead>\n <tbody>\n {#each rows as r (r.id)}\n {@const row = r.original as Row}\n <tr><td>{row.name}</td><td>{row.dept}</td><td>{row.salary}</td></tr>\n {/each}\n </tbody>\n</table>\n\n<button onclick={() => (pageIndex -= 1)} disabled={pageIndex === 0 || loading}>Prev</button>\nPage {pageIndex + 1} of {pageCount}\n<button onclick={() => (pageIndex += 1)} disabled={pageIndex >= pageCount - 1 || loading}>Next</button>\n```\n\n## Why only `coreRowModel`?\n\nBecause the pipeline already ran - on the server. Adding\n`filteredRowModel` / `sortedRowModel` / `paginatedRowModel` would make the engine\ntry to filter, sort and slice the **page** you already fetched, which is wrong: it\nonly has 8 of the 483 rows. Feed the engine the finished page and let it do the\none job left - wrap each item as a `Row` so your markup and any cell logic work\nthe same as everywhere else.\n\n## Three things worth getting right\n\n- **Reset the page on a new sort or filter.** A different sort or query produces a\n different result set, so `pageIndex = 0` - otherwise you can land on an\n out-of-range page.\n- **Guard against out-of-order responses.** Fast typing fires overlapping\n requests; without the `reqSeq` check an early response can overwrite a later\n one. Increment a sequence number per request and ignore any response that is\n not the latest.\n- **Debounce the search** if each keystroke hits a real network. A short\n `setTimeout` on `q` keeps you from firing a request per character.\n\n## More examples\n\n### Server-side rendering\n\nSvelteKit-style SSR with a sandboxed pre-hydration snapshot.\n\n<div data-docs-demo=\"19-ssr\" data-height=\"460\"></div>\n\n## Sort, filter and page on the server\n\nThe three `external*` flags say \"report, do not apply\". The grid then renders\nexactly the rows you hand it, and `rowCount` is what lets the pager show a total\nit cannot count itself.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Order = { id: string; customer: string; total: number }\n\n // Stands in for the database.\n const ALL: Order[] = Array.from({ length: 87 }, (_, i) => ({\n id: 'A-' + String(1000 + i),\n customer: ['Northwind', 'Contoso', 'Fabrikam'][i % 3]!,\n total: 60 + ((i * 53) % 800),\n }))\n\n const columns: GridColumns<Order> = [\n { field: 'id', header: 'Ref', width: 110 },\n { field: 'customer', header: 'Customer', width: 170 },\n { field: 'total', header: 'Total', width: 130,\n format: { type: 'currency', currency: 'USD' } },\n ]\n\n let pageIndex = $state(0)\n let pageSize = $state(10)\n let sorting = $state<Array<{ id: string; desc: boolean }>>([])\n\n // What a real endpoint would do, minus the network.\n const view = $derived.by(() => {\n let rows = [...ALL]\n const s = sorting[0]\n if (s) {\n rows.sort((a, b) => {\n const av = a[s.id as keyof Order], bv = b[s.id as keyof Order]\n const cmp = av < bv ? -1 : av > bv ? 1 : 0\n return s.desc ? -cmp : cmp\n })\n }\n return rows.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize)\n })\n</script>\n\n<SvGrid\n data={view}\n {columns}\n getRowId={(r) => r.id}\n sortable\n externalSort\n onSortingChange={(next) => { sorting = next; pageIndex = 0 }}\n pageable\n externalPagination\n rowCount={ALL.length}\n {pageIndex}\n {pageSize}\n onPaginationChange={(p) => { pageIndex = p.pageIndex; pageSize = p.pageSize }}\n/>\n\n<p>Serving rows {pageIndex * pageSize + 1}-{Math.min((pageIndex + 1) * pageSize, ALL.length)} of {ALL.length}</p>\n```\n\n## See also\n\n- [Row models](./row-models.md) - the local pipeline, for when the client owns the data\n- [Controlled state](./controlled-state.md) - `state` in, `onXxxChange` out\n- [Build a table from scratch](./build-a-table.md) - the rendering half\n- [Server-side data (with `<SvGrid>`)](../server-side-data.md) - the same idea, wired for you\n"
|
|
4639
|
+
"markdown": "# Server-side data (load on demand)\n\nWhen the rows live behind an API and there are too many to ship to the browser,\nthe server does the work: **sorting**, **filtering**, and **paging** become query\nparams, and it returns only the current page plus a total count. The headless\nengine wraps that page and nothing else - it never sees the rest of the dataset.\n\nTry it: search, click a header to sort, page through. Each change fires exactly\none request (watch the counter climb):\n\n<div data-docs-demo=\"191-headless-server-side\" data-height=\"520\"></div>\n\n## The shape of it\n\nThere is no local pipeline here - just `coreRowModel`. You own three things:\n\n1. **The query state** (`pageIndex`, `pageSize`, `sort`, `desc`, `q`) - the\n inputs your API takes.\n2. **A fetch that re-runs when that state changes** - an `$effect` is enough.\n3. **The engine, wrapping only the page you got back.**\n\n```svelte\n<script lang=\"ts\">\n import { createSvGrid, createCoreRowModel, tableFeatures, type ColumnDef } from '@svgrid/grid/core'\n\n type Row = { id: number; name: string; dept: string; salary: number }\n\n // 1. Query state - the inputs your endpoint takes.\n const pageSize = 8\n let pageIndex = $state(0)\n let sort = $state<'name' | 'dept' | 'salary'>('name')\n let desc = $state(false)\n let q = $state('')\n\n // Server response.\n let pageRows = $state<Row[]>([])\n let total = $state(0)\n let loading = $state(false)\n let reqSeq = 0\n\n // 2. Re-fetch whenever any query input changes. The sequence guard drops\n // stale responses if a newer request resolves first (race-safe).\n $effect(() => {\n const query = { pageIndex, pageSize, sort, desc, q }\n const mine = ++reqSeq\n loading = true\n fetch(`/api/employees?${new URLSearchParams(query as never)}`)\n .then((r) => r.json())\n .then((res: { rows: Row[]; total: number }) => {\n if (mine !== reqSeq) return\n pageRows = res.rows\n total = res.total\n loading = false\n })\n })\n\n // 3. The engine wraps ONLY the returned page - no filtered/sorted/paginated\n // row model, because the server already did all of that.\n const features = tableFeatures({}) // the server sorts, filters and pages\n const columns: ColumnDef<typeof features, Row>[] = [\n { field: 'name', header: 'Name' },\n { field: 'dept', header: 'Department' },\n { field: 'salary', header: 'Salary' },\n ]\n const table = $derived.by(() =>\n createSvGrid({\n _features: features,\n _rowModels: { coreRowModel: createCoreRowModel<Row>() },\n data: pageRows,\n columns,\n }),\n )\n // Touch the state this component owns so the derived re-runs:\n // the engine's store is framework-free and not a rune.\n const rows = $derived.by(() => {\n pageIndex\n sort\n desc\n q\n pageRows\n total\n loading\n return table.getRowModel().rows\n })\n const pageCount = $derived(Math.max(1, Math.ceil(total / pageSize)))\n</script>\n```\n\nRender `rows` however you like, and drive the query state from your controls:\n\n```svelte\n<input value={q} oninput={(e) => { q = e.currentTarget.value; pageIndex = 0 }} />\n\n<table>\n <thead>\n <tr>\n {#each columns as col (col.field)}\n <th onclick={() => {\n if (sort === col.field) desc = !desc\n else { sort = col.field; desc = false }\n pageIndex = 0\n }}>\n {col.header}{sort === col.field ? (desc ? ' ▼' : ' ▲') : ''}\n </th>\n {/each}\n </tr>\n </thead>\n <tbody>\n {#each rows as r (r.id)}\n {@const row = r.original as Row}\n <tr><td>{row.name}</td><td>{row.dept}</td><td>{row.salary}</td></tr>\n {/each}\n </tbody>\n</table>\n\n<button onclick={() => (pageIndex -= 1)} disabled={pageIndex === 0 || loading}>Prev</button>\nPage {pageIndex + 1} of {pageCount}\n<button onclick={() => (pageIndex += 1)} disabled={pageIndex >= pageCount - 1 || loading}>Next</button>\n```\n\n## Why only `coreRowModel`?\n\nBecause the pipeline already ran - on the server. Adding\n`filteredRowModel` / `sortedRowModel` / `paginatedRowModel` would make the engine\ntry to filter, sort and slice the **page** you already fetched, which is wrong: it\nonly has 8 of the 483 rows. Feed the engine the finished page and let it do the\none job left - wrap each item as a `Row` so your markup and any cell logic work\nthe same as everywhere else.\n\n## Three things worth getting right\n\n- **Reset the page on a new sort or filter.** A different sort or query produces a\n different result set, so `pageIndex = 0` - otherwise you can land on an\n out-of-range page.\n- **Guard against out-of-order responses.** Fast typing fires overlapping\n requests; without the `reqSeq` check an early response can overwrite a later\n one. Increment a sequence number per request and ignore any response that is\n not the latest.\n- **Debounce the search** if each keystroke hits a real network. A short\n `setTimeout` on `q` keeps you from firing a request per character.\n\n## More examples\n\n### Server-side rendering\n\nSvelteKit-style SSR with a sandboxed pre-hydration snapshot.\n\n<div data-docs-demo=\"19-ssr\" data-height=\"460\"></div>\n\n## Sort, filter and page on the server\n\nThe three `external*` flags say \"report, do not apply\". The grid then renders\nexactly the rows you hand it, and `rowCount` is what lets the pager show a total\nit cannot count itself.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Order = { id: string; customer: string; total: number }\n\n // Stands in for the database.\n const ALL: Order[] = Array.from({ length: 87 }, (_, i) => ({\n id: 'A-' + String(1000 + i),\n customer: ['Northwind', 'Contoso', 'Fabrikam'][i % 3]!,\n total: 60 + ((i * 53) % 800),\n }))\n\n const columns: GridColumns<Order> = [\n { field: 'id', header: 'Ref', width: 110 },\n { field: 'customer', header: 'Customer', width: 170 },\n { field: 'total', header: 'Total', width: 130,\n format: { type: 'currency', currency: 'USD' } },\n ]\n\n let pageIndex = $state(0)\n let pageSize = $state(10)\n let sorting = $state<Array<{ id: string; desc: boolean }>>([])\n\n // What a real endpoint would do, minus the network.\n const view = $derived.by(() => {\n let rows = [...ALL]\n const s = sorting[0]\n if (s) {\n rows.sort((a, b) => {\n const av = a[s.id as keyof Order], bv = b[s.id as keyof Order]\n const cmp = av < bv ? -1 : av > bv ? 1 : 0\n return s.desc ? -cmp : cmp\n })\n }\n return rows.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize)\n })\n</script>\n\n<SvGrid\n data={view}\n {columns}\n getRowId={(r) => r.id}\n sortable\n externalSort\n onSortingChange={(next) => { sorting = next; pageIndex = 0 }}\n pageable\n externalPagination\n rowCount={ALL.length}\n {pageIndex}\n {pageSize}\n onPaginationChange={(p) => { pageIndex = p.pageIndex; pageSize = p.pageSize }}\n/>\n\n<p>Serving rows {pageIndex * pageSize + 1}-{Math.min((pageIndex + 1) * pageSize, ALL.length)} of {ALL.length}</p>\n```\n\n## See also\n\n- [Row models](./row-models.md) - the local pipeline, for when the client owns the data\n- [Controlled state](./controlled-state.md) - `state` in, `onXxxChange` out\n- [Build a table from scratch](./build-a-table.md) - the rendering half\n- [Server-side data (with `<SvGrid>`)](../server-side-data.md) - the same idea, wired for you\n"
|
|
4616
4640
|
},
|
|
4617
4641
|
{
|
|
4618
4642
|
"slug": "help/headless/styling",
|
|
4619
4643
|
"path": "docs/help/headless/styling.md",
|
|
4620
4644
|
"title": "Styling a headless table",
|
|
4621
4645
|
"section": "Headless",
|
|
4622
|
-
"markdown": "# Styling a headless table\n\nWith the headless engine you render your own markup, so **you own every pixel of\nstyling** - there is no grid stylesheet to fight. The same `createSvGrid` engine\ncan drive a minimal underlined table, a fully bordered grid, or floating row\ncards. Flip the controls in the demo; the engine and rows are identical, only\nthe CSS changes.\n\n<div data-docs-demo=\"188-headless-styled\" data-height=\"540\"></div>\n\n## The mindset\n\nThe engine hands you `getHeaderGroups()` and `getRowModel().rows`; how you turn\nthem into DOM - `<table>`, CSS grid, flexbox, divs - and how you style them is\nentirely yours. Nothing below is prescribed; it's just what the demo does.\n\n## Track the site theme with `--sg-*` tokens\n\nThe grid's theming is a set of CSS custom properties (`--sg-bg`, `--sg-fg`,\n`--sg-border`, `--sg-header-bg`, `--sg-muted`, `--sg-accent`, …). Use the same\ntokens in your headless CSS and your table automatically follows the app's\nlight / dark theme and any per-instance overrides - no extra work.\n\n```css\n.my-table { color: var(--sg-fg, #0f172a); font-size: 13px; }\n.my-table th {\n background: var(--sg-header-bg, #f1f5f9);\n border-bottom: 2px solid var(--sg-border, #e2e8f0);\n}\n.my-table td { border-bottom: 1px solid var(--sg-border, #eef2f7); }\n```\n\nAlways keep a literal fallback (`var(--sg-fg, #0f172a)`) so the table is styled\neven outside a themed container. See [Tailwind & theming](../tailwind.md) for the\nfull token list.\n\n## The usual affordances\n\nThese are one CSS rule each - the engine doesn't need to know about them.\n\n```css\n/* Zebra striping */\n.my-table tbody tr:nth-child(even) td {\n background: color-mix(in oklab, var(--sg-muted) 8%, transparent);\n}\n/* Hover */\n.my-table tbody tr:hover td {\n background: color-mix(in oklab, var(--sg-accent, #6366f1) 8%, transparent);\n}\n/* Sticky header inside a scroll container */\n.my-table thead th { position: sticky; top: 0; z-index: 1; }\n/* Right-align numbers */\n.my-table td.num { text-align: right; font-variant-numeric: tabular-nums; }\n/* Density: swap a class */\n.dense th, .dense td { padding: 5px 10px; }\n```\n\n## Sort affordance\n\nSorting is engine state, but the **indicator** is your markup - render it off the\n`sorting` state you already control:\n\n```svelte\n<th onclick={() => toggleSort(h.column.id)}>\n {h.column.columnDef.header}\n {#if sorting[0]?.id === h.column.id}\n <span class=\"ind\">{sorting[0].desc ? '▼' : '▲'}</span>\n {/if}\n</th>\n```\n\n## Prefer Tailwind? Use classes instead of CSS\n\nBecause it's your markup, utility classes work exactly as they would on any\n`<table>` - no wrapper, no `:global`:\n\n```svelte\n<table class=\"w-full text-sm\">\n <thead>\n <tr class=\"border-b-2 border-slate-200\">\n {#each headers as h}\n <th class=\"px-3 py-2 text-left font-semibold cursor-pointer hover:text-indigo-500\">\n {h.column.columnDef.header}\n </th>\n {/each}\n </tr>\n </thead>\n <tbody>\n {#each rows as r}\n <tr class=\"border-b border-slate-100 even:bg-slate-50 hover:bg-indigo-50\">\n <!-- cells -->\n </tr>\n {/each}\n </tbody>\n</table>\n```\n\n\n## A styled table, end to end\n\nEverything above, applied at once: tokens with literal fallbacks, zebra\nstriping, a sticky header, right-aligned numerals. The engine below is the same\nfive lines as the unstyled version.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n { name: 'tinygo', lang: 'Go', stars: 15000 },\n { name: 'bun', lang: 'Zig', stars: 74000 },\n { name: 'zig', lang: 'Zig', stars: 35000 },\n ]\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'lang', header: 'Language' },\n { field: 'stars', header: 'Stars' },\n ]\n\n let sorting = $state([{ id: 'stars', desc: true }])\n\n const table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n sortedRowModel: createSortedRowModel<Repo>(),\n },\n data,\n columns,\n state: { sorting },\n onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),\n })\n\n const rows = $derived(table.getRowModel().rows)\n</script>\n\n<div class=\"scroller\">\n <table class=\"my-table\">\n <thead>\n {#each table.getHeaderGroups() as hg (hg.id)}\n <tr>\n {#each hg.headers as h (h.id)}\n <th\n class:num={h.column.id === 'stars'}\n onclick={h.column.getToggleSortingHandler()}\n >\n {h.column.columnDef.header}\n {#if sorting[0]?.id === h.column.id}\n <span class=\"ind\">{sorting[0].desc ? 'v' : '^'}</span>\n {/if}\n </th>\n {/each}\n </tr>\n {/each}\n </thead>\n <tbody>\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <tr>\n <td>{repo.name}</td>\n <td>{repo.lang}</td>\n <td class=\"num\">{repo.stars.toLocaleString()}</td>\n </tr>\n {/each}\n </tbody>\n </table>\n</div>\n\n<style>\n .scroller { max-height: 220px; overflow: auto; border: 1px solid var(--sg-border, #e2e8f0); border-radius: 8px; }\n .my-table { width: 100%; border-collapse: collapse; color: var(--sg-fg, #0f172a); font-size: 13px; }\n .my-table th {\n position: sticky; top: 0; z-index: 1; text-align: left; cursor: pointer;\n padding: 8px 12px; background: var(--sg-header-bg, #f1f5f9);\n border-bottom: 2px solid var(--sg-border, #e2e8f0);\n }\n .my-table td { padding: 7px 12px; border-bottom: 1px solid var(--sg-border, #eef2f7); }\n .my-table tbody tr:nth-child(even) td { background: color-mix(in oklab, var(--sg-muted, #64748b) 8%, transparent); }\n .my-table tbody tr:hover td { background: color-mix(in oklab, var(--sg-accent, #6366f1) 8%, transparent); }\n .num { text-align: right; font-variant-numeric: tabular-nums; }\n .ind { font-size: 10px; opacity: 0.65; }\n</style>\n```\n\n## Same engine, no table at all\n\nThe strongest argument for headless is that the row model does not care what\nyou render. Identical `createSvGrid` call, identical rows - cards instead of a\n`<table>`. A grid component cannot do this without a card mode; here it is just\ndifferent markup.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n { name: 'tinygo', lang: 'Go', stars: 15000 },\n { name: 'bun', lang: 'Zig', stars: 74000 },\n { name: 'zig', lang: 'Zig', stars: 35000 },\n ]\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'stars', header: 'Stars' },\n ]\n\n let sorting = $state([{ id: 'stars', desc: true }])\n\n const table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n sortedRowModel: createSortedRowModel<Repo>(),\n },\n data,\n columns,\n state: { sorting },\n onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),\n })\n\n const rows = $derived(table.getRowModel().rows)\n</script>\n\n<button type=\"button\" onclick={() => (sorting = [{ id: 'name', desc: false }])}>\n Sort by name\n</button>\n\n<div class=\"cards\">\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <article class=\"card\">\n <h4>{repo.name}</h4>\n <p class=\"lang\">{repo.lang}</p>\n <p class=\"stars\">{repo.stars.toLocaleString()} stars</p>\n </article>\n {/each}\n</div>\n\n<style>\n .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; margin-top: 10px; }\n .card {\n border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px; padding: 10px 12px;\n background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);\n }\n .card h4 { margin: 0 0 4px; font-size: 13px; }\n .lang { margin: 0; font-size: 11px; color: var(--sg-muted, #64748b); }\n .stars { margin: 6px 0 0; font-size: 12px; font-variant-numeric: tabular-nums; }\n</style>\n```\n\n## Your design system's table primitives\n\nIf your team already has `Table` components - shadcn-svelte's `Table.Root` /\n`Table.Row` / `Table.Cell`, or your own - the engine slots under them\nunchanged. The header loop and the row loop are the same as above; only the\nelements differ. This is the layout the\n[shadcn data-table migration](../migrating-from-shadcn-data-table.md) lands\non when it keeps the shadcn markup and swaps the engine.\n\n```svelte\n<script lang=\"ts\">\n import * as Table from '$lib/components/ui/table'\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Payment = { id: string; status: string; email: string; amount: number }\n let { payments }: { payments: Payment[] } = $props()\n\n const features = tableFeatures({ rowSortingFeature })\n const columns: ColumnDef<typeof features, Payment>[] = [\n { field: 'status', header: 'Status' },\n { field: 'email', header: 'Email' },\n { field: 'amount', header: 'Amount', editorType: 'number' },\n ]\n\n let sorting = $state([{ id: 'amount', desc: true }])\n const table = createSvGrid({\n _features: features,\n _rowModels: { coreRowModel: createCoreRowModel<Payment>(), sortedRowModel: createSortedRowModel<Payment>() },\n data: payments,\n columns,\n state: { sorting },\n onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),\n })\n const rows = $derived(table.getRowModel().rows)\n</script>\n\n<Table.Root>\n <Table.Header>\n {#each table.getHeaderGroups() as hg (hg.id)}\n <Table.Row>\n {#each hg.headers as h (h.id)}\n <Table.Head onclick={h.column.getToggleSortingHandler()}>\n {h.column.columnDef.header}\n </Table.Head>\n {/each}\n </Table.Row>\n {/each}\n </Table.Header>\n <Table.Body>\n {#each rows as r (r.id)}\n <Table.Row>\n <Table.Cell>{r.original.status}</Table.Cell>\n <Table.Cell>{r.original.email}</Table.Cell>\n <Table.Cell class=\"text-right\">{r.original.amount.toLocaleString()}</Table.Cell>\n </Table.Row>\n {/each}\n </Table.Body>\n</Table.Root>\n```\n\nNothing from `@svgrid/grid` reaches the DOM here: no stylesheet, no class\nnames, no `--sg-*` variable unless you choose to read one. The design system\nowns the markup and the engine owns the state, which is the split a\ndesign-system team usually wants.\n\n## Keyboard navigation and ARIA\n\nHeadless does not mean you lose the grid semantics; it means you apply them.\nThe engine keeps an `activeCell` in its state and moves it with\n`moveActiveCell({ rowDelta, colDelta })`, and `@svgrid/grid/core` exports the\nWAI-ARIA attribute factories the render component uses, as plain objects to\nspread onto your own elements. Wire the four of them and arrow keys, `Home`,\n`End`, `aria-sort` and `aria-activedescendant` all work on a bare `<table>`.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n getGridRootA11yProps,\n getGridHeaderA11yProps,\n getGridRowA11yProps,\n getGridCellA11yProps,\n getGridCellDomId,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n { name: 'bun', lang: 'Zig', stars: 74000 },\n ]\n\n const features = tableFeatures({ rowSortingFeature })\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'lang', header: 'Language' },\n { field: 'stars', header: 'Stars', editorType: 'number' },\n ]\n\n let sorting = $state([{ id: 'stars', desc: true }])\n\n // The selector picks the slices the template reads; `table.state` is the\n // reactive view of them.\n const table = createSvGrid(\n {\n _features: features,\n _rowModels: { coreRowModel: createCoreRowModel<Repo>(), sortedRowModel: createSortedRowModel<Repo>() },\n data,\n columns,\n state: { sorting },\n onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),\n },\n (s) => ({ activeCell: s.activeCell as { rowIndex: number; colIndex: number } }),\n )\n\n const rows = $derived(table.getRowModel().rows)\n const active = $derived(table.state.activeCell)\n const GRID_ID = 'repos'\n const activeId = $derived(getGridCellDomId(GRID_ID, active.rowIndex, active.colIndex))\n\n function onKeydown(e: KeyboardEvent) {\n const moves: Record<string, { rowDelta?: number; colDelta?: number }> = {\n ArrowDown: { rowDelta: 1 }, ArrowUp: { rowDelta: -1 },\n ArrowRight: { colDelta: 1 }, ArrowLeft: { colDelta: -1 },\n Home: { colDelta: -columns.length }, End: { colDelta: columns.length },\n PageDown: { rowDelta: rows.length }, PageUp: { rowDelta: -rows.length },\n }\n const move = moves[e.key]\n if (!move) return\n e.preventDefault()\n table.moveActiveCell(move)\n }\n\n function sortDirection(id: string) {\n const s = sorting[0]\n return s?.id !== id ? 'none' : s.desc ? 'descending' : 'ascending'\n }\n</script>\n\n<table\n class=\"kb\"\n {...getGridRootA11yProps({ activeDescendantId: activeId, rowCount: rows.length, colCount: columns.length })}\n onkeydown={onKeydown}\n>\n <thead>\n {#each table.getHeaderGroups() as hg (hg.id)}\n <tr {...getGridRowA11yProps(1)}>\n {#each hg.headers as h (h.id)}\n <th\n {...getGridHeaderA11yProps({ sortable: true, sortDirection: sortDirection(h.column.id) })}\n onclick={h.column.getToggleSortingHandler()}\n >\n {h.column.columnDef.header}\n </th>\n {/each}\n </tr>\n {/each}\n </thead>\n <tbody>\n {#each rows as r, ri (r.id)}\n <tr {...getGridRowA11yProps(ri + 2)}>\n {#each columns as c, ci (c.field)}\n {@const selected = active.rowIndex === ri && active.colIndex === ci}\n <td\n {...getGridCellA11yProps({ rowIndex: ri + 2, colIndex: ci + 1, selected, id: getGridCellDomId(GRID_ID, ri, ci) })}\n class:active={selected}\n onclick={() => table.setActiveCell({ rowIndex: ri, colIndex: ci, cellId: `${ri}_${c.field}` })}\n >\n {String(r.original[c.field as keyof Repo])}\n </td>\n {/each}\n </tr>\n {/each}\n </tbody>\n</table>\n\n<style>\n .kb { width: 100%; border-collapse: collapse; font-size: 13px; color: var(--sg-fg, #0f172a); }\n .kb:focus { outline: 2px solid var(--sg-accent, #6366f1); outline-offset: 2px; }\n .kb th { text-align: left; padding: 8px 12px; background: var(--sg-header-bg, #f1f5f9); cursor: pointer; }\n .kb th[aria-sort='ascending']::after { content: ' ^'; }\n .kb th[aria-sort='descending']::after { content: ' v'; }\n .kb td { padding: 7px 12px; border-bottom: 1px solid var(--sg-border, #eef2f7); }\n .kb td.active { box-shadow: inset 0 0 0 2px var(--sg-accent, #6366f1); }\n</style>\n```\n\nClick a cell, then use the arrow keys. The root carries `role=\"grid\"`,\n`tabindex=\"0\"` and `aria-activedescendant`; each header carries\n`aria-sort`; each cell carries `role=\"gridcell\"`, `aria-rowindex`,\n`aria-colindex` and `aria-selected`. Screen readers announce the same grid\nthe render component would. What you still own: focus styling, `Enter` to\nedit if you add editing, and `Tab` to leave the grid (the browser handles\nthat one because the cells themselves are not focusable).\n\n## See also\n\n- [Build a table from scratch](./build-a-table.md) - the render loop\n- [Headless virtualization](./virtualization.md) - style a virtualized list\n- [Tailwind & theming tokens](../tailwind.md) - the `--sg-*` reference\n- [Migrating from the shadcn-svelte data table](../migrating-from-shadcn-data-table.md) - keep the shadcn markup, swap the engine\n- [Keyboard and accessibility](../accessibility.md) - what the render component does, for parity\n"
|
|
4646
|
+
"markdown": "# Styling a headless table\n\nWith the headless engine you render your own markup, so **you own every pixel of\nstyling** - there is no grid stylesheet to fight. The same `createSvGrid` engine\ncan drive a minimal underlined table, a fully bordered grid, or floating row\ncards. Flip the controls in the demo; the engine and rows are identical, only\nthe CSS changes.\n\n<div data-docs-demo=\"188-headless-styled\" data-height=\"540\"></div>\n\n## The mindset\n\nThe engine hands you `getHeaderGroups()` and `getRowModel().rows`; how you turn\nthem into DOM - `<table>`, CSS grid, flexbox, divs - and how you style them is\nentirely yours. Nothing below is prescribed; it's just what the demo does.\n\n## Track the site theme with `--sg-*` tokens\n\nThe grid's theming is a set of CSS custom properties (`--sg-bg`, `--sg-fg`,\n`--sg-border`, `--sg-header-bg`, `--sg-muted`, `--sg-accent`, …). Use the same\ntokens in your headless CSS and your table automatically follows the app's\nlight / dark theme and any per-instance overrides - no extra work.\n\n```css\n.my-table { color: var(--sg-fg, #0f172a); font-size: 13px; }\n.my-table th {\n background: var(--sg-header-bg, #f1f5f9);\n border-bottom: 2px solid var(--sg-border, #e2e8f0);\n}\n.my-table td { border-bottom: 1px solid var(--sg-border, #eef2f7); }\n```\n\nAlways keep a literal fallback (`var(--sg-fg, #0f172a)`) so the table is styled\neven outside a themed container. See [Tailwind & theming](../tailwind.md) for the\nfull token list.\n\n## The usual affordances\n\nThese are one CSS rule each - the engine doesn't need to know about them.\n\n```css\n/* Zebra striping */\n.my-table tbody tr:nth-child(even) td {\n background: color-mix(in oklab, var(--sg-muted) 8%, transparent);\n}\n/* Hover */\n.my-table tbody tr:hover td {\n background: color-mix(in oklab, var(--sg-accent, #6366f1) 8%, transparent);\n}\n/* Sticky header inside a scroll container */\n.my-table thead th { position: sticky; top: 0; z-index: 1; }\n/* Right-align numbers */\n.my-table td.num { text-align: right; font-variant-numeric: tabular-nums; }\n/* Density: swap a class */\n.dense th, .dense td { padding: 5px 10px; }\n```\n\n## Sort affordance\n\nSorting is engine state, but the **indicator** is your markup - render it off the\n`sorting` state you already control:\n\n```svelte\n<th onclick={() => toggleSort(h.column.id)}>\n {h.column.columnDef.header}\n {#if sorting[0]?.id === h.column.id}\n <span class=\"ind\">{sorting[0].desc ? '▼' : '▲'}</span>\n {/if}\n</th>\n```\n\n## Prefer Tailwind? Use classes instead of CSS\n\nBecause it's your markup, utility classes work exactly as they would on any\n`<table>` - no wrapper, no `:global`:\n\n```svelte\n<table class=\"w-full text-sm\">\n <thead>\n <tr class=\"border-b-2 border-slate-200\">\n {#each headers as h}\n <th class=\"px-3 py-2 text-left font-semibold cursor-pointer hover:text-indigo-500\">\n {h.column.columnDef.header}\n </th>\n {/each}\n </tr>\n </thead>\n <tbody>\n {#each rows as r}\n <tr class=\"border-b border-slate-100 even:bg-slate-50 hover:bg-indigo-50\">\n <!-- cells -->\n </tr>\n {/each}\n </tbody>\n</table>\n```\n\n\n## A styled table, end to end\n\nEverything above, applied at once: tokens with literal fallbacks, zebra\nstriping, a sticky header, right-aligned numerals. The engine below is the same\nfive lines as the unstyled version.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n { name: 'tinygo', lang: 'Go', stars: 15000 },\n { name: 'bun', lang: 'Zig', stars: 74000 },\n { name: 'zig', lang: 'Zig', stars: 35000 },\n ]\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'lang', header: 'Language' },\n { field: 'stars', header: 'Stars' },\n ]\n\n let sorting = $state([{ id: 'stars', desc: true }])\n\n const table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n sortedRowModel: createSortedRowModel<Repo>(),\n },\n data,\n columns,\n state: { sorting },\n onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),\n })\n\n // Touch the state this component owns so the derived re-runs:\n // the engine's store is framework-free and not a rune.\n const rows = $derived.by(() => {\n sorting\n return table.getRowModel().rows\n })\n</script>\n\n<div class=\"scroller\">\n <table class=\"my-table\">\n <thead>\n {#each table.getHeaderGroups() as hg (hg.id)}\n <tr>\n {#each hg.headers as h (h.id)}\n <th\n class:num={h.column.id === 'stars'}\n onclick={h.column.getToggleSortingHandler()}\n >\n {h.column.columnDef.header}\n {#if sorting[0]?.id === h.column.id}\n <span class=\"ind\">{sorting[0].desc ? 'v' : '^'}</span>\n {/if}\n </th>\n {/each}\n </tr>\n {/each}\n </thead>\n <tbody>\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <tr>\n <td>{repo.name}</td>\n <td>{repo.lang}</td>\n <td class=\"num\">{repo.stars.toLocaleString()}</td>\n </tr>\n {/each}\n </tbody>\n </table>\n</div>\n\n<style>\n .scroller { max-height: 220px; overflow: auto; border: 1px solid var(--sg-border, #e2e8f0); border-radius: 8px; }\n .my-table { width: 100%; border-collapse: collapse; color: var(--sg-fg, #0f172a); font-size: 13px; }\n .my-table th {\n position: sticky; top: 0; z-index: 1; text-align: left; cursor: pointer;\n padding: 8px 12px; background: var(--sg-header-bg, #f1f5f9);\n border-bottom: 2px solid var(--sg-border, #e2e8f0);\n }\n .my-table td { padding: 7px 12px; border-bottom: 1px solid var(--sg-border, #eef2f7); }\n .my-table tbody tr:nth-child(even) td { background: color-mix(in oklab, var(--sg-muted, #64748b) 8%, transparent); }\n .my-table tbody tr:hover td { background: color-mix(in oklab, var(--sg-accent, #6366f1) 8%, transparent); }\n .num { text-align: right; font-variant-numeric: tabular-nums; }\n .ind { font-size: 10px; opacity: 0.65; }\n</style>\n```\n\n## Same engine, no table at all\n\nThe strongest argument for headless is that the row model does not care what\nyou render. Identical `createSvGrid` call, identical rows - cards instead of a\n`<table>`. A grid component cannot do this without a card mode; here it is just\ndifferent markup.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'rollup', lang: 'JavaScript', stars: 25000 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n { name: 'tinygo', lang: 'Go', stars: 15000 },\n { name: 'bun', lang: 'Zig', stars: 74000 },\n { name: 'zig', lang: 'Zig', stars: 35000 },\n ]\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'stars', header: 'Stars' },\n ]\n\n let sorting = $state([{ id: 'stars', desc: true }])\n\n const table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Repo>(),\n sortedRowModel: createSortedRowModel<Repo>(),\n },\n data,\n columns,\n state: { sorting },\n onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),\n })\n\n // Touch the state this component owns so the derived re-runs:\n // the engine's store is framework-free and not a rune.\n const rows = $derived.by(() => {\n sorting\n return table.getRowModel().rows\n })\n</script>\n\n<button type=\"button\" onclick={() => (sorting = [{ id: 'name', desc: false }])}>\n Sort by name\n</button>\n\n<div class=\"cards\">\n {#each rows as r (r.id)}\n {@const repo = r.original as Repo}\n <article class=\"card\">\n <h4>{repo.name}</h4>\n <p class=\"lang\">{repo.lang}</p>\n <p class=\"stars\">{repo.stars.toLocaleString()} stars</p>\n </article>\n {/each}\n</div>\n\n<style>\n .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; margin-top: 10px; }\n .card {\n border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px; padding: 10px 12px;\n background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);\n }\n .card h4 { margin: 0 0 4px; font-size: 13px; }\n .lang { margin: 0; font-size: 11px; color: var(--sg-muted, #64748b); }\n .stars { margin: 6px 0 0; font-size: 12px; font-variant-numeric: tabular-nums; }\n</style>\n```\n\n## Your design system's table primitives\n\nIf your team already has `Table` components - shadcn-svelte's `Table.Root` /\n`Table.Row` / `Table.Cell`, or your own - the engine slots under them\nunchanged. The header loop and the row loop are the same as above; only the\nelements differ. This is the layout the\n[shadcn data-table migration](../migrating-from-shadcn-data-table.md) lands\non when it keeps the shadcn markup and swaps the engine.\n\n```svelte\n<script lang=\"ts\">\n import * as Table from '$lib/components/ui/table'\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Payment = { id: string; status: string; email: string; amount: number }\n let { payments }: { payments: Payment[] } = $props()\n\n const features = tableFeatures({ rowSortingFeature })\n const columns: ColumnDef<typeof features, Payment>[] = [\n { field: 'status', header: 'Status' },\n { field: 'email', header: 'Email' },\n { field: 'amount', header: 'Amount', editorType: 'number' },\n ]\n\n let sorting = $state([{ id: 'amount', desc: true }])\n const table = createSvGrid({\n _features: features,\n _rowModels: { coreRowModel: createCoreRowModel<Payment>(), sortedRowModel: createSortedRowModel<Payment>() },\n data: payments,\n columns,\n state: { sorting },\n onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),\n })\n // Touch the state this component owns so the derived re-runs:\n // the engine's store is framework-free and not a rune.\n const rows = $derived.by(() => {\n sorting\n return table.getRowModel().rows\n })\n</script>\n\n<Table.Root>\n <Table.Header>\n {#each table.getHeaderGroups() as hg (hg.id)}\n <Table.Row>\n {#each hg.headers as h (h.id)}\n <Table.Head onclick={h.column.getToggleSortingHandler()}>\n {h.column.columnDef.header}\n </Table.Head>\n {/each}\n </Table.Row>\n {/each}\n </Table.Header>\n <Table.Body>\n {#each rows as r (r.id)}\n <Table.Row>\n <Table.Cell>{r.original.status}</Table.Cell>\n <Table.Cell>{r.original.email}</Table.Cell>\n <Table.Cell class=\"text-right\">{r.original.amount.toLocaleString()}</Table.Cell>\n </Table.Row>\n {/each}\n </Table.Body>\n</Table.Root>\n```\n\nNothing from `@svgrid/grid` reaches the DOM here: no stylesheet, no class\nnames, no `--sg-*` variable unless you choose to read one. The design system\nowns the markup and the engine owns the state, which is the split a\ndesign-system team usually wants.\n\n## Keyboard navigation and ARIA\n\nHeadless does not mean you lose the grid semantics; it means you apply them.\nThe engine keeps an `activeCell` in its state and moves it with\n`moveActiveCell({ rowDelta, colDelta })`, and `@svgrid/grid/core` exports the\nWAI-ARIA attribute factories the render component uses, as plain objects to\nspread onto your own elements. Wire the four of them and arrow keys, `Home`,\n`End`, `aria-sort` and `aria-activedescendant` all work on a bare `<table>`.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n getGridRootA11yProps,\n getGridHeaderA11yProps,\n getGridRowA11yProps,\n getGridCellA11yProps,\n getGridCellDomId,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Repo = { name: string; lang: string; stars: number }\n const data: Repo[] = [\n { name: 'svelte', lang: 'JavaScript', stars: 78000 },\n { name: 'vite', lang: 'TypeScript', stars: 68000 },\n { name: 'sv-grid', lang: 'TypeScript', stars: 172 },\n { name: 'esbuild', lang: 'Go', stars: 38000 },\n { name: 'bun', lang: 'Zig', stars: 74000 },\n ]\n\n const features = tableFeatures({ rowSortingFeature })\n const columns: ColumnDef<typeof features, Repo>[] = [\n { field: 'name', header: 'Repo' },\n { field: 'lang', header: 'Language' },\n { field: 'stars', header: 'Stars', editorType: 'number' },\n ]\n\n let sorting = $state([{ id: 'stars', desc: true }])\n\n // The selector picks the slices the template reads; `table.state` is the\n // reactive view of them.\n const table = createSvGrid(\n {\n _features: features,\n _rowModels: { coreRowModel: createCoreRowModel<Repo>(), sortedRowModel: createSortedRowModel<Repo>() },\n data,\n columns,\n state: { sorting },\n onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u),\n },\n (s) => ({ activeCell: s.activeCell as { rowIndex: number; colIndex: number } }),\n )\n\n // Touch the state this component owns so the derived re-runs:\n // the engine's store is framework-free and not a rune.\n const rows = $derived.by(() => {\n sorting\n return table.getRowModel().rows\n })\n const active = $derived(table.state.activeCell)\n const GRID_ID = 'repos'\n const activeId = $derived(getGridCellDomId(GRID_ID, active.rowIndex, active.colIndex))\n\n function onKeydown(e: KeyboardEvent) {\n const moves: Record<string, { rowDelta?: number; colDelta?: number }> = {\n ArrowDown: { rowDelta: 1 }, ArrowUp: { rowDelta: -1 },\n ArrowRight: { colDelta: 1 }, ArrowLeft: { colDelta: -1 },\n Home: { colDelta: -columns.length }, End: { colDelta: columns.length },\n PageDown: { rowDelta: rows.length }, PageUp: { rowDelta: -rows.length },\n }\n const move = moves[e.key]\n if (!move) return\n e.preventDefault()\n table.moveActiveCell(move)\n }\n\n function sortDirection(id: string) {\n const s = sorting[0]\n return s?.id !== id ? 'none' : s.desc ? 'descending' : 'ascending'\n }\n</script>\n\n<table\n class=\"kb\"\n {...getGridRootA11yProps({ activeDescendantId: activeId, rowCount: rows.length, colCount: columns.length })}\n onkeydown={onKeydown}\n>\n <thead>\n {#each table.getHeaderGroups() as hg (hg.id)}\n <tr {...getGridRowA11yProps(1)}>\n {#each hg.headers as h (h.id)}\n <th\n {...getGridHeaderA11yProps({ sortable: true, sortDirection: sortDirection(h.column.id) })}\n onclick={h.column.getToggleSortingHandler()}\n >\n {h.column.columnDef.header}\n </th>\n {/each}\n </tr>\n {/each}\n </thead>\n <tbody>\n {#each rows as r, ri (r.id)}\n <tr {...getGridRowA11yProps(ri + 2)}>\n {#each columns as c, ci (c.field)}\n {@const selected = active.rowIndex === ri && active.colIndex === ci}\n <td\n {...getGridCellA11yProps({ rowIndex: ri + 2, colIndex: ci + 1, selected, id: getGridCellDomId(GRID_ID, ri, ci) })}\n class:active={selected}\n onclick={() => table.setActiveCell({ rowIndex: ri, colIndex: ci, cellId: `${ri}_${c.field}` })}\n >\n {String(r.original[c.field as keyof Repo])}\n </td>\n {/each}\n </tr>\n {/each}\n </tbody>\n</table>\n\n<style>\n .kb { width: 100%; border-collapse: collapse; font-size: 13px; color: var(--sg-fg, #0f172a); }\n .kb:focus { outline: 2px solid var(--sg-accent, #6366f1); outline-offset: 2px; }\n .kb th { text-align: left; padding: 8px 12px; background: var(--sg-header-bg, #f1f5f9); cursor: pointer; }\n .kb th[aria-sort='ascending']::after { content: ' ^'; }\n .kb th[aria-sort='descending']::after { content: ' v'; }\n .kb td { padding: 7px 12px; border-bottom: 1px solid var(--sg-border, #eef2f7); }\n .kb td.active { box-shadow: inset 0 0 0 2px var(--sg-accent, #6366f1); }\n</style>\n```\n\nClick a cell, then use the arrow keys. The root carries `role=\"grid\"`,\n`tabindex=\"0\"` and `aria-activedescendant`; each header carries\n`aria-sort`; each cell carries `role=\"gridcell\"`, `aria-rowindex`,\n`aria-colindex` and `aria-selected`. Screen readers announce the same grid\nthe render component would. What you still own: focus styling, `Enter` to\nedit if you add editing, and `Tab` to leave the grid (the browser handles\nthat one because the cells themselves are not focusable).\n\n## See also\n\n- [Build a table from scratch](./build-a-table.md) - the render loop\n- [Headless virtualization](./virtualization.md) - style a virtualized list\n- [Tailwind & theming tokens](../tailwind.md) - the `--sg-*` reference\n- [Migrating from the shadcn-svelte data table](../migrating-from-shadcn-data-table.md) - keep the shadcn markup, swap the engine\n- [Keyboard and accessibility](../accessibility.md) - what the render component does, for parity\n"
|
|
4623
4647
|
},
|
|
4624
4648
|
{
|
|
4625
4649
|
"slug": "help/headless/virtualization",
|
|
@@ -4675,7 +4699,7 @@ export const docs = [
|
|
|
4675
4699
|
"path": "docs/help/mcp-server.md",
|
|
4676
4700
|
"title": "MCP server",
|
|
4677
4701
|
"section": "Help",
|
|
4678
|
-
"markdown": "# MCP server\n\nThe SvGrid MCP server does two things for an AI client (Claude Code,\nClaude Desktop, Cursor, Zed, Codex, custom agents): it answers questions\nabout SvGrid from the files this repository ships, and it **checks the\ncode the model writes** against the real exported surface of the version\nyou have installed. No API key either way.\n\n\n\n> **What is MCP?** Model Context Protocol is the open standard\n> ([modelcontextprotocol.io](https://modelcontextprotocol.io)) for\n> exposing tools to LLM clients. SvGrid ships an MCP server so the\n> model your team already uses can \"see\" the grid without you having\n> to copy-paste docs into prompts.\n\nIt runs either way you like: as a local process\n([`@svgrid/mcp`](https://www.npmjs.com/package/@svgrid/mcp) on npm), or as\na hosted endpoint at `https://mcp.svgrid.com/mcp` that needs no install at\nall. Both are listed in the official MCP registry as `com.svgrid/svgrid`.\n\n## Two ways to connect\n\n**Hosted (nothing to install).** Point any MCP client at the URL:\n\n```\nhttps://mcp.svgrid.com/mcp\n```\n\n```bash\nclaude mcp add --transport http svgrid https://mcp.svgrid.com/mcp\n```\n\nOne click:\n[Add to Cursor](https://cursor.com/en/install-mcp?name=svgrid&config=eyJ1cmwiOiJodHRwczovL21jcC5zdmdyaWQuY29tL21jcCJ9)\n· [Add to VS Code](https://insiders.vscode.dev/redirect/mcp/install?name=svgrid&config=%7B%22name%22%3A%22svgrid%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fmcp.svgrid.com%2Fmcp%22%7D)\n\nIt carries six tools - `search`, `fetch`, `list_examples`,\n`get_example_source`, `get_api_reference` and `check_svgrid_code` - and\nneeds no Node, no config file, and no key.\n\n**Local (`npx @svgrid/mcp`).** Everything the hosted server has, plus\nthe Studio tools, the docs and demos as MCP resources, ready-made\nprompts, and a `svgrid_check_code` that additionally *compiles* the file\nwith the Svelte compiler rather than checking it statically. Use it when\nyou want the compile pass, the Studio tools, or no third-party endpoint\nin the loop.\n\n| | Hosted | Local |\n| --- | --- | --- |\n| Setup | a URL | `npx @svgrid/mcp` |\n| Needs Node | no | yes |\n| Code check compiles | no, static checks only | yes |\n| Resources and prompts | yes | yes |\n| Visual preview | yes | yes |\n| `studio_*` tools | no | yes, opt-in |\n| Works offline | no | yes |\n\n## Install\n\nNo install step is required - `npx` fetches it on demand:\n\n```bash\n# One-shot, from any project\nnpx -y @svgrid/mcp\n```\n\nTo pin it as a dev dependency instead:\n\n```bash\npnpm add -D @svgrid/mcp\n```\n\nThe local server is a Node binary (`svgrid-mcp`) that speaks MCP over\nstdio. There is no daemon to maintain. The hosted server needs no install\nstep at all - skip to [Wire it into your AI client](#wire-it-into-your-ai-client)\nand use the URL.\n\n## Wire it into your AI client\n\nEvery snippet below shows the local (stdio) form and the hosted (HTTP)\nform. Pick one - running both connects the same tools twice.\n\n### Claude Code\n\nThe plugin is the shortest path: it installs the\n[Agent Skill](./skill.md) and the hosted server together, so the model\ngets the house style *and* the checker in one step.\n\n```\n/plugin marketplace add sv-grid/sv-grid\n/plugin install svgrid@svgrid\n```\n\nOr add the server on its own:\n\n```bash\n# hosted, nothing to install\nclaude mcp add --transport http svgrid https://mcp.svgrid.com/mcp\n\n# or local\nclaude mcp add svgrid -- npx -y @svgrid/mcp\n```\n\nThen run `/mcp` in a session and you will see `svgrid` listed.\n\nTo share the server with your team, add `--scope project`. That writes\na `.mcp.json` at the repository root which you can commit, so everyone\nwho clones the repo gets the same tooling with no per-machine setup:\n\n```bash\nclaude mcp add svgrid --scope project -- npx -y @svgrid/mcp\n```\n\n### Claude Desktop\n\nEdit `~/Library/Application Support/Claude/claude_desktop_config.json`\n(macOS) or `%APPDATA%\\Claude\\claude_desktop_config.json` (Windows):\n\n```json\n{\n \"mcpServers\": {\n \"svgrid\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@svgrid/mcp\"]\n }\n }\n}\n```\n\nOr the hosted server, with nothing to install:\n\n```json\n{\n \"mcpServers\": {\n \"svgrid\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.svgrid.com/mcp\"\n }\n }\n}\n```\n\nRestart Claude Desktop, then ask *\"using svgrid, build me a grid that\ngroups by department\"* to confirm the tools are exposed.\n\n### Cursor\n\nOne click:\n[Add to Cursor](https://cursor.com/en/install-mcp?name=svgrid&config=eyJ1cmwiOiJodHRwczovL21jcC5zdmdyaWQuY29tL21jcCJ9)\n\nOr by hand, in `Settings -> MCP -> Add new MCP server`:\n\n```json\n{ \"url\": \"https://mcp.svgrid.com/mcp\" }\n```\n\n```json\n{ \"command\": \"npx\", \"args\": [\"-y\", \"@svgrid/mcp\"] }\n```\n\n### Zed\n\n`~/.config/zed/settings.json`:\n\n```json\n{\n \"context_servers\": {\n \"svgrid\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@svgrid/mcp\"],\n \"env\": {}\n }\n }\n}\n```\n\n### VS Code\n\nOne click:\n[Add to VS Code](https://insiders.vscode.dev/redirect/mcp/install?name=svgrid&config=%7B%22name%22%3A%22svgrid%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fmcp.svgrid.com%2Fmcp%22%7D)\n\nOr create `.vscode/mcp.json` in the workspace. Note that VS Code uses\n`servers` rather than the `mcpServers` wrapper:\n\n```json\n{\n \"servers\": {\n \"svgrid\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.svgrid.com/mcp\"\n }\n }\n}\n```\n\n```json\n{\n \"servers\": {\n \"svgrid\": {\n \"type\": \"stdio\",\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@svgrid/mcp\"]\n }\n }\n}\n```\n\n### Custom agents (OpenAI Agents SDK, Anthropic SDK, LangChain)\n\nPoint your client's MCP stdio transport at `npx -y @svgrid/mcp`, or its\nstreamable-HTTP transport at `https://mcp.svgrid.com/mcp`. Any client that\nspeaks either transport works.\n\nThe hosted server names its two retrieval tools `search` and `fetch`\nexactly, which is what a connector needs to index it.\n\n## Tools exposed\n\nFour, by default. `tools/list` is sent on **every** request, so the tool\nsurface is pure overhead on every turn - and this server used to spend\n~4,710 tokens of it on 36 tools, 79% of them Studio tools that most\nsessions never call once.\n\n| Tool | What it does |\n| ---- | ------------ |\n| `svgrid_search` | Search the docs, the 375 demos and the API surface **in one call**. No arguments returns an index. |\n| `svgrid_get` | Read one thing in full: a doc slug, a demo id, or `api`. |\n| `svgrid_check_code` | Verify code against the real exported surface before the user sees it. |\n| `svgrid_preview` | Render a **live, interactive grid** in the conversation from columns + rows. |\n| `svgrid_scaffold` | Turn a Drizzle schema, sample rows or an EntitySchema into runnable SvelteKit files. |\n\nStudio adds four more, and only when you ask for them - set\n`SVGRID_MCP_STUDIO=1` or a licence key in the server's env. They need a\nlicence to be useful, so they are not charged to everyone else's context\nwindow.\n\nThe **hosted** server carries `search`, `fetch`, `list_examples`,\n`get_example_source`, `get_api_reference`, `check_svgrid_code` and\n`svgrid_preview`, plus the same resources and prompts. The first two are named\nthat way deliberately: those exact names are what a one-click connector needs to\nindex a remote server. It also answers to `svgrid_search`, `svgrid_get` and\n`svgrid_check_code`, so a model that learned the npm package's names is never\ntold \"unknown tool\".\n\nWhat it does **not** have is the compile pass (the Svelte compiler cannot run in\na Worker), the Studio tools (they need a filesystem), and the offline guarantee\n- it is a remote server, so by definition your query reaches it. Use the local\none when any of those matter.\n\n### Verification\n\n#### `svgrid_check_code`\n\nChecks a file **against the version you have installed** and returns\nline-numbered diagnostics with the exact replacement for each: unknown\n`<SvGrid>` props, wrong `ColumnDef` keys, api methods that do not\nexist, imports that will not resolve, and Svelte 5 runes mistakes. It\nalso compiles the component, so a syntax error surfaces here rather\nthan in the user's terminal.\n\nThis is the tool that makes the rest worth having. Retrieval alone\nstill lets a model write a confident, wrong grid; nothing else in the\nserver stops it. Run it on every file you write, fix what it reports,\nand run it again.\n\n### Finding and reading\n\n#### `svgrid_search`\n\nOne call across all three corpora, because \"how do I pin a column\" does\nnot announce whether it is answered by a doc page, a demo, or an API\nname:\n\n```json\n{ \"query\": \"pin a column\" }\n```\n\nReturns ranked doc hits with excerpts, matching demos, and matching API\nnames - each with the reference you pass to `svgrid_get`. Narrow with\n`kind` (`docs` | `examples` | `api`), trade tokens for completeness\nwith `detail` (`concise` | `full`), and cap with `limit` (max 50).\n\nCall it with **no arguments** for an index of doc sections, demo\ncategories and API groups - the cheapest way to orient before searching.\n\n#### `svgrid_get`\n\n```json\n{ \"ref\": \"help/columns/column-definitions\" }\n{ \"ref\": \"11-stock-market\" }\n{ \"ref\": \"api\" }\n```\n\nThe kind is inferred from the reference; pass `kind` to force it. A\nreference that does not resolve comes back with near matches rather\nthan a bare \"not found\".\n\n### It fixes what it finds\n\n`svgrid_check_code` does not stop at telling you what is wrong. Where the\ncorrection is exact it returns the corrected file too:\n\n```json\n{\n \"diagnostics\": [ ... ],\n \"applied\": [\"rowData -> data (line 7)\", \"accessorKey -> field (line 3)\"],\n \"fixed\": \"<the corrected source>\"\n}\n```\n\nBoth fields are omitted when there is nothing mechanically fixable, so a clean\ncheck stays small.\n\nThe rules are deliberately narrow, because this tool's whole worth is that it\nnever cries wolf - and rewriting raises the stakes from wasting a turn to\ncorrupting working code. Only exact renames are applied (a known rename, or a\nclose-enough spelling), only on word boundaries, and only on the line the\ndiagnostic reported. Advice that is ambiguous - \"this has no equivalent, remove\nit\" - stays advice. A test asserts that running auto-fix across all 367 demos\nin this repository changes nothing at all.\n\n### It knows which version you have\n\nThe result carries the version it checked against:\n\n```json\n{ \"version\": { \"corpus\": \"3.0.0\", \"installed\": \"2.6.8\", \"warning\": \"...\" } }\n```\n\nThe server reads the `@svgrid/grid` in your project and says plainly when it\ndisagrees with the corpus it ships, so a model is told to hedge instead of\nconfidently citing an API you do not have. A server that answers from a remote\nbackend cannot do this - it has no idea what is in your `node_modules`.\n\n### Nothing leaves your machine\n\nThis server makes **no network calls**. Not for docs, not for the API surface,\nnot for verification - the whole corpus (375 demos, 408 doc pages, the full\nexported API) ships inside the package, which is why it is 8 MB rather than\n90 KB. There is no API key, no licence check on the wire, and no telemetry.\n\nIt works on a plane, behind a corporate proxy, and inside an air-gapped\nnetwork. `tools/mcp-offline.test.ts` enforces it: the server is driven through\na full session with every network primitive booby-trapped, and the test fails if\nanything is even attempted.\n\nThe one honest exception: the **preview** loads the grid from a CDN to draw it.\nThat happens in your client's sandboxed iframe, never in this server, and only\nwhen you actually render a preview.\n\n### Seeing the grid, not just reading about it\n\n`svgrid_preview` renders a **real, interactive SvGrid inside the\nconversation** - sortable, filterable, scrollable. Not a screenshot and not a\nmock table: the same `<sv-grid>` custom element a page would use, loaded from\nthe CDN, with the columns and rows you passed.\n\n```json\n{\n \"title\": \"Team roster\",\n \"columns\": [{ \"field\": \"name\", \"header\": \"Name\" }, { \"field\": \"amount\", \"header\": \"Amount\" }],\n \"data\": [{ \"name\": \"Ada\", \"amount\": 20000 }],\n \"sortable\": true\n}\n```\n\nIt uses [MCP Apps](https://blog.modelcontextprotocol.io/posts/2026-01-26-mcp-apps/),\nthe official UI extension: the tool points at a `ui://` resource through\n`_meta`, the client loads that HTML in a sandboxed iframe, and the tool's\n`structuredContent` arrives over a postMessage bridge. Supported in Claude\n(web and desktop), VS Code and ChatGPT.\n\nClients without UI support are not left out - they ignore the `_meta` and get\na text summary of the same grid, so it is always safe to call. That is the\nextension's own rule, and it is why the text half describes the grid rather\nthan pointing at a picture the reader cannot see.\n\nPass `demo` instead of `columns` / `data` and you get the demo's source: a\ndemo is a Svelte component, not data, so there is nothing to hand the element\nand rendering an empty grid would just look broken.\n\n### Resources and prompts\n\nThe docs and demos are also exposed as **MCP resources**, so a user can\nattach a page or a demo directly instead of hoping the model thinks to\nsearch for it:\n\n```\nsvgrid://doc/help/columns/column-definitions\nsvgrid://example/11-stock-market\n```\n\nAnd three **prompts** ship ready to run: `build_grid`, `explain_api`\nand `review_grid_code`. Each is written to make the model use this\nserver rather than recall SvGrid from training data, and each ends at\n`svgrid_check_code`.\n\n### SvGrid Studio (commercial)\n\nOff unless enabled. Set `SVGRID_MCP_STUDIO=1`, or a valid\n`SVGRID_LICENSE_KEY`, in the server's env.\n\n#### `svgrid_scaffold`\n\nPoint it at a Drizzle schema, sample JSON rows, or an `EntitySchema`\nyou already have, and it infers the schema and generates the `$lib`\nschema module, a `+server.ts` API route and a `+page.svelte` with\n`SvGrid` + `SvGridEditPanel` - in one call. Pass `schemaOnly: true` to\nstop after inference. Generated bodies carry `svgrid:managed` markers,\nso regenerating preserves your edits outside them.\n\n#### The `studio_*` tools\n\nFour tools drive the same validated project model the visual designer\nuses:\n\n| Tool | What it does |\n| ---- | ------------ |\n| `studio_project` | `new`, `load`, `describe`, `config`, `capabilities` |\n| `studio_apply` | A **batch** of model changes - entities, screens, blocks, components - applied in order |\n| `studio_configure` | Theme, auth, access, tenancy, data layer, jobs, deploy target, layouts, in one call |\n| `studio_build` | `validate`, then `generate` the full runnable SvelteKit app |\n\nCall `studio_project` with `action: \"capabilities\"` first: it reports\nexactly what the installed version supports, so the agent does not have\nto guess block kinds or component keys.\n\n`studio_apply` takes a batch on purpose. Building a five-screen app was\ntwenty-odd round trips when every block was its own tool; now a whole\nscreen is one call, and a failure reports which op failed and what\nalready applied.\n\n### The pre-3.0 names\n\nEvery tool name from before 3.0 still answers - `search_docs`,\n`get_doc`, `list_examples`, `get_example_source`, `list_docs`,\n`get_api_reference`, `check_svgrid_code`, `introspect_source`,\n`scaffold_entity` and all 27 individual `studio_*` tools. They are not\n**listed**, because listing is what costs context and answering a name\nyou did not advertise costs nothing. Saved prompts and scripts keep\nworking; nobody pays for the compatibility.\n\nTheir JSON response shape follows 3.0, not 2.x. A model reads either\nwithout trouble; a script that parsed the old shape by hand needs a look.\n\n## Licensing\n\nThe documentation and example tools are free. The Studio code\ngenerators are part of the commercial offering, with its 30-day\nevaluation period: without a key they run, but prepend a notice comment\nto generated files. To license them, set\nthe key in your MCP client's server config:\n\n```json\n{\n \"mcpServers\": {\n \"svgrid\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@svgrid/mcp\"],\n \"env\": { \"SVGRID_LICENSE_KEY\": \"SVENTERPRISE-...\" }\n }\n }\n}\n```\n\n## Verifying it works\n\nAsk your model: *\"What MCP tools do you have from svgrid?\"* The local\nserver answers with 36, including `check_svgrid_code` and the `studio_*`\nset; the hosted server answers with 6.\n\nThen give it something to catch:\n\n> Check this with svgrid: `<SvGrid rowData={rows} {columns} />`\n\nIt should come back with `rowData` is not a SvGrid prop, use `data`. If\nit explains the code instead of calling a tool, the server is not\nconnected.\n\nIf nothing is listed at all, check your client's MCP log. For the local\nserver the usual cause is `npx` not being on PATH - use the absolute path\nto the binary. For the hosted one, confirm the endpoint answers:\n\n```bash\ncurl https://mcp.svgrid.com/health\n```\n\n## Security model\n\nThe two ways of running it differ here, so pick deliberately.\n\n**Local (`npx @svgrid/mcp`)**\n\n- Runs on your machine over stdio. No telemetry, no outbound network\n calls, no API key.\n- Serves a documentation and example corpus bundled into the package at\n build time, so answers are pinned to the version you installed.\n- `check_svgrid_code` reads the source you pass it in-process and never\n sends it anywhere.\n\n**Hosted (`https://mcp.svgrid.com/mcp`)**\n\n- Your client sends tool arguments to a Cloudflare Worker we operate.\n Anything you pass to a tool leaves your machine, and for\n `check_svgrid_code` that means **the source you ask it to check**.\n- Each call is logged: tool name, duration, whether it succeeded, and the\n query or id it was given. For `check_svgrid_code` only the **byte\n length** of the submitted source is recorded, never the source itself.\n- No account, no key, no cookies. Requests are not tied to a user.\n- If your code cannot leave the building, use the local server. That is\n what it is for.\n\n**Both**\n\n- The Studio tools return generated files as data. Writing them to disk\n is your client's decision, not the server's.\n- See [security](./security.md) for the general supply-chain posture.\n\n## Building your own MCP integrations\n\nThe same docs manifest, JSON Schemas (column definitions, the grid's\noptions, export options, and the chart spec at `schemas/chart-spec.json`,\nwhich `svgrid_get` also serves for \"chart spec schema\"), and `llms.txt`\nfiles are also served directly from the docs site:\n\n```ts\nconst docs = await fetch('https://svgrid.com/docs.json').then((r) => r.json())\nconst schemas = await fetch('https://svgrid.com/schemas/index.json').then((r) => r.json())\nconst llms = await fetch('https://svgrid.com/llms-full.txt').then((r) => r.text())\n```\n\nIf you do not want to run the MCP server, building these into your\nagent's system prompt gives most of the same grounding.\n\n## Frequently asked questions\n\n### What is the SvGrid MCP server?\n\nA Model Context Protocol server that lets AI clients (Claude Code, Claude\nDesktop, Cursor, Zed, custom agents) query SvGrid's documentation, read real\ndemo source, scaffold SvelteKit CRUD apps, and check the code they write\nagainst the real API - grounded in the files the package ships, so the model\nanswers from current facts instead of guessing.\n\n### Do I need an API key to run it?\n\nNo, on either transport. A `SVGRID_LICENSE_KEY` is optional and only affects\nthe commercial Studio code generators.\n\n### Local or hosted - which should I use?\n\nHosted (`https://mcp.svgrid.com/mcp`) if you want it working in one paste,\nwith no Node and no config file. Local (`npx @svgrid/mcp`) if you want the\nSvelte compiler pass in `check_svgrid_code`, the 27 Studio tools, offline\nuse, or your source never leaving the machine. See\n[Security model](#security-model) for what each one sends.\n\n### How does it help AI assistants write better SvGrid code?\n\nTwo ways. It exposes the docs, example sources, and API reference as tools,\nso the assistant retrieves version-pinned answers instead of hallucinating an\nAPI from training data. And `check_svgrid_code` closes the loop: the model\nruns what it wrote past the real exported surface and gets told exactly what\nis wrong before you see it. That matters most for Svelte 5, where models\nroutinely mix in outdated Svelte 4 syntax.\n\n### Does it work with a version of SvGrid I have not upgraded to?\n\nThe local server answers for the version bundled in the `@svgrid/mcp` release\nyou install, so pin it alongside `@svgrid/grid` if you are behind. The hosted\nserver always tracks the current release. Either way, every\n`check_svgrid_code` result names the version it checked against in its\n`checkedAgainst` field.\n\n## See also\n\n- [Agent Skill](./skill.md) - the always-on house-style layer; the Claude Code plugin installs it together with this server\n- [LLM grounding](./llm-grounding.md) - the same files used by the MCP server, but documented for direct LLM consumption\n- [Agents](./agents.md) - how to build an AI agent that drives the live grid\n- [AI assistant](./ai.md) - the in-grid AI features (filter / smart-fill / classify / summarise), free in @svgrid/grid\n"
|
|
4702
|
+
"markdown": "# MCP server\n\nThe SvGrid MCP server does two things for an AI client (Claude Code,\nClaude Desktop, Cursor, Zed, Codex, custom agents): it answers questions\nabout SvGrid from the files this repository ships, and it **checks the\ncode the model writes** against the real exported surface of the version\nyou have installed. No API key either way.\n\n\n\n> **What is MCP?** Model Context Protocol is the open standard\n> ([modelcontextprotocol.io](https://modelcontextprotocol.io)) for\n> exposing tools to LLM clients. SvGrid ships an MCP server so the\n> model your team already uses can \"see\" the grid without you having\n> to copy-paste docs into prompts.\n\nIt runs either way you like: as a local process\n([`@svgrid/mcp`](https://www.npmjs.com/package/@svgrid/mcp) on npm), or as\na hosted endpoint at `https://mcp.svgrid.com/mcp` that needs no install at\nall. Both are listed in the official MCP registry as `com.svgrid/svgrid`.\n\n## Two ways to connect\n\n**Hosted (nothing to install).** Point any MCP client at the URL:\n\n```\nhttps://mcp.svgrid.com/mcp\n```\n\n```bash\nclaude mcp add --transport http svgrid https://mcp.svgrid.com/mcp\n```\n\nOne click:\n[Add to Cursor](https://cursor.com/en/install-mcp?name=svgrid&config=eyJ1cmwiOiJodHRwczovL21jcC5zdmdyaWQuY29tL21jcCJ9)\n· [Add to VS Code](https://insiders.vscode.dev/redirect/mcp/install?name=svgrid&config=%7B%22name%22%3A%22svgrid%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fmcp.svgrid.com%2Fmcp%22%7D)\n\nIt carries six tools - `search`, `fetch`, `list_examples`,\n`get_example_source`, `get_api_reference` and `check_svgrid_code` - and\nneeds no Node, no config file, and no key.\n\n**Local (`npx @svgrid/mcp`).** Everything the hosted server has, plus\nthe Studio tools, the docs and demos as MCP resources, ready-made\nprompts, and a `svgrid_check_code` that additionally *compiles* the file\nwith the Svelte compiler rather than checking it statically. Use it when\nyou want the compile pass, the Studio tools, or no third-party endpoint\nin the loop.\n\n| | Hosted | Local |\n| --- | --- | --- |\n| Setup | a URL | `npx @svgrid/mcp` |\n| Needs Node | no | yes |\n| Code check compiles | no, static checks only | yes |\n| Resources and prompts | yes | yes |\n| Visual preview | yes | yes |\n| `studio_*` tools | no | yes, opt-in |\n| Works offline | no | yes |\n\n## Install\n\nNo install step is required - `npx` fetches it on demand:\n\n```bash\n# One-shot, from any project\nnpx -y @svgrid/mcp\n```\n\nTo pin it as a dev dependency instead:\n\n```bash\npnpm add -D @svgrid/mcp\n```\n\nThe local server is a Node binary (`svgrid-mcp`) that speaks MCP over\nstdio. There is no daemon to maintain. The hosted server needs no install\nstep at all - skip to [Wire it into your AI client](#wire-it-into-your-ai-client)\nand use the URL.\n\n## Wire it into your AI client\n\nEvery snippet below shows the local (stdio) form and the hosted (HTTP)\nform. Pick one - running both connects the same tools twice.\n\n### Claude Code\n\nThe plugin is the shortest path: it installs the\n[Agent Skill](./skill.md) and the hosted server together, so the model\ngets the house style *and* the checker in one step.\n\n```\n/plugin marketplace add sv-grid/sv-grid\n/plugin install svgrid@svgrid\n```\n\nOr add the server on its own:\n\n```bash\n# hosted, nothing to install\nclaude mcp add --transport http svgrid https://mcp.svgrid.com/mcp\n\n# or local\nclaude mcp add svgrid -- npx -y @svgrid/mcp\n```\n\nThen run `/mcp` in a session and you will see `svgrid` listed.\n\nTo share the server with your team, add `--scope project`. That writes\na `.mcp.json` at the repository root which you can commit, so everyone\nwho clones the repo gets the same tooling with no per-machine setup:\n\n```bash\nclaude mcp add svgrid --scope project -- npx -y @svgrid/mcp\n```\n\n### Claude Desktop\n\nEdit `~/Library/Application Support/Claude/claude_desktop_config.json`\n(macOS) or `%APPDATA%\\Claude\\claude_desktop_config.json` (Windows):\n\n```json\n{\n \"mcpServers\": {\n \"svgrid\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@svgrid/mcp\"]\n }\n }\n}\n```\n\nOr the hosted server, with nothing to install:\n\n```json\n{\n \"mcpServers\": {\n \"svgrid\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.svgrid.com/mcp\"\n }\n }\n}\n```\n\nRestart Claude Desktop, then ask *\"using svgrid, build me a grid that\ngroups by department\"* to confirm the tools are exposed.\n\n### Cursor\n\nOne click:\n[Add to Cursor](https://cursor.com/en/install-mcp?name=svgrid&config=eyJ1cmwiOiJodHRwczovL21jcC5zdmdyaWQuY29tL21jcCJ9)\n\nOr by hand, in `Settings -> MCP -> Add new MCP server`:\n\n```json\n{ \"url\": \"https://mcp.svgrid.com/mcp\" }\n```\n\n```json\n{ \"command\": \"npx\", \"args\": [\"-y\", \"@svgrid/mcp\"] }\n```\n\n### Zed\n\n`~/.config/zed/settings.json`:\n\n```json\n{\n \"context_servers\": {\n \"svgrid\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@svgrid/mcp\"],\n \"env\": {}\n }\n }\n}\n```\n\n### VS Code\n\nOne click:\n[Add to VS Code](https://insiders.vscode.dev/redirect/mcp/install?name=svgrid&config=%7B%22name%22%3A%22svgrid%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fmcp.svgrid.com%2Fmcp%22%7D)\n\nOr create `.vscode/mcp.json` in the workspace. Note that VS Code uses\n`servers` rather than the `mcpServers` wrapper:\n\n```json\n{\n \"servers\": {\n \"svgrid\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.svgrid.com/mcp\"\n }\n }\n}\n```\n\n```json\n{\n \"servers\": {\n \"svgrid\": {\n \"type\": \"stdio\",\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@svgrid/mcp\"]\n }\n }\n}\n```\n\n### Custom agents (OpenAI Agents SDK, Anthropic SDK, LangChain)\n\nPoint your client's MCP stdio transport at `npx -y @svgrid/mcp`, or its\nstreamable-HTTP transport at `https://mcp.svgrid.com/mcp`. Any client that\nspeaks either transport works.\n\nThe hosted server names its two retrieval tools `search` and `fetch`\nexactly, which is what a connector needs to index it.\n\n## Tools exposed\n\nFour, by default. `tools/list` is sent on **every** request, so the tool\nsurface is pure overhead on every turn - and this server used to spend\n~4,710 tokens of it on 36 tools, 79% of them Studio tools that most\nsessions never call once.\n\n| Tool | What it does |\n| ---- | ------------ |\n| `svgrid_search` | Search the docs, the 375 demos and the API surface **in one call**. No arguments returns an index. |\n| `svgrid_get` | Read one thing in full: a doc slug, a demo id, or `api`. |\n| `svgrid_check_code` | Verify code against the real exported surface before the user sees it. |\n| `svgrid_preview` | Render a **live, interactive grid** in the conversation from columns + rows. |\n| `svgrid_scaffold` | Turn a Drizzle schema, sample rows or an EntitySchema into runnable SvelteKit files. |\n\nStudio adds four more, and only when you ask for them - set\n`SVGRID_MCP_STUDIO=1` or a licence key in the server's env. They need a\nlicence to be useful, so they are not charged to everyone else's context\nwindow.\n\nThe **hosted** server carries `search`, `fetch`, `list_examples`,\n`get_example_source`, `get_api_reference`, `check_svgrid_code` and\n`svgrid_preview`, plus the same resources and prompts. The first two are named\nthat way deliberately: those exact names are what a one-click connector needs to\nindex a remote server. It also answers to `svgrid_search`, `svgrid_get` and\n`svgrid_check_code`, so a model that learned the npm package's names is never\ntold \"unknown tool\".\n\nWhat it does **not** have is the compile pass (the Svelte compiler cannot run in\na Worker), the Studio tools (they need a filesystem), and the offline guarantee\n- it is a remote server, so by definition your query reaches it. Use the local\none when any of those matter.\n\n### Verification\n\n#### `svgrid_check_code`\n\nChecks a file **against the version you have installed** and returns\nline-numbered diagnostics with the exact replacement for each: unknown\n`<SvGrid>` props, wrong `ColumnDef` keys, api methods that do not\nexist, imports that will not resolve, and Svelte 5 runes mistakes. It\nalso compiles the component, so a syntax error surfaces here rather\nthan in the user's terminal.\n\nThis is the tool that makes the rest worth having. Retrieval alone\nstill lets a model write a confident, wrong grid; nothing else in the\nserver stops it. Run it on every file you write, fix what it reports,\nand run it again.\n\n### Finding and reading\n\n#### `svgrid_search`\n\nOne call across all three corpora, because \"how do I pin a column\" does\nnot announce whether it is answered by a doc page, a demo, or an API\nname:\n\n```json\n{ \"query\": \"pin a column\" }\n```\n\nReturns ranked doc hits with excerpts, matching demos, and matching API\nnames - each with the reference you pass to `svgrid_get`. Narrow with\n`kind` (`docs` | `examples` | `api`), trade tokens for completeness\nwith `detail` (`concise` | `full`), and cap with `limit` (max 50).\n\nCall it with **no arguments** for an index of doc sections, demo\ncategories and API groups - the cheapest way to orient before searching.\n\n#### `svgrid_get`\n\n```json\n{ \"ref\": \"help/columns/column-definitions\" }\n{ \"ref\": \"11-stock-market\" }\n{ \"ref\": \"api\" }\n```\n\nThe kind is inferred from the reference; pass `kind` to force it. A\nreference that does not resolve comes back with near matches rather\nthan a bare \"not found\".\n\n### It fixes what it finds\n\n`svgrid_check_code` does not stop at telling you what is wrong. Where the\ncorrection is exact it returns the corrected file too:\n\n```json\n{\n \"diagnostics\": [ ... ],\n \"applied\": [\"rowData -> data (line 7)\", \"accessorKey -> field (line 3)\"],\n \"fixed\": \"<the corrected source>\"\n}\n```\n\nBoth fields are omitted when there is nothing mechanically fixable, so a clean\ncheck stays small.\n\nThe rules are deliberately narrow, because this tool's whole worth is that it\nnever cries wolf - and rewriting raises the stakes from wasting a turn to\ncorrupting working code. Only exact renames are applied (a known rename, or a\nclose-enough spelling), only on word boundaries, and only on the line the\ndiagnostic reported. Advice that is ambiguous - \"this has no equivalent, remove\nit\" - stays advice. A test asserts that running auto-fix across all 367 demos\nin this repository changes nothing at all.\n\n### It knows which version you have\n\nThe result carries the version it checked against:\n\n```json\n{ \"version\": { \"corpus\": \"3.0.0\", \"installed\": \"2.6.8\", \"warning\": \"...\" } }\n```\n\nThe server reads the `@svgrid/grid` in your project and says plainly when it\ndisagrees with the corpus it ships, so a model is told to hedge instead of\nconfidently citing an API you do not have. A server that answers from a remote\nbackend cannot do this - it has no idea what is in your `node_modules`.\n\n### Nothing leaves your machine\n\nThis server makes **no network calls**. Not for docs, not for the API surface,\nnot for verification - the whole corpus (375 demos, 408 doc pages, the full\nexported API) ships inside the package, which is why it is 8 MB rather than\n90 KB. There is no API key, no licence check on the wire, and no telemetry.\n\nIt works on a plane, behind a corporate proxy, and inside an air-gapped\nnetwork. `tools/mcp-offline.test.ts` enforces it: the server is driven through\na full session with every network primitive booby-trapped, and the test fails if\nanything is even attempted.\n\nThe one honest exception: the **preview** loads the grid from a CDN to draw it.\nThat happens in your client's sandboxed iframe, never in this server, and only\nwhen you actually render a preview.\n\n### Seeing the grid, not just reading about it\n\n`svgrid_preview` renders a **real, interactive SvGrid inside the\nconversation** - sortable, filterable, scrollable. Not a screenshot and not a\nmock table: the same `<sv-grid>` custom element a page would use, loaded from\nthe CDN, with the columns and rows you passed.\n\n```json\n{\n \"title\": \"Team roster\",\n \"columns\": [{ \"field\": \"name\", \"header\": \"Name\" }, { \"field\": \"amount\", \"header\": \"Amount\" }],\n \"data\": [{ \"name\": \"Ada\", \"amount\": 20000 }],\n \"sortable\": true\n}\n```\n\nIt uses [MCP Apps](https://blog.modelcontextprotocol.io/posts/2026-01-26-mcp-apps/),\nthe official UI extension: the tool points at a `ui://` resource through\n`_meta`, the client loads that HTML in a sandboxed iframe, and the tool's\n`structuredContent` arrives over a postMessage bridge. Supported in Claude\n(web and desktop), VS Code and ChatGPT.\n\nClients without UI support are not left out - they ignore the `_meta` and get\na text summary of the same grid, so it is always safe to call. That is the\nextension's own rule, and it is why the text half describes the grid rather\nthan pointing at a picture the reader cannot see.\n\nPass `demo` instead of `columns` / `data` and you get the demo's source: a\ndemo is a Svelte component, not data, so there is nothing to hand the element\nand rendering an empty grid would just look broken.\n\n### Resources and prompts\n\nThe docs and demos are also exposed as **MCP resources**, so a user can\nattach a page or a demo directly instead of hoping the model thinks to\nsearch for it:\n\n```\nsvgrid://doc/help/columns/column-definitions\nsvgrid://example/11-stock-market\n```\n\nAnd three **prompts** ship ready to run: `build_grid`, `explain_api`\nand `review_grid_code`. Each is written to make the model use this\nserver rather than recall SvGrid from training data, and each ends at\n`svgrid_check_code`.\n\n### SvGrid Studio (commercial)\n\nOff unless enabled. Set `SVGRID_MCP_STUDIO=1`, or a valid\n`SVGRID_LICENSE_KEY`, in the server's env.\n\n#### `svgrid_scaffold`\n\nPoint it at a Drizzle schema, sample JSON rows, or an `EntitySchema`\nyou already have, and it infers the schema and generates the `$lib`\nschema module, a `+server.ts` API route and a `+page.svelte` with\n`SvGrid` + `SvGridEditPanel` - in one call. Pass `schemaOnly: true` to\nstop after inference. Generated bodies carry `svgrid:managed` markers,\nso regenerating preserves your edits outside them.\n\n#### The `studio_*` tools\n\nFour tools drive the same validated project model the visual designer\nuses:\n\n| Tool | What it does |\n| ---- | ------------ |\n| `studio_project` | `new`, `load`, `describe`, `config`, `capabilities` |\n| `studio_apply` | A **batch** of model changes - entities, screens, blocks, components - applied in order |\n| `studio_configure` | Theme, auth, access, tenancy, data layer, jobs, deploy target, layouts, in one call |\n| `studio_build` | `validate`, then `generate` the full runnable SvelteKit app |\n\nCall `studio_project` with `action: \"capabilities\"` first: it reports\nexactly what the installed version supports, so the agent does not have\nto guess block kinds or component keys.\n\n`studio_apply` takes a batch on purpose. Building a five-screen app was\ntwenty-odd round trips when every block was its own tool; now a whole\nscreen is one call, and a failure reports which op failed and what\nalready applied.\n\n### The pre-3.0 names\n\nEvery tool name from before 3.0 still answers - `search_docs`,\n`get_doc`, `list_examples`, `get_example_source`, `list_docs`,\n`get_api_reference`, `check_svgrid_code`, `introspect_source`,\n`scaffold_entity` and all 27 individual `studio_*` tools. They are not\n**listed**, because listing is what costs context and answering a name\nyou did not advertise costs nothing. Saved prompts and scripts keep\nworking; nobody pays for the compatibility.\n\nTheir JSON response shape follows 3.0, not 2.x. A model reads either\nwithout trouble; a script that parsed the old shape by hand needs a look.\n\n## Licensing\n\nThe documentation and example tools are free. The Studio code\ngenerators are part of the commercial offering. You can evaluate them\nwithout a key: they run, but prepend a notice comment\nto generated files. To license them, set\nthe key in your MCP client's server config:\n\n```json\n{\n \"mcpServers\": {\n \"svgrid\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@svgrid/mcp\"],\n \"env\": { \"SVGRID_LICENSE_KEY\": \"SVENTERPRISE-...\" }\n }\n }\n}\n```\n\n## Verifying it works\n\nAsk your model: *\"What MCP tools do you have from svgrid?\"* The local\nserver answers with 36, including `check_svgrid_code` and the `studio_*`\nset; the hosted server answers with 6.\n\nThen give it something to catch:\n\n> Check this with svgrid: `<SvGrid rowData={rows} {columns} />`\n\nIt should come back with `rowData` is not a SvGrid prop, use `data`. If\nit explains the code instead of calling a tool, the server is not\nconnected.\n\nIf nothing is listed at all, check your client's MCP log. For the local\nserver the usual cause is `npx` not being on PATH - use the absolute path\nto the binary. For the hosted one, confirm the endpoint answers:\n\n```bash\ncurl https://mcp.svgrid.com/health\n```\n\n## Security model\n\nThe two ways of running it differ here, so pick deliberately.\n\n**Local (`npx @svgrid/mcp`)**\n\n- Runs on your machine over stdio. No telemetry, no outbound network\n calls, no API key.\n- Serves a documentation and example corpus bundled into the package at\n build time, so answers are pinned to the version you installed.\n- `check_svgrid_code` reads the source you pass it in-process and never\n sends it anywhere.\n\n**Hosted (`https://mcp.svgrid.com/mcp`)**\n\n- Your client sends tool arguments to a Cloudflare Worker we operate.\n Anything you pass to a tool leaves your machine, and for\n `check_svgrid_code` that means **the source you ask it to check**.\n- Each call is logged: tool name, duration, whether it succeeded, and the\n query or id it was given. For `check_svgrid_code` only the **byte\n length** of the submitted source is recorded, never the source itself.\n- No account, no key, no cookies. Requests are not tied to a user.\n- If your code cannot leave the building, use the local server. That is\n what it is for.\n\n**Both**\n\n- The Studio tools return generated files as data. Writing them to disk\n is your client's decision, not the server's.\n- See [security](./security.md) for the general supply-chain posture.\n\n## Building your own MCP integrations\n\nThe same docs manifest, JSON Schemas (column definitions, the grid's\noptions, export options, and the chart spec at `schemas/chart-spec.json`,\nwhich `svgrid_get` also serves for \"chart spec schema\"), and `llms.txt`\nfiles are also served directly from the docs site:\n\n```ts\nconst docs = await fetch('https://svgrid.com/docs.json').then((r) => r.json())\nconst schemas = await fetch('https://svgrid.com/schemas/index.json').then((r) => r.json())\nconst llms = await fetch('https://svgrid.com/llms-full.txt').then((r) => r.text())\n```\n\nIf you do not want to run the MCP server, building these into your\nagent's system prompt gives most of the same grounding.\n\n## Frequently asked questions\n\n### What is the SvGrid MCP server?\n\nA Model Context Protocol server that lets AI clients (Claude Code, Claude\nDesktop, Cursor, Zed, custom agents) query SvGrid's documentation, read real\ndemo source, scaffold SvelteKit CRUD apps, and check the code they write\nagainst the real API - grounded in the files the package ships, so the model\nanswers from current facts instead of guessing.\n\n### Do I need an API key to run it?\n\nNo, on either transport. A `SVGRID_LICENSE_KEY` is optional and only affects\nthe commercial Studio code generators.\n\n### Local or hosted - which should I use?\n\nHosted (`https://mcp.svgrid.com/mcp`) if you want it working in one paste,\nwith no Node and no config file. Local (`npx @svgrid/mcp`) if you want the\nSvelte compiler pass in `check_svgrid_code`, the 27 Studio tools, offline\nuse, or your source never leaving the machine. See\n[Security model](#security-model) for what each one sends.\n\n### How does it help AI assistants write better SvGrid code?\n\nTwo ways. It exposes the docs, example sources, and API reference as tools,\nso the assistant retrieves version-pinned answers instead of hallucinating an\nAPI from training data. And `check_svgrid_code` closes the loop: the model\nruns what it wrote past the real exported surface and gets told exactly what\nis wrong before you see it. That matters most for Svelte 5, where models\nroutinely mix in outdated Svelte 4 syntax.\n\n### Does it work with a version of SvGrid I have not upgraded to?\n\nThe local server answers for the version bundled in the `@svgrid/mcp` release\nyou install, so pin it alongside `@svgrid/grid` if you are behind. The hosted\nserver always tracks the current release. Either way, every\n`check_svgrid_code` result names the version it checked against in its\n`checkedAgainst` field.\n\n## See also\n\n- [Agent Skill](./skill.md) - the always-on house-style layer; the Claude Code plugin installs it together with this server\n- [LLM grounding](./llm-grounding.md) - the same files used by the MCP server, but documented for direct LLM consumption\n- [Agents](./agents.md) - how to build an AI agent that drives the live grid\n- [AI assistant](./ai.md) - the in-grid AI features (filter / smart-fill / classify / summarise), free in @svgrid/grid\n"
|
|
4679
4703
|
},
|
|
4680
4704
|
{
|
|
4681
4705
|
"slug": "help/migrating-from-ag-grid",
|
|
@@ -4710,7 +4734,7 @@ export const docs = [
|
|
|
4710
4734
|
"path": "docs/help/migrating-from-handsontable.md",
|
|
4711
4735
|
"title": "Migrating from Handsontable",
|
|
4712
4736
|
"section": "Help",
|
|
4713
|
-
"markdown": "# Migrating from Handsontable\n\nHandsontable's mental model is \"spreadsheet-in-a-page\": every cell\neditable, formulas, copy/paste with the OS clipboard, frozen rows\nand columns. Sv-grid covers ~90% of the same surface; the port is\nmostly a configuration translation, except for HyperFormula (see\nthe Formulas section below).\n\n> Estimated effort: **2-4 hours** per grid for typical spreadsheet\n> workflows. **Add 1-2 days** if you depend heavily on\n> HyperFormula features sv-grid doesn't ship.\n\n<!-- facts:start handsontable -->\n> **Facts, checked 12 Sep 2026.** `handsontable` 18.1.0, Commercial (see licence file), last published 1 Sep 2026, 1,190,000 npm downloads in the 30 days to 10 Sep 2026. `@svgrid/grid` 3.0.3, MIT, last published 11 Sep 2026, 16,900 npm downloads in the same window. Bundle, minified and gzipped, each package built alone with Svelte external: SvGrid 3.0.4 97.6 KB JS + 10.5 KB CSS (measured 22 Sep 2026). Handsontable pricing, as its site states it: Handsontable's Hobby licence is free for personal, exploratory projects and cannot be used in commercial settings; handsontable.com lists Standard from $999 per developer and Priority from $1,299 per developer, with Enterprise on custom terms (https://handsontable.com/pricing, read 12 Sep 2026). SvGrid: MIT core; @svgrid/enterprise from $599 per developer per year. Side by side, with sources: [SvGrid vs Handsontable](https://svgrid.com/compare/handsontable/).\n<!-- facts:end -->\n\n## Imports\n\nThe example at the end of this page runs against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n let rows = $state<Person[]>(people)\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200, editorType: 'text' },\n { field: 'department', header: 'Department', width: 150, editorType: 'text' },\n { field: 'city', header: 'City', width: 140, editorType: 'text' },\n { field: 'age', header: 'Age', width: 90, editorType: 'number' },\n { field: 'salary', header: 'Salary', width: 130, editorType: 'number', format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```diff\n- import Handsontable from 'handsontable'\n- import 'handsontable/dist/handsontable.full.min.css'\n\n+ import {\n+ SvGrid, tableFeatures,\n+ rowSortingFeature, columnFilteringFeature, rowSelectionFeature,\n+ + } from '@svgrid/grid'\n```\n\n## Schema → columns\n\nHandsontable accepts a 2D array OR an array of objects. Sv-grid\nexpects the array-of-objects form:\n\n```diff\n- const hot = new Handsontable(container, {\n- data: [\n- ['Tesla', 2017, 'black', false],\n- ['Nissan', 2018, 'blue', true ],\n- ],\n- colHeaders: ['Make', 'Year', 'Color', 'In stock'],\n- columns: [\n- {},\n- { type: 'numeric' },\n- {},\n- { type: 'checkbox' },\n- ],\n- })\n\n+ type Car = { make: string; year: number; color: string; inStock: boolean }\n+ const rows: Car[] = [\n+ { make: 'Tesla', year: 2017, color: 'black', inStock: false },\n+ { make: 'Nissan', year: 2018, color: 'blue', inStock: true },\n+ ]\n+ const columns: ColumnDef<typeof features, Car>[] = [\n+ { field: 'make', header: 'Make' },\n+ { field: 'year', header: 'Year', editorType: 'number' },\n+ { field: 'color', header: 'Color' },\n+ { field: 'inStock', header: 'In stock', editorType: 'checkbox' },\n+ ]\n+ <SvGrid data={rows} columns={columns} features={features} />\n```\n\nIf your existing data is a 2D array, transform once at boot:\n\n```ts\nconst headerRow = raw[0]\nconst rows = raw.slice(1).map((cells) =>\n Object.fromEntries(cells.map((v, i) => [headerRow[i], v])),\n)\n```\n\n## Editor types\n\n| Handsontable | sv-grid |\n| ---------------------------------------------- | ------------------------------------------------ |\n| `type: 'text'` | `editorType: 'text'` |\n| `type: 'numeric'` | `editorType: 'number'` |\n| `type: 'date'` | `editorType: 'date'` |\n| `type: 'checkbox'` | `editorType: 'checkbox'` |\n| `type: 'dropdown', source: [...]` | `editorType: 'list', editorOptions: [...]` |\n| `type: 'autocomplete'` | `editorType: 'list'` (combobox UI ships) |\n| `type: 'time'` | `editorType: 'time'` |\n| `type: 'password'` | Use `editorType: 'text'` + a custom snippet |\n\n## Selection + clipboard\n\n| Handsontable | sv-grid |\n| --------------------------------------- | ------------------------------------------------------- |\n| `selectCells([[0,0,2,5]])` | `api.selectCells(ranges)`, with `selectionMode='cell'` |\n| `getSelected()` | `api.getSelected()`: the same `[[startRow, startCol, endRow, endCol], ...]`, normalised to top-left / bottom-right and `[]` rather than `undefined` when nothing is selected |\n| `getSelectedLast()` | `api.getSelectedLast()`: `[startRow, startCol, endRow, endCol]` with the orientation kept (a range dragged upwards has `startRow > endRow`), or `undefined` |\n| `getSelectedRange()` / `getSelectedRangeLast()` | `api.getSelectedRange()` / `api.getSelectedRangeLast()`: `{ from, to, highlight }` per rectangle, the same three fields as `CellRange`; plain data rather than a class, so use `Math.min` / `Math.max` for the corners |\n| `copyPaste: true` (default) | Ships built-in; `enableCellSelection={true}`. The spreadsheet shell also exchanges formats, number formats and formulas with Excel and Google Sheets through the HTML clipboard |\n| `fillHandle: true` | Ships built-in (drag-to-fill on selected range) |\n| `moveCells: true` | `moveCells` - drag the range border to move it, Ctrl / Cmd to copy. On by default; see [move or copy a range](./editing/move-cells.md) |\n\n## Frozen rows / columns\n\n| Handsontable | sv-grid |\n| ---------------------------------- | ------------------------------------------------------ |\n| `fixedColumnsStart: 2` | `api.setColumnPinning({ left: ['firstColId', 'secondColId'] })` |\n| `fixedRowsTop: 1` | `pinnedTopRows` (and `pinnedBottomRows`) |\n| `manualColumnFreeze: true` | Ships via the column-header right-click menu |\n| `mergeCells: [{ row, col, rowspan, colspan }]` | `mergedCells={[{ rowIndex, colIndex, rowSpan, colSpan }]}` on `<SvGrid>`; the spreadsheet shell adds Merge & Center, Merge Across, Merge Cells and Unmerge on the ribbon and the cell menu |\n\nFreeze panes on a plain `<SvGrid>`: two pinned columns and the sticky\nletter and number headers, with formulas underneath.\n\n<div data-docs-demo=\"208-freeze-panes\" data-height=\"520\"></div>\n\n## Hooks → callbacks\n\n| Handsontable hook | sv-grid |\n| -------------------------------------- | ------------------------------------------------------- |\n| `afterChange((changes, src) => {})` | `onCellValueChange((e) => {...})` |\n| `afterSelection(...)` | `onActiveCellChange((cell) => {...})` |\n| `afterFilter(...)` | `onFiltersChange((f) => {...})` |\n| `afterColumnSort(...)` | `onSortingChange((s) => {...})` |\n| `beforeChange((changes) => false)` | Throw / return `false` from your inline validator; see [validation](./editing/validation.md) |\n| `afterCreateRow / afterRemoveRow` | Wrap `api.addRow / removeRow` in your own emitter; on the spreadsheet shell, `onChange` reports a `structure` reason with the edit |\n| `afterMergeCells / afterUnmergeCells` | On the spreadsheet shell, `onChange` reports `merges`; `api.getMergedCells()` reads the grid's list |\n| `afterSetCellMeta` (comments, validation) | `onChange` reports `comments`, `validation`, `conditional-formats` and `protection` on the shell |\n| `afterFilter` (the Filters plugin) | `onChange` reports `filter` on the shell; `getState().sheets[name].autoFilter` is the state |\n\nOn the spreadsheet shell (`<SvSheet>` in `@svgrid/enterprise`) one callback\ncovers the lot: `onChange(reasons)` fires once per tick with every kind of\nchange since the last call (`cells`, `formats`, `sizes`, `hidden`,\n`freeze`, `sheets`, `comments`, `protection`, `validation`,\n`conditional-formats`, `merges`, `filter`, `structure` with the insert or\ndelete, `restore`), which is where Handsontable's `afterChange` autosave\ngoes. `getState()` gives the document to save and `setState()` puts it back.\n\n## Cell meta → the sheet document\n\nHandsontable keeps comments, validators, read-only flags and merges as cell\nmeta on the instance. On the spreadsheet shell they are parts of the\ndocument, per sheet, saved with it and moved by inserts and deletes:\n\n| Handsontable | sv-grid spreadsheet shell |\n| --------------------------------------------------- | --------------------------------------------------------------- |\n| `comments: true`, `cell: [{ row, col, comment }]` | Review > New Comment, Shift+F2, the cell menu; `getState().sheets[name].comments` |\n| `validator: 'numeric'`, `allowInvalid: false` | Data > Data Validation (Whole number, Decimal, List, Date, Text length, Custom), Stop or Warning; `validation` in the state |\n| `readOnly: true` on a cell | Format Cells > Protection unlocks the cells that may change, Review > Protect Sheet locks the rest; `locked: false` in the format entry, `protected` per sheet |\n| `mergeCells` | Merge & Center on the ribbon and the cell menu; `merges` in the state |\n| `filters: true` + `dropdownMenu` | Ctrl+Shift+L or Data > Filter: Excel's AutoFilter arrows and menus; `autoFilter` in the state |\n| conditional formatting (a custom renderer) | Home > Styles > Conditional Formatting: Highlight Cells, Top/Bottom, Data Bars, Color Scales, Icon Sets, Manage Rules; `conditionalFormats` in the state |\n| `autofill` rules | The fill handle's own series: numbers, dates by day or by the set gap, `Item 1`, weekdays and months |\n\nOn a plain `<SvGrid>` the free grid has the primitives instead: `notes`\nand `editableComments` for comments, `editable` and `valueParser` on a\ncolumn for read-only cells and validation, `mergedCells` for merges, the\nfilter row and its Excel-style menu for filtering, and value-driven\n`conditionalFormats` by column.\n\n## Formulas (HyperFormula)\n\nSv-grid ships a focused subset under\n[`spreadsheet-formulas`](./spreadsheet-formulas.md) (SUM, IF, COUNTIF\n+ cell refs + ranges + cycle detection). It does NOT ship the full\nHyperFormula library.\n\nIf you need HyperFormula's full surface (~400 functions, complex\ndate math, R1C1 refs), the integration pattern is:\n\n```ts\nimport { HyperFormula } from 'hyperformula'\n\nconst hf = HyperFormula.buildFromArray(rowsAsMatrix, { licenseKey: 'gpl-v3' })\n\n// On every cell edit, push to HF + read back the computed value:\nfunction onCellValueChange(e) {\n hf.setCellContents({ sheet: 0, row: e.rowIndex, col: columnIndex }, e.newValue)\n // ...read computed cells you depend on + push back via api.setCellValue\n}\n```\n\nThis is the only piece where the swap isn't 1:1. Most teams find\nsv-grid's shipped subset covers 80% of real-world formula usage; if\nyours is in the 20%, HyperFormula plugs in with the pattern above.\n\n## Licence\n\nHandsontable's grid needs a commercial licence for commercial use; its free\nHobby licence is for personal, exploratory projects, as its pricing page\nstates (the wording and the date are in the facts box at the top of this\npage). Sv-grid's `@svgrid/grid` is MIT with no licence key, and the\n[Enterprise tier](../enterprise/licensing.md) comes with a 30-day evaluation\nperiod: it works without a key and displays a watermark and a console\nmessage until you add one, so there is no cutoff to plan around.\n\n## What you get for free vs Handsontable\n\n- **No commercial licence for the grid.** @svgrid/grid is MIT.\n- **Modern Svelte 5 ergonomics.** `$state` arrays beat\n `loadData(...)`.\n- **Grid features a spreadsheet does not have.** Row grouping with\n aggregation, master/detail, server-side data (grouping, tree, pivot and transactions on the server are Enterprise) and integrated\n charts in the same package.\n- **CSP-clean.** Handsontable's HyperFormula path needs CSP `eval`\n exceptions in some configurations.\n\n## What you give up\n\n- **Full HyperFormula surface as a supported feature.** Sv-grid ships a\n subset and lets you wire HyperFormula yourself; see above.\n- **Nested headers.** Handsontable's `nestedHeaders` have no\n counterpart beyond column groups.\n- **Threaded comments and a validation Input Message.** Comments on the\n shell are Excel's notes, one text per cell; validation shows its message\n on a refusal, not on arrival.\n- **Excel export without a paid pack.** Handsontable's export plugin writes\n XLSX through its asynchronous method; on sv-grid, CSV, TSV and JSON export\n are free and Excel, PDF and print output are in `@svgrid/enterprise`.\n\n## Frequently asked questions\n\n### Can SvGrid replace Handsontable's spreadsheet features?\n\nIt covers roughly 90% of the surface - inline editing on every cell, copy/paste\nas TSV, range selection, column groups, and a built-in formula engine. Budget\n2-4 hours per grid for typical spreadsheet workflows, plus 1-2 days if you lean\nheavily on the full HyperFormula function set.\n\n### Does SvGrid support formulas like Handsontable?\n\nYes, a built-in formula engine with cell references, ranges, and common\nfunctions (see the spreadsheet-formulas guide and demo 27). It is a practical\nsubset rather than the complete HyperFormula library, so verify your specific\nfunctions before porting.\n\n### Is SvGrid licensed like Handsontable?\n\nNo. The `@svgrid/grid` core is MIT and free for commercial use - no per-seat\nlicence key - whereas Handsontable's free licence excludes commercial use and\nits commercial plans are per developer; the facts box has the statement as\nread from handsontable.com. The optional `@svgrid/enterprise` pack (Excel and\nPDF export, print, pivot, import) is priced per developer.\n\n### Does SvGrid have a blank spreadsheet like Handsontable?\n\nYes, on the plain grid: column-letter headers, a row gutter, a name box\nand formula bar, gridlines, range selection and a fill handle, with\nHyperFormula underneath when you want its function library.\n\n<div data-docs-demo=\"207-blank-sheet\" data-height=\"560\"></div>\n\n## What you end up with\n\nSpreadsheet behaviour: inline edit, drag a cell range, copy it out as TSV.\n\n```svelte {runnable}\n<SvGrid data={rows} {columns} editable enableCellSelection statusBar />\n```\n\n## See also\n\n- [SvGrid vs Handsontable](https://svgrid.com/compare/handsontable/) - the side-by-side comparison, with a source and date for every claim\n- [Spreadsheet formulas](./spreadsheet-formulas.md) - sv-grid's\n built-in formula engine, and the rest of the spreadsheet demos\n- [Demo 27 (Spreadsheet + Ribbon)](https://svgrid.com/demos/27-spreadsheet-ribbon/) - live\n- [Migrating from AG Grid](./migrating-from-ag-grid.md)\n"
|
|
4737
|
+
"markdown": "# Migrating from Handsontable\n\nHandsontable's mental model is \"spreadsheet-in-a-page\": every cell\neditable, formulas, copy/paste with the OS clipboard, frozen rows\nand columns. Sv-grid covers ~90% of the same surface; the port is\nmostly a configuration translation, except for HyperFormula (see\nthe Formulas section below).\n\n> Estimated effort: **2-4 hours** per grid for typical spreadsheet\n> workflows. **Add 1-2 days** if you depend heavily on\n> HyperFormula features sv-grid doesn't ship.\n\n<!-- facts:start handsontable -->\n> **Facts, checked 12 Sep 2026.** `handsontable` 18.1.0, Commercial (see licence file), last published 1 Sep 2026, 1,190,000 npm downloads in the 30 days to 10 Sep 2026. `@svgrid/grid` 3.0.3, MIT, last published 11 Sep 2026, 16,900 npm downloads in the same window. Bundle, minified and gzipped, each package built alone with Svelte external: SvGrid 3.0.4 97.6 KB JS + 10.5 KB CSS (measured 22 Sep 2026). Handsontable pricing, as its site states it: Handsontable's Hobby licence is free for personal, exploratory projects and cannot be used in commercial settings; handsontable.com lists Standard from $999 per developer and Priority from $1,299 per developer, with Enterprise on custom terms (https://handsontable.com/pricing, read 12 Sep 2026). SvGrid: MIT core; @svgrid/enterprise from $599 per developer per year. Side by side, with sources: [SvGrid vs Handsontable](https://svgrid.com/compare/handsontable/).\n<!-- facts:end -->\n\n## Imports\n\nThe example at the end of this page runs against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n let rows = $state<Person[]>(people)\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200, editorType: 'text' },\n { field: 'department', header: 'Department', width: 150, editorType: 'text' },\n { field: 'city', header: 'City', width: 140, editorType: 'text' },\n { field: 'age', header: 'Age', width: 90, editorType: 'number' },\n { field: 'salary', header: 'Salary', width: 130, editorType: 'number', format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```diff\n- import Handsontable from 'handsontable'\n- import 'handsontable/dist/handsontable.full.min.css'\n\n+ import {\n+ SvGrid, tableFeatures,\n+ rowSortingFeature, columnFilteringFeature, rowSelectionFeature,\n+ + } from '@svgrid/grid'\n```\n\n## Schema → columns\n\nHandsontable accepts a 2D array OR an array of objects. Sv-grid\nexpects the array-of-objects form:\n\n```diff\n- const hot = new Handsontable(container, {\n- data: [\n- ['Tesla', 2017, 'black', false],\n- ['Nissan', 2018, 'blue', true ],\n- ],\n- colHeaders: ['Make', 'Year', 'Color', 'In stock'],\n- columns: [\n- {},\n- { type: 'numeric' },\n- {},\n- { type: 'checkbox' },\n- ],\n- })\n\n+ type Car = { make: string; year: number; color: string; inStock: boolean }\n+ const rows: Car[] = [\n+ { make: 'Tesla', year: 2017, color: 'black', inStock: false },\n+ { make: 'Nissan', year: 2018, color: 'blue', inStock: true },\n+ ]\n+ const columns: ColumnDef<typeof features, Car>[] = [\n+ { field: 'make', header: 'Make' },\n+ { field: 'year', header: 'Year', editorType: 'number' },\n+ { field: 'color', header: 'Color' },\n+ { field: 'inStock', header: 'In stock', editorType: 'checkbox' },\n+ ]\n+ <SvGrid data={rows} columns={columns} features={features} />\n```\n\nIf your existing data is a 2D array, transform once at boot:\n\n```ts\nconst headerRow = raw[0]\nconst rows = raw.slice(1).map((cells) =>\n Object.fromEntries(cells.map((v, i) => [headerRow[i], v])),\n)\n```\n\n## Editor types\n\n| Handsontable | sv-grid |\n| ---------------------------------------------- | ------------------------------------------------ |\n| `type: 'text'` | `editorType: 'text'` |\n| `type: 'numeric'` | `editorType: 'number'` |\n| `type: 'date'` | `editorType: 'date'` |\n| `type: 'checkbox'` | `editorType: 'checkbox'` |\n| `type: 'dropdown', source: [...]` | `editorType: 'list', editorOptions: [...]` |\n| `type: 'autocomplete'` | `editorType: 'list'` (combobox UI ships) |\n| `type: 'time'` | `editorType: 'time'` |\n| `type: 'password'` | Use `editorType: 'text'` + a custom snippet |\n\n## Selection + clipboard\n\n| Handsontable | sv-grid |\n| --------------------------------------- | ------------------------------------------------------- |\n| `selectCells([[0,0,2,5]])` | `api.selectCells(ranges)`, with `selectionMode='cell'` |\n| `getSelected()` | `api.getSelected()`: the same `[[startRow, startCol, endRow, endCol], ...]`, normalised to top-left / bottom-right and `[]` rather than `undefined` when nothing is selected |\n| `getSelectedLast()` | `api.getSelectedLast()`: `[startRow, startCol, endRow, endCol]` with the orientation kept (a range dragged upwards has `startRow > endRow`), or `undefined` |\n| `getSelectedRange()` / `getSelectedRangeLast()` | `api.getSelectedRange()` / `api.getSelectedRangeLast()`: `{ from, to, highlight }` per rectangle, the same three fields as `CellRange`; plain data rather than a class, so use `Math.min` / `Math.max` for the corners |\n| `copyPaste: true` (default) | Ships built-in; `enableCellSelection={true}`. The spreadsheet shell also exchanges formats, number formats and formulas with Excel and Google Sheets through the HTML clipboard |\n| `fillHandle: true` | Ships built-in (drag-to-fill on selected range) |\n| `moveCells: true` | `moveCells` - drag the range border to move it, Ctrl / Cmd to copy. On by default; see [move or copy a range](./editing/move-cells.md) |\n\n## Frozen rows / columns\n\n| Handsontable | sv-grid |\n| ---------------------------------- | ------------------------------------------------------ |\n| `fixedColumnsStart: 2` | `api.setColumnPinning({ left: ['firstColId', 'secondColId'] })` |\n| `fixedRowsTop: 1` | `pinnedTopRows` (and `pinnedBottomRows`) |\n| `manualColumnFreeze: true` | Ships via the column-header right-click menu |\n| `mergeCells: [{ row, col, rowspan, colspan }]` | `mergedCells={[{ rowIndex, colIndex, rowSpan, colSpan }]}` on `<SvGrid>`; the spreadsheet shell adds Merge & Center, Merge Across, Merge Cells and Unmerge on the ribbon and the cell menu |\n\nFreeze panes on a plain `<SvGrid>`: two pinned columns and the sticky\nletter and number headers, with formulas underneath.\n\n<div data-docs-demo=\"208-freeze-panes\" data-height=\"520\"></div>\n\n## Hooks → callbacks\n\n| Handsontable hook | sv-grid |\n| -------------------------------------- | ------------------------------------------------------- |\n| `afterChange((changes, src) => {})` | `onCellValueChange((e) => {...})` |\n| `afterSelection(...)` | `onActiveCellChange((cell) => {...})` |\n| `afterFilter(...)` | `onFiltersChange((f) => {...})` |\n| `afterColumnSort(...)` | `onSortingChange((s) => {...})` |\n| `beforeChange((changes) => false)` | Throw / return `false` from your inline validator; see [validation](./editing/validation.md) |\n| `afterCreateRow / afterRemoveRow` | Wrap `api.addRow / removeRow` in your own emitter; on the spreadsheet shell, `onChange` reports a `structure` reason with the edit |\n| `afterMergeCells / afterUnmergeCells` | On the spreadsheet shell, `onChange` reports `merges`; `api.getMergedCells()` reads the grid's list |\n| `afterSetCellMeta` (comments, validation) | `onChange` reports `comments`, `validation`, `conditional-formats` and `protection` on the shell |\n| `afterFilter` (the Filters plugin) | `onChange` reports `filter` on the shell; `getState().sheets[name].autoFilter` is the state |\n\nOn the spreadsheet shell (`<SvSheet>` in `@svgrid/enterprise`) one callback\ncovers the lot: `onChange(reasons)` fires once per tick with every kind of\nchange since the last call (`cells`, `formats`, `sizes`, `hidden`,\n`freeze`, `sheets`, `comments`, `protection`, `validation`,\n`conditional-formats`, `merges`, `filter`, `structure` with the insert or\ndelete, `restore`), which is where Handsontable's `afterChange` autosave\ngoes. `getState()` gives the document to save and `setState()` puts it back.\n\n## Cell meta → the sheet document\n\nHandsontable keeps comments, validators, read-only flags and merges as cell\nmeta on the instance. On the spreadsheet shell they are parts of the\ndocument, per sheet, saved with it and moved by inserts and deletes:\n\n| Handsontable | sv-grid spreadsheet shell |\n| --------------------------------------------------- | --------------------------------------------------------------- |\n| `comments: true`, `cell: [{ row, col, comment }]` | Review > New Comment, Shift+F2, the cell menu; `getState().sheets[name].comments` |\n| `validator: 'numeric'`, `allowInvalid: false` | Data > Data Validation (Whole number, Decimal, List, Date, Text length, Custom), Stop or Warning; `validation` in the state |\n| `readOnly: true` on a cell | Format Cells > Protection unlocks the cells that may change, Review > Protect Sheet locks the rest; `locked: false` in the format entry, `protected` per sheet |\n| `mergeCells` | Merge & Center on the ribbon and the cell menu; `merges` in the state |\n| `filters: true` + `dropdownMenu` | Ctrl+Shift+L or Data > Filter: Excel's AutoFilter arrows and menus; `autoFilter` in the state |\n| conditional formatting (a custom renderer) | Home > Styles > Conditional Formatting: Highlight Cells, Top/Bottom, Data Bars, Color Scales, Icon Sets, Manage Rules; `conditionalFormats` in the state |\n| `autofill` rules | The fill handle's own series: numbers, dates by day or by the set gap, `Item 1`, weekdays and months |\n\nOn a plain `<SvGrid>` the free grid has the primitives instead: `notes`\nand `editableComments` for comments, `editable` and `valueParser` on a\ncolumn for read-only cells and validation, `mergedCells` for merges, the\nfilter row and its Excel-style menu for filtering, and value-driven\n`conditionalFormats` by column.\n\n## Formulas (HyperFormula)\n\nSv-grid ships a focused subset under\n[`spreadsheet-formulas`](./spreadsheet-formulas.md) (SUM, IF, COUNTIF\n+ cell refs + ranges + cycle detection). It does NOT ship the full\nHyperFormula library.\n\nIf you need HyperFormula's full surface (~400 functions, complex\ndate math, R1C1 refs), the integration pattern is:\n\n```ts\nimport { HyperFormula } from 'hyperformula'\n\nconst hf = HyperFormula.buildFromArray(rowsAsMatrix, { licenseKey: 'gpl-v3' })\n\n// On every cell edit, push to HF + read back the computed value:\nfunction onCellValueChange(e) {\n hf.setCellContents({ sheet: 0, row: e.rowIndex, col: columnIndex }, e.newValue)\n // ...read computed cells you depend on + push back via api.setCellValue\n}\n```\n\nThis is the only piece where the swap isn't 1:1. Most teams find\nsv-grid's shipped subset covers 80% of real-world formula usage; if\nyours is in the 20%, HyperFormula plugs in with the pattern above.\n\n## Licence\n\nHandsontable's grid needs a commercial licence for commercial use; its free\nHobby licence is for personal, exploratory projects, as its pricing page\nstates (the wording and the date are in the facts box at the top of this\npage). Sv-grid's `@svgrid/grid` is MIT with no licence key, and the\n[Enterprise tier](../enterprise/licensing.md) needs no key to evaluate:\nit works without one and displays a watermark and a console\nmessage until you add one, so there is no cutoff to plan around.\n\n## What you get for free vs Handsontable\n\n- **No commercial licence for the grid.** @svgrid/grid is MIT.\n- **Modern Svelte 5 ergonomics.** `$state` arrays beat\n `loadData(...)`.\n- **Grid features a spreadsheet does not have.** Row grouping with\n aggregation, master/detail, server-side data (grouping, tree, pivot and transactions on the server are Enterprise) and integrated\n charts in the same package.\n- **CSP-clean.** Handsontable's HyperFormula path needs CSP `eval`\n exceptions in some configurations.\n\n## What you give up\n\n- **Full HyperFormula surface as a supported feature.** Sv-grid ships a\n subset and lets you wire HyperFormula yourself; see above.\n- **Nested headers.** Handsontable's `nestedHeaders` have no\n counterpart beyond column groups.\n- **Threaded comments and a validation Input Message.** Comments on the\n shell are Excel's notes, one text per cell; validation shows its message\n on a refusal, not on arrival.\n- **Excel export without a paid pack.** Handsontable's export plugin writes\n XLSX through its asynchronous method; on sv-grid, CSV, TSV and JSON export\n are free and Excel, PDF and print output are in `@svgrid/enterprise`.\n\n## Frequently asked questions\n\n### Can SvGrid replace Handsontable's spreadsheet features?\n\nIt covers roughly 90% of the surface - inline editing on every cell, copy/paste\nas TSV, range selection, column groups, and a built-in formula engine. Budget\n2-4 hours per grid for typical spreadsheet workflows, plus 1-2 days if you lean\nheavily on the full HyperFormula function set.\n\n### Does SvGrid support formulas like Handsontable?\n\nYes, a built-in formula engine with cell references, ranges, and common\nfunctions (see the spreadsheet-formulas guide and demo 27). It is a practical\nsubset rather than the complete HyperFormula library, so verify your specific\nfunctions before porting.\n\n### Is SvGrid licensed like Handsontable?\n\nNo. The `@svgrid/grid` core is MIT and free for commercial use - no per-seat\nlicence key - whereas Handsontable's free licence excludes commercial use and\nits commercial plans are per developer; the facts box has the statement as\nread from handsontable.com. The optional `@svgrid/enterprise` pack (Excel and\nPDF export, print, pivot, import) is priced per developer.\n\n### Does SvGrid have a blank spreadsheet like Handsontable?\n\nYes, on the plain grid: column-letter headers, a row gutter, a name box\nand formula bar, gridlines, range selection and a fill handle, with\nHyperFormula underneath when you want its function library.\n\n<div data-docs-demo=\"207-blank-sheet\" data-height=\"560\"></div>\n\n## What you end up with\n\nSpreadsheet behaviour: inline edit, drag a cell range, copy it out as TSV.\n\n```svelte {runnable}\n<SvGrid data={rows} {columns} editable enableCellSelection statusBar />\n```\n\n## See also\n\n- [SvGrid vs Handsontable](https://svgrid.com/compare/handsontable/) - the side-by-side comparison, with a source and date for every claim\n- [Spreadsheet formulas](./spreadsheet-formulas.md) - sv-grid's\n built-in formula engine, and the rest of the spreadsheet demos\n- [Demo 27 (Spreadsheet + Ribbon)](https://svgrid.com/demos/27-spreadsheet-ribbon/) - live\n- [Migrating from AG Grid](./migrating-from-ag-grid.md)\n"
|
|
4714
4738
|
},
|
|
4715
4739
|
{
|
|
4716
4740
|
"slug": "help/migrating-from-jqxgrid",
|
|
@@ -4857,7 +4881,7 @@ export const docs = [
|
|
|
4857
4881
|
"path": "docs/help/real-time.md",
|
|
4858
4882
|
"title": "Real-time / streaming updates",
|
|
4859
4883
|
"section": "Help",
|
|
4860
|
-
"markdown": "# Real-time / streaming updates\n\nHow to drive the grid from a WebSocket / SSE / poll. Four patterns\nranked by the rate of change:\n\n1. **Periodic full refresh** - poll for the latest rows, swap the array.\n2. **Cell flash on change** - the same swap, but the renderer\n highlights cells whose values just changed.\n3. **Delta merge with backlog** - WebSocket pushes individual row\n patches; you merge them into the in-memory state, optionally\n batching while the user has the page paused.\n4. **A large sorted grid under a tick feed** - ticks batched per frame\n into one transaction; the grid repairs the sort around the rows that\n changed instead of re-sorting them all.\n\n\n\nTry a streaming order desk - cell flashes on every change, pause / resume,\ndisconnect / reconnect, configurable throughput slider:\n\n<div data-docs-demo=\"34-realtime-orders\" data-height=\"520\"></div>\n\n## Pattern 1: periodic full refresh\n\nThe simplest reactive pattern. Poll every N seconds, hand the new\narray down.\n\nEvery example below runs against this setup:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature, type ColumnDef } from '@svgrid/grid'\n\n type Order = { id: string; symbol: string; qty: number; price: number; updatedAt: number }\n\n const features = tableFeatures({ rowSortingFeature })\n\n const fmtMoney = (n: number) =>\n n.toLocaleString('en-US', { style: 'currency', currency: 'USD' })\n\n let rows = $state<Order[]>([\n { id: 'o1', symbol: 'AAPL', qty: 120, price: 231.4, updatedAt: Date.now() },\n { id: 'o2', symbol: 'MSFT', qty: 80, price: 418.9, updatedAt: Date.now() },\n { id: 'o3', symbol: 'NVDA', qty: 45, price: 902.1, updatedAt: Date.now() },\n ])\n const data = rows\n\n const columns: ColumnDef<typeof features, Order>[] = [\n { field: 'symbol', header: 'Symbol', width: 110 },\n { field: 'qty', header: 'Qty', width: 90 },\n { field: 'price', header: 'Price', width: 120, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<script lang=\"ts\">\n let rows = $state<Order[]>([])\n\n $effect(() => {\n const id = setInterval(async () => {\n rows = await fetch('/api/orders').then((r) => r.json())\n }, 5_000)\n return () => clearInterval(id)\n })\n</script>\n\n<SvGrid data={rows} columns={columns} features={features} />\n```\n\nThe grid re-renders the visible rows; virtualization keeps the cost\nproportional to the viewport, not the dataset.\n\n**Use when:** dataset is small (< 1000 rows), update rate is low (≥ 5 s),\n\"freshness\" is the only requirement.\n\n**Avoid when:** the user is mid-edit on a cell. A full swap mid-edit\nwill close the editor. Pause your refresh while\n`onActiveCellChange` reports a non-null active cell.\n\n## Pattern 2: cell flash on change\n\nSame swap, but each cell snippet tracks its previous value and renders\na brief highlight when it differs.\n\n```svelte {runnable}\n<script lang=\"ts\">\n // Track per-row, per-field last-seen values.\n let lastSeen = new Map<string, Record<string, unknown>>()\n function diff(rowId: string, current: Record<string, unknown>): Record<string, boolean> {\n const prev = lastSeen.get(rowId)\n const changed: Record<string, boolean> = {}\n if (prev) for (const k of Object.keys(current)) if (prev[k] !== current[k]) changed[k] = true\n lastSeen.set(rowId, { ...current })\n return changed\n }\n</script>\n\n{#snippet PriceCell(props: { row: Order })}\n {@const changes = diff(props.row.id, props.row)}\n <span class={`tabular-nums ${changes.price ? 'flash' : ''}`}>\n {fmtMoney(props.row.price)}\n </span>\n{/snippet}\n```\n\n```css\n.flash {\n animation: flash-bg 800ms ease-out;\n}\n@keyframes flash-bg {\n from { background: rgba(250, 204, 21, 0.4); }\n to { background: transparent; }\n}\n```\n\n**Use when:** user wants to spot changes. The stock-ticker pattern.\n\n**Avoid when:** the flash interferes with selection or accessibility -\nadd `prefers-reduced-motion` guards if the flash is purely\ndecorative.\n\n## Pattern 3: delta merge with backlog\n\nThe grown-up pattern for higher-rate updates. The server pushes\nindividual row patches; you maintain an in-memory map and reassign the\narray when needed.\n\n```svelte {runnable}\n<script lang=\"ts\">\n type OrderId = string\n let rowsMap = $state(new Map<OrderId, Order>())\n let paused = $state(false)\n let backlog = $state<Order[]>([])\n const flushDebounce = 200 // ms\n\n const rows = $derived(Array.from(rowsMap.values()))\n\n $effect(() => {\n const ws = new WebSocket('/api/orders/stream')\n ws.onmessage = (e) => {\n const patch: Order = JSON.parse(e.data)\n if (paused) {\n backlog = [...backlog, patch]\n return\n }\n applyPatch(patch)\n }\n return () => ws.close()\n })\n\n function applyPatch(patch: Order) {\n const next = new Map(rowsMap)\n next.set(patch.id, patch)\n rowsMap = next\n }\n\n function resume() {\n paused = false\n for (const p of backlog) applyPatch(p)\n backlog = []\n }\n</script>\n\n<button onclick={() => (paused = !paused)}>\n {paused ? `Resume (${backlog.length} pending)` : 'Pause'}\n</button>\n\n<SvGrid data={rows} columns={columns} features={features} />\n```\n\nA few production knobs:\n\n- **Debounce the resignment.** A WebSocket spitting out 20 patches per\n second creates 20 array allocations per second. Batch into the next\n `requestAnimationFrame`:\n\n ```ts\n let pendingPatches: Order[] = []\n let scheduled = false\n function applyPatch(patch: Order) {\n pendingPatches.push(patch)\n if (scheduled) return\n scheduled = true\n requestAnimationFrame(() => {\n const next = new Map(rowsMap)\n for (const p of pendingPatches) next.set(p.id, p)\n rowsMap = next\n pendingPatches = []\n scheduled = false\n })\n }\n ```\n\n- **Out-of-order safety.** Each patch carries a server-side sequence\n number; drop patches older than the latest you've applied for that\n row. The streaming demo (#34) shows this with `lastSeq` per row.\n\n- **Disconnect handling.** On `ws.onclose`, set a status flag the user\n sees (\"reconnecting…\") and reconnect with exponential backoff. The\n demo shows the recovery flow.\n\n## Pause during edit / selection\n\nA common mistake: updates fly in while the user is mid-paste or\nmid-edit. The fix is two flags:\n\n```ts\nlet isEditing = $state(false)\nlet hasSelection = $state(false)\nconst isInteracting = $derived(isEditing || hasSelection)\n\n$effect(() => {\n if (isInteracting) {\n paused = true\n } else if (paused) {\n resume()\n }\n})\n```\n\nWire to the grid:\n\n```svelte\n<SvGrid\n ...\n onCellDoubleClick={() => (isEditing = true)}\n onCellValueChange={() => (isEditing = false)}\n onRowSelectionChange={(selection, rows) => (hasSelection = rows.length > 0)}\n/>\n```\n\n`onRowSelectionChange` receives two positional arguments - the\n`{ [rowId]: true }` record and the array of selected rows.\n\nThere is no dedicated editing-state callback today, so the edit flag is\nassembled from the two ends of the edit: a double-click opens the\neditor, and a committed value closes it. That leaves one gap - an edit\nabandoned with `Escape` commits nothing, so clear the flag on the\nwrapper's `onkeydown` too:\n\n```svelte\n<div onkeydown={(e) => { if (e.key === 'Escape') isEditing = false }}>\n <SvGrid ... />\n</div>\n```\n\nIf your app starts edits itself through `api.startEditing()` /\n`api.stopEditing()`, set the flag at those call sites instead - you\nalready know the state there, and it covers every exit path.\n\n## Backpressure (when the server is too fast)\n\nIf your producer can outrun the UI's frame budget, sample down at the\nclient. Drop intermediate patches for the same row; only the newest\none survives:\n\n```ts\nfunction applyPatch(patch: Order) {\n // pendingPatches is keyed by row id so a fast-moving row only\n // commits its NEWEST value per frame.\n pendingPatches.set(patch.id, patch)\n /* ...schedule rAF... */\n}\n```\n\nFor 1000 rows updating at 5 Hz, this caps the work at 1000\nassignments per frame regardless of the actual message rate. The\nstreaming demo's \"throughput slider\" stresses this exact path.\n\n## Pattern 4: a large sorted grid under a tick feed\n\nA blotter is sorted by something that ticks, and it has more rows than the\npatterns above assume. Two facts decide the design.\n\n**Which of the two update paths you take.** Writing a field through a\n`$state` proxy (`rows[i].price = x`) re-renders that one cell and nothing\nelse: the row model caches on the array reference, which did not change, so\nthe sort and the filters are stale for that row until something replaces the\narray. Replacing the array (`api.applyTransaction`, or `rows = next`) runs the\nrow model, and the order is current again. For a sorted blotter the second\npath is the correct one, and the question is how often.\n\n**What a data change costs at that row count.** Every array replacement used\nto re-sort every row - 100,000 rows in about 30 ms in the engine alone, so a\nfeed ticking faster than that could not keep up. The grid now repairs the\nsort instead: when the new array has the same length and only some of its\nobjects were replaced, the sorted stage keeps its previous output, drops the\nreplaced rows, sorts the replacements among themselves and merges them back\nin one pass; a 1,000-row tick on 100,000 sorted rows is about 8 ms in the\nengine, measured on the [benchmarks page](./benchmarks.md#streaming-updates).\nThe filtered stage does the same for a replacement whose membership did not\nchange. The repaired order is exactly the order a full sort would produce.\n\nSo the pattern is: batch ticks per animation frame into one\n`applyTransaction({ update })`, and never mutate a row in place on this\npath. The repair assumes the rows it keeps are unchanged in value as well\nas identity, which an immutable update guarantees. A feed that mutates rows\nin place must pass a new array with no replacements (`rows = [...rows]`) to\nrefresh, and that runs the full pipeline.\n\n```ts\n// Ticks arrive as JSON batches; the newest price per id wins the frame.\nconst pending = new Map<string, number>()\nlet frame: number | null = null\n\nsocket.onmessage = (e) => {\n for (const [id, last] of JSON.parse(e.data).t) pending.set(id, last)\n if (frame === null) frame = requestAnimationFrame(flush)\n}\n\nfunction flush() {\n frame = null\n const byId = new Map(api.getData().map((r) => [r.id, r]))\n const update = []\n for (const [id, last] of pending) {\n const row = byId.get(id)\n if (row) update.push({ ...row, last, direction: last > row.last ? 'up' : 'down' })\n }\n pending.clear()\n api.applyTransaction({ update }) // one data change, one pipeline run\n}\n```\n\nWhen the repair does not apply and the full pipeline runs instead: the tick\nadded or removed rows (structural), it replaced more than a quarter of the\nrows, the sort or filter state changed in the same frame, grouping is\nactive (group rows are rebuilt from the sorted output), or a replaced text\nvalue is one the ranked text sort had never seen. None of these is wrong,\nonly slower, and each is the full-sort cost above.\n\nThe [market blotter demo](https://svgrid.com/demos/495-market-blotter-100k/)\nruns this at 100,000 rows with a live frame-time readout, over an in-page\nsocket or your own (`?ws=`).\n\n## Combining with sort + filter\n\nThe grid's sort + filter run AFTER your patches land in `rows`. Two\nimplications:\n\n- **A row that no longer matches the filter disappears.** Expected.\n- **The active row may move.** When `onActiveCellChange` fires with a\n new index because the row above shifted, your toolbar / detail panel\n should follow.\n\nIf you're using a side detail panel keyed on row id (not index),\nthis is a non-issue - the detail stays bound to the order even as\nthe row moves.\n\n## Frequently asked questions\n\n### How do I show real-time data in SvGrid?\n\nDrive the grid's `data` from a WebSocket, SSE, or poll. This page covers three\npatterns ranked by update rate: periodic full refresh, targeted row patches via\n`getRowId`, and high-frequency cell updates with change-flash highlighting.\n\n### Will the grid keep my scroll and selection on live updates?\n\nYes, if you give rows a stable identity with `getRowId`. Then selection,\nexpansion, edit state, and scroll position survive incoming updates instead of\nresetting on every refresh.\n\n### How fast can SvGrid update?\n\nFast enough for tick-by-tick feeds. The stock-market demo updates 25 symbols\nevery 250 ms with green/red cell flashes while sorting and selection stay\nlive, and the market blotter demo keeps 100,000 rows sorted under tens of\nthousands of updates a second. Batch the ticks per animation frame into one\n`applyTransaction`; the engine cost of a 1,000-row tick on 100,000 sorted\nrows is on the [benchmarks page](./benchmarks.md#streaming-updates).\n\n## See also\n\n- [Server-side data](./server-side-data.md) - the pull side of\n remote data.\n- [Saved views](./saved-views.md) - persist a \"live mode on / off\"\n toggle alongside the rest of the view config.\n- [Performance benchmarks](./benchmarks.md) - measured per-frame cost\n of the patterns above, and the cost of a tick on a sorted grid.\n- [Transactions](./rows/transactions.md) - the `applyTransaction`\n contract and what an update batch costs.\n- [Svelte trading grid](https://svgrid.com/svelte/trading-grid/) - the\n blotter landing page: the 100,000-row demo, the formats and the flash.\n"
|
|
4884
|
+
"markdown": "# Real-time / streaming updates\n\nHow to drive the grid from a WebSocket / SSE / poll. Four patterns\nranked by the rate of change:\n\n1. **Periodic full refresh** - poll for the latest rows, swap the array.\n2. **Cell flash on change** - the same swap, but the renderer\n highlights cells whose values just changed.\n3. **Delta merge with backlog** - WebSocket pushes individual row\n patches; you merge them into the in-memory state, optionally\n batching while the user has the page paused.\n4. **A large sorted grid under a tick feed** - ticks batched per frame\n into one transaction; the grid repairs the sort around the rows that\n changed instead of re-sorting them all.\n\n\n\nTry a streaming order desk - cell flashes on every change, pause / resume,\ndisconnect / reconnect, configurable throughput slider:\n\n<div data-docs-demo=\"34-realtime-orders\" data-height=\"520\"></div>\n\n## Pattern 1: periodic full refresh\n\nThe simplest reactive pattern. Poll every N seconds, hand the new\narray down.\n\nEvery example below runs against this setup:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature, type ColumnDef } from '@svgrid/grid'\n\n type Order = { id: string; symbol: string; qty: number; price: number; updatedAt: number }\n\n const features = tableFeatures({ rowSortingFeature })\n\n const fmtMoney = (n: number) =>\n n.toLocaleString('en-US', { style: 'currency', currency: 'USD' })\n\n let rows = $state<Order[]>([\n { id: 'o1', symbol: 'AAPL', qty: 120, price: 231.4, updatedAt: Date.now() },\n { id: 'o2', symbol: 'MSFT', qty: 80, price: 418.9, updatedAt: Date.now() },\n { id: 'o3', symbol: 'NVDA', qty: 45, price: 902.1, updatedAt: Date.now() },\n ])\n const data = rows\n\n const columns: ColumnDef<typeof features, Order>[] = [\n { field: 'symbol', header: 'Symbol', width: 110 },\n { field: 'qty', header: 'Qty', width: 90 },\n { field: 'price', header: 'Price', width: 120, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<script lang=\"ts\">\n let rows = $state<Order[]>([])\n\n $effect(() => {\n const id = setInterval(async () => {\n rows = await fetch('/api/orders').then((r) => r.json())\n }, 5_000)\n return () => clearInterval(id)\n })\n</script>\n\n<SvGrid data={rows} columns={columns} features={features} />\n```\n\nThe grid re-renders the visible rows; virtualization keeps the cost\nproportional to the viewport, not the dataset.\n\n**Use when:** dataset is small (< 1000 rows), update rate is low (≥ 5 s),\n\"freshness\" is the only requirement.\n\n**Avoid when:** the user is mid-edit on a cell. A full swap mid-edit\nwill close the editor. Pause your refresh while\n`onActiveCellChange` reports a non-null active cell.\n\n## Pattern 2: cell flash on change\n\nSame swap, but each cell snippet tracks its previous value and renders\na brief highlight when it differs.\n\n```svelte {runnable}\n<script lang=\"ts\">\n // Track per-row, per-field last-seen values.\n let lastSeen = new Map<string, Record<string, unknown>>()\n function diff(rowId: string, current: Record<string, unknown>): Record<string, boolean> {\n const prev = lastSeen.get(rowId)\n const changed: Record<string, boolean> = {}\n if (prev) for (const k of Object.keys(current)) if (prev[k] !== current[k]) changed[k] = true\n lastSeen.set(rowId, { ...current })\n return changed\n }\n</script>\n\n{#snippet PriceCell(props: { row: Order })}\n {@const changes = diff(props.row.id, props.row)}\n <span class={`tabular-nums ${changes.price ? 'flash' : ''}`}>\n {fmtMoney(props.row.price)}\n </span>\n{/snippet}\n```\n\n```css\n.flash {\n animation: flash-bg 800ms ease-out;\n}\n@keyframes flash-bg {\n from { background: rgba(250, 204, 21, 0.4); }\n to { background: transparent; }\n}\n```\n\n**Use when:** user wants to spot changes. The stock-ticker pattern.\n\n**Avoid when:** the flash interferes with selection or accessibility -\nadd `prefers-reduced-motion` guards if the flash is purely\ndecorative.\n\n## Pattern 3: delta merge with backlog\n\nThe grown-up pattern for higher-rate updates. The server pushes\nindividual row patches; you maintain an in-memory map and reassign the\narray when needed.\n\n```svelte {runnable}\n<script lang=\"ts\">\n type OrderId = string\n let rowsMap = $state(new Map<OrderId, Order>())\n let paused = $state(false)\n let backlog = $state<Order[]>([])\n const flushDebounce = 200 // ms\n\n const rows = $derived(Array.from(rowsMap.values()))\n\n $effect(() => {\n const ws = new WebSocket('/api/orders/stream')\n ws.onmessage = (e) => {\n const patch: Order = JSON.parse(e.data)\n if (paused) {\n backlog = [...backlog, patch]\n return\n }\n applyPatch(patch)\n }\n return () => ws.close()\n })\n\n function applyPatch(patch: Order) {\n const next = new Map(rowsMap)\n next.set(patch.id, patch)\n rowsMap = next\n }\n\n function resume() {\n paused = false\n for (const p of backlog) applyPatch(p)\n backlog = []\n }\n</script>\n\n<button onclick={() => (paused = !paused)}>\n {paused ? `Resume (${backlog.length} pending)` : 'Pause'}\n</button>\n\n<SvGrid data={rows} columns={columns} features={features} />\n```\n\nA few production knobs:\n\n- **Debounce the resignment.** A WebSocket spitting out 20 patches per\n second creates 20 array allocations per second. Batch into the next\n `requestAnimationFrame`:\n\n ```ts\n let pendingPatches: Order[] = []\n let scheduled = false\n function applyPatch(patch: Order) {\n pendingPatches.push(patch)\n if (scheduled) return\n scheduled = true\n requestAnimationFrame(() => {\n const next = new Map(rowsMap)\n for (const p of pendingPatches) next.set(p.id, p)\n rowsMap = next\n pendingPatches = []\n scheduled = false\n })\n }\n ```\n\n- **Out-of-order safety.** Each patch carries a server-side sequence\n number; drop patches older than the latest you've applied for that\n row. The streaming demo (#34) shows this with `lastSeq` per row.\n\n- **Disconnect handling.** On `ws.onclose`, set a status flag the user\n sees (\"reconnecting…\") and reconnect with exponential backoff. The\n demo shows the recovery flow.\n\n## Pause during edit / selection\n\nA common mistake: updates fly in while the user is mid-paste or\nmid-edit. The fix is two flags:\n\n```ts\nlet isEditing = $state(false)\nlet hasSelection = $state(false)\nconst isInteracting = $derived(isEditing || hasSelection)\n\n$effect(() => {\n if (isInteracting) {\n paused = true\n } else if (paused) {\n resume()\n }\n})\n```\n\nWire to the grid:\n\n```svelte\n<SvGrid\n ...\n onCellDoubleClick={() => (isEditing = true)}\n onCellValueChange={() => (isEditing = false)}\n onRowSelectionChange={(selection, rows) => (hasSelection = rows.length > 0)}\n/>\n```\n\n`onRowSelectionChange` receives two positional arguments - the\n`{ [rowId]: true }` record and the array of selected rows.\n\nThere is no dedicated editing-state callback today, so the edit flag is\nassembled from the two ends of the edit: a double-click opens the\neditor, and a committed value closes it. That leaves one gap - an edit\nabandoned with `Escape` commits nothing, so clear the flag on the\nwrapper's `onkeydown` too:\n\n```svelte\n<div onkeydown={(e) => { if (e.key === 'Escape') isEditing = false }}>\n <SvGrid ... />\n</div>\n```\n\nIf your app starts edits itself through `api.startEditing()` /\n`api.stopEditing()`, set the flag at those call sites instead - you\nalready know the state there, and it covers every exit path.\n\n## Backpressure (when the server is too fast)\n\nIf your producer can outrun the UI's frame budget, sample down at the\nclient. Drop intermediate patches for the same row; only the newest\none survives:\n\n```ts\nfunction applyPatch(patch: Order) {\n // pendingPatches is keyed by row id so a fast-moving row only\n // commits its NEWEST value per frame.\n pendingPatches.set(patch.id, patch)\n /* ...schedule rAF... */\n}\n```\n\nFor 1000 rows updating at 5 Hz, this caps the work at 1000\nassignments per frame regardless of the actual message rate. The\nstreaming demo's \"throughput slider\" stresses this exact path.\n\n## Pattern 4: a large sorted grid under a tick feed\n\nA blotter is sorted by something that ticks, and it has more rows than the\npatterns above assume. Two facts decide the design.\n\n**Which of the two update paths you take.** Writing a field through a\n`$state` proxy (`rows[i].price = x`) re-renders that one cell and nothing\nelse: the row model caches on the array reference, which did not change, so\nthe sort and the filters are stale for that row until something replaces the\narray. Replacing the array (`api.applyTransaction`, or `rows = next`) runs the\nrow model, and the order is current again. For a sorted blotter the second\npath is the correct one, and the question is how often. Adding or removing a\nrow through the proxy (`rows.push(...)`, `rows.splice(...)`) is tracked on its\nown: the row count is a dependency, so the grid rebuilds without you replacing\nthe array. It is the right call for a user clicking \"Add row\" and the wrong one\nfor a tick feed, which changes values rather than the row count.\n\n**What a data change costs at that row count.** Every array replacement used\nto re-sort every row - 100,000 rows in about 30 ms in the engine alone, so a\nfeed ticking faster than that could not keep up. The grid now repairs the\nsort instead: when the new array has the same length and only some of its\nobjects were replaced, the sorted stage keeps its previous output, drops the\nreplaced rows, sorts the replacements among themselves and merges them back\nin one pass; a 1,000-row tick on 100,000 sorted rows is about 8 ms in the\nengine, measured on the [benchmarks page](./benchmarks.md#streaming-updates).\nThe filtered stage does the same for a replacement whose membership did not\nchange. The repaired order is exactly the order a full sort would produce.\n\nSo the pattern is: batch ticks per animation frame into one\n`applyTransaction({ update })`, and never mutate a row in place on this\npath. The repair assumes the rows it keeps are unchanged in value as well\nas identity, which an immutable update guarantees. A feed that mutates rows\nin place must pass a new array with no replacements (`rows = [...rows]`) to\nrefresh, and that runs the full pipeline.\n\n```ts\n// Ticks arrive as JSON batches; the newest price per id wins the frame.\nconst pending = new Map<string, number>()\nlet frame: number | null = null\n\nsocket.onmessage = (e) => {\n for (const [id, last] of JSON.parse(e.data).t) pending.set(id, last)\n if (frame === null) frame = requestAnimationFrame(flush)\n}\n\nfunction flush() {\n frame = null\n const byId = new Map(api.getData().map((r) => [r.id, r]))\n const update = []\n for (const [id, last] of pending) {\n const row = byId.get(id)\n if (row) update.push({ ...row, last, direction: last > row.last ? 'up' : 'down' })\n }\n pending.clear()\n api.applyTransaction({ update }) // one data change, one pipeline run\n}\n```\n\nWhen the repair does not apply and the full pipeline runs instead: the tick\nadded or removed rows (structural), it replaced more than a quarter of the\nrows, the sort or filter state changed in the same frame, grouping is\nactive (group rows are rebuilt from the sorted output), or a replaced text\nvalue is one the ranked text sort had never seen. None of these is wrong,\nonly slower, and each is the full-sort cost above.\n\nThe [market blotter demo](https://svgrid.com/demos/495-market-blotter-100k/)\nruns this at 100,000 rows with a live frame-time readout, over an in-page\nsocket or your own (`?ws=`).\n\n## Combining with sort + filter\n\nThe grid's sort + filter run AFTER your patches land in `rows`. Two\nimplications:\n\n- **A row that no longer matches the filter disappears.** Expected.\n- **The active row may move.** When `onActiveCellChange` fires with a\n new index because the row above shifted, your toolbar / detail panel\n should follow.\n\nIf you're using a side detail panel keyed on row id (not index),\nthis is a non-issue - the detail stays bound to the order even as\nthe row moves.\n\n## Frequently asked questions\n\n### How do I show real-time data in SvGrid?\n\nDrive the grid's `data` from a WebSocket, SSE, or poll. This page covers three\npatterns ranked by update rate: periodic full refresh, targeted row patches via\n`getRowId`, and high-frequency cell updates with change-flash highlighting.\n\n### Will the grid keep my scroll and selection on live updates?\n\nYes, if you give rows a stable identity with `getRowId`. Then selection,\nexpansion, edit state, and scroll position survive incoming updates instead of\nresetting on every refresh.\n\n### How fast can SvGrid update?\n\nFast enough for tick-by-tick feeds. The stock-market demo updates 25 symbols\nevery 250 ms with green/red cell flashes while sorting and selection stay\nlive, and the market blotter demo keeps 100,000 rows sorted under tens of\nthousands of updates a second. Batch the ticks per animation frame into one\n`applyTransaction`; the engine cost of a 1,000-row tick on 100,000 sorted\nrows is on the [benchmarks page](./benchmarks.md#streaming-updates).\n\n## See also\n\n- [Server-side data](./server-side-data.md) - the pull side of\n remote data.\n- [Saved views](./saved-views.md) - persist a \"live mode on / off\"\n toggle alongside the rest of the view config.\n- [Performance benchmarks](./benchmarks.md) - measured per-frame cost\n of the patterns above, and the cost of a tick on a sorted grid.\n- [Transactions](./rows/transactions.md) - the `applyTransaction`\n contract and what an update batch costs.\n- [Svelte trading grid](https://svgrid.com/svelte/trading-grid/) - the\n blotter landing page: the 100,000-row demo, the formats and the flash.\n"
|
|
4861
4885
|
},
|
|
4862
4886
|
{
|
|
4863
4887
|
"slug": "help/recipes",
|
|
@@ -4941,7 +4965,7 @@ export const docs = [
|
|
|
4941
4965
|
"path": "docs/help/rows/row-sorting.md",
|
|
4942
4966
|
"title": "Row sorting",
|
|
4943
4967
|
"section": "Rows",
|
|
4944
|
-
"markdown": "# Row sorting\n\nSorting is a feature you opt into. Try it live - click any column\nheader to cycle `none → asc → desc → none`; shift-click adds the\ncolumn to the sort key list (multi-sort):\n\n<div data-docs-demo=\"02-sort-filter-paginate\" data-height=\"440\"></div>\n\n\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n department: string\n age: number\n salary: number\n city: string\n startDate: string\n active: boolean\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering', age: 36, salary: 142000, city: 'London', startDate: '2021-03-01', active: true },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', department: 'Engineering', age: 45, salary: 168000, city: 'New York', startDate: '2019-07-15', active: true },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', department: 'Platform', age: 54, salary: 155000, city: 'Portland', startDate: '2020-01-20', active: false },\n { id: 4, name: 'Radia Perlman', email: 'radia@example.com', department: 'Networking', age: 49, salary: 161000, city: 'Seattle', startDate: '2022-09-05', active: true },\n { id: 5, name: 'Barbara Liskov', email: 'barbara@example.com', department: 'Platform', age: 52, salary: 172000, city: 'Boston', startDate: '2018-11-11', active: true },\n ]\n\n const data = people\n</script>\n```\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid, tableFeatures, rowSortingFeature, type ColumnDef,\n } from '@svgrid/grid'\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First name', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n { field: 'joinedAt', header: 'Joined', editorType: 'date' },\n ]\n</script>\n\n<SvGrid {data} {columns} features={features} />\n```\n\nClicking a sortable header toggles `none → asc → desc → none`. Shift-click\nadds the column to the sort key list (multi-sort).\n\n## Sort functions\n\n`sortFns` exposes the built-in comparators:\n\n```ts\nimport { sortFns } from '@svgrid/grid'\n// sortFns.auto -
|
|
4968
|
+
"markdown": "# Row sorting\n\nSorting is a feature you opt into. Try it live - click any column\nheader to cycle `none → asc → desc → none`; shift-click adds the\ncolumn to the sort key list (multi-sort):\n\n<div data-docs-demo=\"02-sort-filter-paginate\" data-height=\"440\"></div>\n\n\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n department: string\n age: number\n salary: number\n city: string\n startDate: string\n active: boolean\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering', age: 36, salary: 142000, city: 'London', startDate: '2021-03-01', active: true },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', department: 'Engineering', age: 45, salary: 168000, city: 'New York', startDate: '2019-07-15', active: true },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', department: 'Platform', age: 54, salary: 155000, city: 'Portland', startDate: '2020-01-20', active: false },\n { id: 4, name: 'Radia Perlman', email: 'radia@example.com', department: 'Networking', age: 49, salary: 161000, city: 'Seattle', startDate: '2022-09-05', active: true },\n { id: 5, name: 'Barbara Liskov', email: 'barbara@example.com', department: 'Platform', age: 52, salary: 172000, city: 'Boston', startDate: '2018-11-11', active: true },\n ]\n\n const data = people\n</script>\n```\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid, tableFeatures, rowSortingFeature, type ColumnDef,\n } from '@svgrid/grid'\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First name', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n { field: 'joinedAt', header: 'Joined', editorType: 'date' },\n ]\n</script>\n\n<SvGrid {data} {columns} features={features} />\n```\n\nClicking a sortable header toggles `none → asc → desc → none`. Shift-click\nadds the column to the sort key list (multi-sort).\n\n## Sort functions\n\n`sortFns` exposes the built-in comparators:\n\n```ts\nimport { sortFns } from '@svgrid/grid'\n// sortFns.auto - blanks, then numbers numerically, then text (default for unknown types)\n// sortFns.number - numeric; blanks and non-numeric values ('n/a') first\n// sortFns.date - Date-parsed; blanks and invalid dates first\n```\n\nAll three sort blanks (`null`, `undefined`, `''`) first ascending and last\ndescending, and never return `NaN`, so a column with placeholder text still\nsorts into a consistent order.\n\nThe grid picks the comparator based on the column's `editorType`:\n\n| editorType | comparator |\n| ---------- | ---------- |\n| `'number'` | `sortFns.number` |\n| `'date'` \\| `'datetime'` | `sortFns.date` |\n| anything else | `sortFns.auto` |\n\nIf your column has a non-trivial type, set `editorType` even if you do not\nwant inline editing - it is what tells sort and filter how to behave.\n\n## Programmatic sort\n\n```ts\napi.setSort('age', 'desc') // sort by age descending\napi.setSort('age', null) // clear sort on this column\napi.clearSort() // clear all sort\n```\n\n`setSort` replaces any existing sort with a single clause. For\nmulti-column sort, the user clicks the second column header with\n**Shift** held (the in-grid affordance). A multi-sort setter on the API\nis tracked in [Missing features](../missing-features.md).\n\nTo observe sort changes from outside the grid:\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n onSortingChange={(next) => (sorting = next)}\n/>\n```\n\n## Disable sort per column\n\nNot yet first-class - there is no `enableSorting: false` field. To\nprevent a column from being sortable, omit `rowSortingFeature` from\n`tableFeatures(...)`, or render a custom header via `header: () =>\nrenderSnippet(...)` that doesn't bind to the sort button.\n\n## Sorting + server-side data\n\nWhen sort happens on the backend, pair `externalSort={true}` with\n`onSortingChange` and round-trip the sort clauses through your\nfetcher. See the [`09-server-side` demo](../../../examples/src/demos/09-server-side.svelte).\n\n## Multi-column sort\n\nShift-click a second header and the sort becomes ordered: department first,\nthen salary within it. The order you click is the order applied, which is why\nthe header shows a position number once more than one is active.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n salary: number\n bio: string\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', salary: 142000,\n bio: 'Wrote the first algorithm intended for a machine, and the first account of what a general-purpose computer could do beyond arithmetic.' },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', salary: 168000,\n bio: 'Built the first compiler and argued that programs should be written in something closer to English than to machine code.' },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', salary: 155000,\n bio: 'Wrote a kernel and, later, the version control system that most of the industry now runs on.' },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'department', header: 'Department', width: 170 },\n { field: 'name', header: 'Name', width: 180 },\n { field: 'salary', header: 'Salary', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={seed} {columns} sortable />\n```\n\n\n## Reading the sort model\n\n`onSortingChange` reports the model as an ordered array, which is exactly the\nshape you persist to a URL or hand to a server.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n salary: number\n bio: string\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', salary: 142000,\n bio: 'Wrote the first algorithm intended for a machine, and the first account of what a general-purpose computer could do beyond arithmetic.' },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', salary: 168000,\n bio: 'Built the first compiler and argued that programs should be written in something closer to English than to machine code.' },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', salary: 155000,\n bio: 'Wrote a kernel and, later, the version control system that most of the industry now runs on.' },\n ]\n\n let model = $state('[]')\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'city', header: 'City', width: 150 },\n { field: 'salary', header: 'Salary', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid\n data={seed}\n {columns}\n sortable\n onSortingChange={(s) => (model = JSON.stringify(s))}\n/>\n\n<p>Sort model: <code>{model}</code></p>\n```\n\n## See also\n\n- [Filter API](../filtering/filter-api.md)\n- [Server-side guide](../../getting-started.md#11-server-side-data)\n"
|
|
4945
4969
|
},
|
|
4946
4970
|
{
|
|
4947
4971
|
"slug": "help/rows/row-spanning",
|
|
@@ -4962,7 +4986,7 @@ export const docs = [
|
|
|
4962
4986
|
"path": "docs/help/rows/selection-bar.md",
|
|
4963
4987
|
"title": "Bulk-action bar",
|
|
4964
4988
|
"section": "Rows",
|
|
4965
|
-
"markdown": "# Bulk-action bar\n\nTick some rows and a bar floats over the grid holding the count, the actions\nthat apply to the whole selection, and a way to clear it - the pattern an issue\ntracker uses for bulk edit.\n\n<div data-docs-demo=\"430-selection-bar\" data-height=\"560\"></div>\n\nEvery example below runs against this setup. The bar *renderer* ships in\n`@svgrid/enterprise`, so `enableSelectionBar()` is what lights it up - the\n`selectionBar` prop and its types are part of the free grid. Without the\nrenderer the grid shows a note in the bar's place saying it is an Enterprise\nfeature and pointing at [licensing](https://svgrid.com/pricing/).\n\nLike the rest of Enterprise this comes with the 30-day evaluation period:\nwith the package installed the bar works whether or not a license key is\nset, and without one the grid displays the watermark and the console\nmessage about the Enterprise package. Set a key with `setLicenseKey()`\nbefore shipping.\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SelectionBarAction } from '@svgrid/grid'\n import { enableSelectionBar } from '@svgrid/enterprise'\n\n enableSelectionBar()\n\n type Task = {\n id: number\n key: string\n title: string\n assignee: string\n status: 'Open' | 'In progress' | 'Done'\n }\n\n const tasks: Task[] = [\n { id: 1, key: 'PLAT-412', title: 'Virtualize the audit log', assignee: 'A. Osei', status: 'Open' },\n { id: 2, key: 'PLAT-413', title: 'Retry webhook delivery', assignee: 'R. Vance', status: 'In progress' },\n { id: 3, key: 'PLAT-414', title: 'Cache the tenant lookup', assignee: 'M. Iqbal', status: 'Done' },\n { id: 4, key: 'PLAT-415', title: 'Fix timezone drift', assignee: 'J. Lindqvist', status: 'Open' },\n { id: 5, key: 'PLAT-416', title: 'Batch the settings writes', assignee: 'D. Okonkwo', status: 'Done' },\n ]\n\n const columns: GridColumns<Task> = [\n { field: 'key', header: 'Key', width: 110 },\n { field: 'title', header: 'Summary', width: 240 },\n { field: 'assignee', header: 'Assignee', width: 150 },\n { field: 'status', header: 'Status', width: 130 },\n ]\n</script>\n```\n\n## The smallest version\n\n`selectionBar` on its own gives the count and a clear button. Nothing else is\nrequired - `showRowSelection` puts the checkboxes there, and the bar reacts to\nwhatever ends up selected:\n\n```svelte {runnable}\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar />\n```\n\n## Adding actions\n\nAn array is the shorthand for `{ actions }`. Each action gets the whole\nselection as `{ rows, ids }`, in **display order** - so it reads the same order\nthe user is looking at, even after a sort:\n\n```svelte {runnable}\n<script lang=\"ts\">\n let log = $state<string[]>([])\n\n const actions: SelectionBarAction<Task>[] = [\n {\n key: 'done',\n label: 'Mark done',\n icon: 'M20 6 9 17l-5-5',\n action: ({ ids }) => (log = [`Marked ${ids.length} done`, ...log]),\n },\n {\n key: 'delete',\n label: 'Delete',\n icon: 'M3 6h18M8 6V4h8v2m-1 0v14H9V6',\n danger: true,\n action: ({ ids }) => (log = [`Deleted ${ids.join(', ')}`, ...log]),\n },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={actions} />\n\n<ul>\n {#each log as line, i (i)}<li>{line}</li>{/each}\n</ul>\n```\n\n`icon` is the `d` of a single 24x24 SVG path, so an action array stays plain\ndata you can lift into its own module next to the columns.\n\n## Built-in actions\n\nStrings in the array are built-ins, so the common buttons need no wiring:\n\n| Key | Does |\n| --- | --- |\n| `'selectAll'` | Selects every row in the view, then disables itself |\n| `'editFields'` | Opens the bulk-edit drawer - see below |\n| `'separator'` | Draws a divider. Not counted against `maxVisible` |\n\nMix them with your own actions in one array:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const bar: Array<SelectionBarAction<Task> | 'selectAll' | 'editFields' | 'separator'> = [\n 'selectAll',\n 'editFields',\n 'separator',\n { key: 'watch', label: 'Watch', action: () => {} },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={bar} />\n```\n\n## Bulk edit\n\n`'editFields'` opens a **drawer** - the same `SvDrawer` + `SvForm` the grid\nuses for single-row editing - holding one control per editable column. Change\nas many fields as you like and they apply to **every selected row**, as a\nsingle undo run.\n\nThe rule that makes that safe: **only fields you actually change are written.**\n\n| The field | Opens showing | Leave it alone and |\n| --- | --- | --- |\n| Every selected row agrees | That shared value | Nothing is written |\n| They disagree | Blank, labelled \"Multiple values\" | Each row keeps its own value |\n\nSo editing Status across twelve issues does not flatten their summaries to\nwhichever one happened to be first.\n\nThe field list is the grid's own editable, field-backed columns. A column with\n`editable: false` is not offered, because writing it in bulk would be a\nfootgun, and `editorOptions` carry over - a status column with a fixed set\nbecomes a picker in the drawer rather than a free-text box.\n\nWhat it does per cell:\n\n- skips rows whose per-row `editable` rule refuses the write,\n- skips cells already holding the value, so they do not become empty undo steps,\n- fires `onCellValueChange` for every cell it does change,\n- appends the whole run's undo steps together, so `Ctrl+Z` walks the edit back\n rather than needing one press per cell.\n\nThe engine is exported if you want the same write from your own UI:\n\n```ts\nimport { applyBulkEdit, bulkEditableFields, bulkEditInitialValues } from '@svgrid/enterprise'\n\nconst fields = bulkEditableFields(ctrl) // what can be targeted\nconst { values, mixed } = bulkEditInitialValues(ctrl, fields) // what to show\nconst { changed, skipped, fields: n } = applyBulkEdit(ctrl, { status: 'Done', points: 8 })\n```\n\n## On a server-side row model\n\nUnder `<SvGrid rowModel={ctl}>` with the Enterprise row model's selection\nturned on, the bar reads the model instead of the loaded rows: the count\nchip says what the rule selects - a million under select-all, even with\ntwenty rows on screen - and **Edit fields** sends the patch and the rule\nto the datasource's `updateWhere` as one request, rather than writing the\nloaded cells. See [Server selection](../server/server-selection.md).\n\n## Where it floats\n\n`position` pins the bar to the `'bottom'` (default) or `'top'` edge of the\ngrid.\n\nThe bar **floats** - it never resizes the grid, so a layout holding one does\nnot jump when a checkbox is ticked. To stop it hiding anything, the scroll area\ngains bottom padding the height of the bar while a selection is live: the last\nrow can always be scrolled clear, and the bar overlays that padding rather than\na row.\n\n```svelte {runnable}\n<SvGrid\n data={tasks}\n {columns}\n getRowId={(t) => String(t.id)}\n showRowSelection\n selectionBar={{ position: 'top', actions: [{ key: 'x', label: 'Export', action: () => {} }] }}\n/>\n```\n\n## Buttons that come and go\n\n`hidden` and `disabled` are re-checked on every render against the live\nselection, so buttons appear and grey out as the set changes rather than\nfreezing at first paint. Select one row and **Merge** is not there; select two\nand it appears:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const conditional: SelectionBarAction<Task>[] = [\n {\n key: 'merge',\n label: 'Merge',\n // Merging one task into itself is meaningless.\n hidden: ({ ids }) => ids.length < 2,\n action: () => {},\n },\n {\n key: 'close',\n label: 'Close',\n // Nothing to do when everything picked is already closed.\n disabled: ({ rows }) => rows.every((t) => t.status === 'Done'),\n action: () => {},\n },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={conditional} />\n```\n\n## More actions than fit\n\nPast `maxVisible` (6 by default) the tail folds into a `···` menu, which opens\n**away** from the edge the bar is pinned to - upward from a bottom bar, downward\nfrom a top one - so it never falls off the grid. A bar wide enough to scroll is\na bar whose last button nobody finds:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const many: SelectionBarAction<Task>[] = [\n 'Mark done', 'Assign', 'Watch', 'Export', 'Move', 'Archive',\n ].map((label) => ({ key: label, label, action: () => {} }))\n</script>\n\n<SvGrid\n data={tasks}\n {columns}\n getRowId={(t) => String(t.id)}\n showRowSelection\n selectionBar={{ actions: many, maxVisible: 3 }}\n/>\n```\n\n## Keyboard and screen readers\n\nThe bar is a `role=\"toolbar\"`, which is a promise about how it behaves, so it\nkeeps that promise:\n\n| Key | Does |\n| --- | --- |\n| `Tab` | One stop for the whole bar, not one per button |\n| `Left` / `Right` | Move between buttons, wrapping at each end |\n| `Esc` | Closes the overflow menu if it is open, otherwise clears the selection |\n\nThese are handled on the bar, so they apply while focus is inside it. `Esc`\npressed in the grid body still does whatever the grid does with it - the bar\ndoes not install a global key handler that would fight cell editing or an open\nmenu.\n\nThe count carries `aria-live=\"polite\"` so it is read as the selection changes.\nThe bar itself is not live - that would re-read every button on each tick.\n\n## Options\n\n| Option | Default | Does |\n| --- | --- | --- |\n| `actions` | `[]` | The buttons, left to right |\n| `position` | `'bottom'` | Which edge the bar floats against |\n| `maxVisible` | `6` | How many stay on the bar before the rest collapse. Separators do not count |\n| `hideClear` | `false` | Drop the trailing clear button |\n\n`selectionBar={true}` is shorthand for all defaults with no actions, and\n`selectionBar={[...]}` for `{ actions: [...] }`.\n\n## Related\n\nDemo `23-bulk-actions` builds the same idea by hand - a toolbar of your own\nmarkup driven by `onRowSelectionChange`. Reach for `selectionBar` when you want\nit working out of the box; hand-roll it when you want full control of the\nlayout.\n\n## See also\n\n- [Row selection](../accessibility.md)\n- [Server selection](../server/server-selection.md) - the bar over a million rows the grid never loaded\n- [Kanban board mode](./kanban-board.md)\n- [Scheduler](./scheduler.md)\n"
|
|
4989
|
+
"markdown": "# Bulk-action bar\n\nTick some rows and a bar floats over the grid holding the count, the actions\nthat apply to the whole selection, and a way to clear it - the pattern an issue\ntracker uses for bulk edit.\n\n<div data-docs-demo=\"430-selection-bar\" data-height=\"560\"></div>\n\nEvery example below runs against this setup. The bar *renderer* ships in\n`@svgrid/enterprise`, so `enableSelectionBar()` is what lights it up - the\n`selectionBar` prop and its types are part of the free grid. Without the\nrenderer the grid shows a note in the bar's place saying it is an Enterprise\nfeature and pointing at [licensing](https://svgrid.com/pricing/).\n\nLike the rest of Enterprise this comes with the 30-day evaluation period:\nwith the package installed the bar works whether or not a license key is\nset, and without one the grid displays the watermark and the console\nmessage about the Enterprise package. Set a key with `setLicenseKey()`\nbefore shipping.\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SelectionBarAction } from '@svgrid/grid'\n import { enableSelectionBar } from '@svgrid/enterprise'\n\n enableSelectionBar()\n\n type Task = {\n id: number\n key: string\n title: string\n assignee: string\n status: 'Open' | 'In progress' | 'Done'\n }\n\n const tasks: Task[] = [\n { id: 1, key: 'PLAT-412', title: 'Virtualize the audit log', assignee: 'A. Osei', status: 'Open' },\n { id: 2, key: 'PLAT-413', title: 'Retry webhook delivery', assignee: 'R. Vance', status: 'In progress' },\n { id: 3, key: 'PLAT-414', title: 'Cache the tenant lookup', assignee: 'M. Iqbal', status: 'Done' },\n { id: 4, key: 'PLAT-415', title: 'Fix timezone drift', assignee: 'J. Lindqvist', status: 'Open' },\n { id: 5, key: 'PLAT-416', title: 'Batch the settings writes', assignee: 'D. Okonkwo', status: 'Done' },\n ]\n\n const columns: GridColumns<Task> = [\n { field: 'key', header: 'Key', width: 110 },\n { field: 'title', header: 'Summary', width: 240 },\n { field: 'assignee', header: 'Assignee', width: 150 },\n { field: 'status', header: 'Status', width: 130 },\n ]\n</script>\n```\n\n## The smallest version\n\n`selectionBar` on its own gives the count and a clear button. Nothing else is\nrequired - `showRowSelection` puts the checkboxes there, and the bar reacts to\nwhatever ends up selected:\n\n```svelte {runnable}\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar />\n```\n\n## Adding actions\n\nAn array is the shorthand for `{ actions }`. Each action gets the whole\nselection as `{ rows, ids }`, in **display order** - so it reads the same order\nthe user is looking at, even after a sort:\n\n```svelte {runnable}\n<script lang=\"ts\">\n let log = $state<string[]>([])\n\n const actions: SelectionBarAction<Task>[] = [\n {\n key: 'done',\n label: 'Mark done',\n icon: 'M20 6 9 17l-5-5',\n action: ({ ids }) => (log = [`Marked ${ids.length} done`, ...log]),\n },\n {\n key: 'delete',\n label: 'Delete',\n icon: 'M3 6h18M8 6V4h8v2m-1 0v14H9V6',\n danger: true,\n action: ({ ids }) => (log = [`Deleted ${ids.join(', ')}`, ...log]),\n },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={actions} />\n\n<ul>\n {#each log as line, i (i)}<li>{line}</li>{/each}\n</ul>\n```\n\n`icon` is the `d` of a single 24x24 SVG path, so an action array stays plain\ndata you can lift into its own module next to the columns.\n\n## Built-in actions\n\nStrings in the array are built-ins, so the common buttons need no wiring:\n\n| Key | Does |\n| --- | --- |\n| `'selectAll'` | Selects every row in the view, then disables itself |\n| `'editFields'` | Opens the bulk-edit drawer - see below |\n| `'separator'` | Draws a divider. Not counted against `maxVisible` |\n\nMix them with your own actions in one array:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const bar: Array<SelectionBarAction<Task> | 'selectAll' | 'editFields' | 'separator'> = [\n 'selectAll',\n 'editFields',\n 'separator',\n { key: 'watch', label: 'Watch', action: () => {} },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={bar} />\n```\n\n## Bulk edit\n\n`'editFields'` opens a **drawer** - the same `SvDrawer` + `SvForm` the grid\nuses for single-row editing - holding one control per editable column. Change\nas many fields as you like and they apply to **every selected row**, as a\nsingle undo run.\n\nThe rule that makes that safe: **only fields you actually change are written.**\n\n| The field | Opens showing | Leave it alone and |\n| --- | --- | --- |\n| Every selected row agrees | That shared value | Nothing is written |\n| They disagree | Blank, labelled \"Multiple values\" | Each row keeps its own value |\n\nSo editing Status across twelve issues does not flatten their summaries to\nwhichever one happened to be first.\n\nThe field list is the grid's own editable, field-backed columns. A column with\n`editable: false` is not offered, because writing it in bulk would be a\nfootgun, and `editorOptions` carry over - a status column with a fixed set\nbecomes a picker in the drawer rather than a free-text box.\n\nWhat it does per cell:\n\n- skips rows whose per-row `editable` rule refuses the write,\n- skips cells already holding the value, so they do not become empty undo steps,\n- fires `onCellValueChange` for every cell it does change,\n- appends the whole run's undo steps together, so `Ctrl+Z` walks the edit back\n rather than needing one press per cell.\n\nThe engine is exported if you want the same write from your own UI:\n\n```ts\nimport { applyBulkEdit, bulkEditableFields, bulkEditInitialValues } from '@svgrid/enterprise'\n\nconst fields = bulkEditableFields(ctrl) // what can be targeted\nconst { values, mixed } = bulkEditInitialValues(ctrl, fields) // what to show\nconst { changed, skipped, fields: n } = applyBulkEdit(ctrl, { status: 'Done', points: 8 })\n```\n\n## On a server-side row model\n\nUnder `<SvGrid rowModel={ctl}>` with the Enterprise row model's selection\nturned on, the bar reads the model instead of the loaded rows: the count\nchip says what the rule selects - a million under select-all, even with\ntwenty rows on screen - and **Edit fields** sends the patch and the rule\nto the datasource's `updateWhere` as one request, rather than writing the\nloaded cells. See [Server selection](../server/server-selection.md).\n\n## Where it floats\n\n`position` pins the bar to the `'bottom'` (default) or `'top'` edge of the\ngrid.\n\nThe bar **floats** - it never resizes the grid, so a layout holding one does\nnot jump when a checkbox is ticked. To stop it hiding anything, the scroll area\ngains bottom padding the height of the bar while a selection is live: the last\nrow can always be scrolled clear, and the bar overlays that padding rather than\na row.\n\n```svelte {runnable}\n<SvGrid\n data={tasks}\n {columns}\n getRowId={(t) => String(t.id)}\n showRowSelection\n selectionBar={{ position: 'top', actions: [{ key: 'x', label: 'Export', action: () => {} }] }}\n/>\n```\n\n## Buttons that come and go\n\n`hidden` and `disabled` are re-checked on every render against the live\nselection, so buttons appear and grey out as the set changes rather than\nfreezing at first paint. Select one row and **Merge** is not there; select two\nand it appears:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const conditional: SelectionBarAction<Task>[] = [\n {\n key: 'merge',\n label: 'Merge',\n // Merging one task into itself is meaningless.\n hidden: ({ ids }) => ids.length < 2,\n action: () => {},\n },\n {\n key: 'close',\n label: 'Close',\n // Nothing to do when everything picked is already closed.\n disabled: ({ rows }) => rows.every((t) => t.status === 'Done'),\n action: () => {},\n },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={conditional} />\n```\n\n## More actions than fit\n\nPast `maxVisible` (6 by default) the tail folds into a `···` menu, which opens\n**away** from the edge the bar is pinned to - upward from a bottom bar, downward\nfrom a top one - so it never falls off the grid. A bar wide enough to scroll is\na bar whose last button nobody finds:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const many: SelectionBarAction<Task>[] = [\n 'Mark done', 'Assign', 'Watch', 'Export', 'Move', 'Archive',\n ].map((label) => ({ key: label, label, action: () => {} }))\n</script>\n\n<SvGrid\n data={tasks}\n {columns}\n getRowId={(t) => String(t.id)}\n showRowSelection\n selectionBar={{ actions: many, maxVisible: 3 }}\n/>\n```\n\n## Keyboard and screen readers\n\nThe bar is a `role=\"toolbar\"`, which is a promise about how it behaves, so it\nkeeps that promise:\n\n| Key | Does |\n| --- | --- |\n| `Tab` | One stop for the whole bar, not one per button |\n| `Left` / `Right` | Move between buttons, wrapping at each end |\n| `Esc` | Closes the overflow menu if it is open, otherwise clears the selection |\n\nThese are handled on the bar, so they apply while focus is inside it. `Esc`\npressed in the grid body still does whatever the grid does with it - the bar\ndoes not install a global key handler that would fight cell editing or an open\nmenu.\n\nThe count carries `aria-live=\"polite\"` so it is read as the selection changes.\nThe bar itself is not live - that would re-read every button on each tick.\n\n## Options\n\n| Option | Default | Does |\n| --- | --- | --- |\n| `actions` | `[]` | The buttons, left to right |\n| `position` | `'bottom'` | Which edge the bar floats against |\n| `maxVisible` | `6` | How many stay on the bar before the rest collapse. Separators do not count |\n| `hideClear` | `false` | Drop the trailing clear button |\n\n`selectionBar={true}` is shorthand for all defaults with no actions, and\n`selectionBar={[...]}` for `{ actions: [...] }`.\n\n## Related\n\nDemo `23-bulk-actions` builds the same idea by hand - a toolbar of your own\nmarkup driven by `onRowSelectionChange`. Reach for `selectionBar` when you want\nit working out of the box; hand-roll it when you want full control of the\nlayout.\n\n<!-- tutorial:learn-5-selection -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-5-selection\" data-docs-tutorial=\"learn-5-selection\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-5-selection.mp4\" poster=\"/tutorials/learn-5-selection.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 5: selection and bulk actions, 86 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-5-selection.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-5-selection.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 5: selection and bulk actions</strong> (1:26, silent). <a href=\"https://www.youtube.com/watch?v=NO_xuVJdqVQ\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Selecting rows is easy. Knowing which rows are selected after a sort, a filter or a refresh is the part that catches people out, and it comes down to one prop.</p>\n<p>A small backlog of tasks and the columns to show them.</p>\n<p>Three things on the component. showRowSelection adds the checkbox column. selectionBar floats the action bar. And getRowId is the one that matters: it tells the grid what identifies a row, so a selection survives sorting, filtering and new data arriving. Without it the grid falls back to position, and position changes.</p>\n<p>Tick a couple of rows and the bar appears with a live count and the actions you defined. It stays out of the way until there is something to act on.</p>\n<p>Now sort the table while those rows are ticked. The same three stay selected, because the grid is tracking ids rather than row numbers.</p>\n<p>Over server data the same idea scales: select-all becomes a rule, every row except these, rather than a list of ids the browser would have to hold. Lesson six groups the rows and adds totals.</p>\n<p>The file is on the page below. See you in lesson six.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-5-selection -->\n\n## See also\n\n- [Row selection](../accessibility.md)\n- [Server selection](../server/server-selection.md) - the bar over a million rows the grid never loaded\n- [Kanban board mode](./kanban-board.md)\n- [Scheduler](./scheduler.md)\n"
|
|
4966
4990
|
},
|
|
4967
4991
|
{
|
|
4968
4992
|
"slug": "help/rows/styling-rows",
|
|
@@ -5053,7 +5077,7 @@ export const docs = [
|
|
|
5053
5077
|
"path": "docs/help/server/server-grouping.md",
|
|
5054
5078
|
"title": "Server grouping - Enterprise",
|
|
5055
5079
|
"section": "Server data",
|
|
5056
|
-
"markdown": "# Server grouping - Enterprise\n\nGrouping a hundred thousand rows in the browser means shipping all hundred\nthousand rows first. Server grouping flips that: the backend runs the\n`GROUP BY`, and the grid receives **one pre-aggregated row per group** - a key\nplus its subtotals. Expanding a group then lazily drills into the next level (or\nthe raw rows) for **that group only**, so the network never carries the full\ntable.\n\nThis is **first-class in SvGrid**: grouping flows through the **same\n`ServerDataSource.getRows` contract** as paging, sorting, and filtering.\nThe request carries `groupBy` (the columns grouped on) and `groupKeys` (the path\nof the group being expanded); `createServerRowModel` owns the group tree -\na block cache per level, lazy fetch on expand, race-safety, refresh and retry\nper route - and the grid mounts it through one `rowModel` prop.\n\n<img src=\"/docs-media/server-grouping.svg\" alt=\"Server grouping flow: the backend runs GROUP BY and returns one pre-aggregated row per group instead of the raw table; the grid renders those group rows; expanding one group drills into the next level for that group only.\" width=\"100%\" />\n\n<div data-docs-demo=\"496-server-grouping-model\" data-height=\"480\"></div>\n\n## The contract\n\nOne `getRows`. When `groupKeys.length < groupBy.length` the server returns\n**group rows** (one per distinct key at that level, carrying the group key and\nits aggregates); when they are equal it returns the **leaf rows** under that\npath. Every request carries `startRow` / `endRow` as well: a level loads one\nblock at a time, group rows and leaves alike.\n\nThe row model and its chrome ship in `@svgrid/enterprise`; the datasource\ncontract they run on, and the grid itself, are free in `@svgrid/grid`. The\nexamples on this page import from both:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n import { SvRowGroupPanel } from '@svgrid/enterprise'\n</script>\n```\n\n```ts\nasync function getRows(req) {\n const level = req.groupKeys.length\n if (level < req.groupBy.length) {\n // GROUP row level: GROUP BY the column at this level, within groupKeys.\n const col = req.groupBy[level] // e.g. 'country', then 'city'\n // SELECT country, SUM(amount) AS amount, COUNT(*) AS childCount\n // FROM sales WHERE <groupKeys path> GROUP BY country\n // ORDER BY country LIMIT <endRow - startRow> OFFSET <startRow>\n return { rows: groupRows, rowCount: distinctGroups }\n }\n // LEAF level: the raw rows under the fully-specified path.\n // SELECT * FROM sales WHERE country = $1 AND city = $2 LIMIT ... OFFSET ...\n return { rows: leafRows, rowCount: total }\n}\n```\n\nA group row is a plain object: the group column's value under that column's\nfield, each aggregate under its column (`amount: 12345` for `sum(amount)`),\nand optionally `childCount`, the number of rows the next level holds. The\nmodel reads them straight off the row.\n\n## You probably do not have to write that\n\nMapping the grid's request onto a backend query is the part teams actually\nfind hard, so `@svgrid/enterprise` ships adapters that already do it - SQL,\nREST and Supabase - including the grouped case above.\n\n```ts\nimport { planQuery, planToSql } from '@svgrid/enterprise'\n\n// In your endpoint. `schema` is the EntitySchema for the table.\nconst plan = planQuery(schema, request)\nconst sql = planToSql(plan, { placeholders: '$', ilike: true }) // Postgres\n\nconst rows = await db.query(\n plan.groupBy\n // Grouped level: the plan hands you the SELECT list and GROUP BY.\n ? `SELECT ${sql.select} FROM sales ${sql.whereText} ${sql.groupByText}\n ${sql.orderByText} LIMIT ${sql.limit} OFFSET ${sql.offset}`\n // Leaf level: your own columns.\n : `SELECT * FROM sales ${sql.whereText}\n ${sql.orderByText} LIMIT ${sql.limit} OFFSET ${sql.offset}`,\n sql.params,\n)\n```\n\nFour details the plan handles that are easy to get wrong by hand:\n\n- **The path becomes ordinary predicates.** `groupKeys` arrives as equality\n filters in `plan.where`, so your backend only ever handles \"filter, then\n group by one column\" - never a multi-level GROUP BY.\n- **The count means different things at different levels.** `sql.countText` is\n `COUNT(DISTINCT col)` when grouping and `COUNT(*)` when not, because the grid\n sizes its scrollbar from the number of *groups* at a group level.\n- **Aggregates are aliased back to their source column.** `SUM(\"amount\") AS\n \"amount\"`, because that is the key the grid reads from the group row. The\n grouped SELECT also carries `COUNT(*) AS \"childCount\"`.\n- **Groups have an order.** A grouped level orders by the key unless the\n request sorts by the key or an aggregate, so paging over groups is stable.\n- **Function names are whitelisted, like columns.** `fn` is `sum`, `avg`,\n `min`, `max` or `count` out of the box, and any other name your database\n knows once you list it: `planQuery(schema, request, { aggregators:\n ['median'] })`, or `aggregators` on `createSqlDataSource`. A name that is\n neither is dropped from the plan, and `planToSql` refuses anything that\n is not an identifier, because the name is pasted into the statement.\n `ServerAggregation.fn` is typed to take any string for that reason: the\n request carries `median` untouched, and the backend decides.\n\nOnly fields declared on the `EntitySchema` reach the plan, so a client cannot\ngroup by or aggregate an identifier you did not declare.\n\nFor REST, `createRestDataSource` sends `?groupBy=region&aggregate=sum:amount`\nplus the path as ordinary filter params. For Postgres via PostgREST,\n`createSupabaseDataSource` uses aggregate selects (requires PostgREST 12+ with\naggregates enabled). `createInMemoryDataSource` implements the whole contract\nin memory and is the reference to test your own backend against; its request\nmatrix is a test every shipped backend runs.\n\n\n\n## Wiring the model\n\n```ts\nimport { createServerRowModel, type ServerRowModelState } from '@svgrid/enterprise'\n\nlet view = $state<ServerRowModelState<Sale>>()\nconst ctl = createServerRowModel<Sale>(source, {\n groupBy: ['country', 'city'], // group two levels deep\n aggregations: [{ col: 'amount', fn: 'sum' }], // roll up per group\n childCount: (row) => row.childCount, // the badge beside the key\n onChange: (s) => (view = s),\n})\nctl.refresh() // load the top level\n```\n\n`view.displayRows` is the flattened tree: top-level groups, with each expanded\ngroup's children spliced in beneath it, and placeholder rows where a block is\nstill in flight. Every group row carries `level` (for indentation),\n`expanded`, `loading`, `key`, `childCount` and `aggregates`.\n\n## Rendering the display rows\n\nHand the model to the grid and add one column for the group cell. The model\nsupplies the rows, the loading flag, the sort and filter hooks, the visible\nrange (so each level fetches the blocks on screen), the treegrid keyboard and\nthe placeholder rows; the shipped `SvGroupCell` draws the expander,\nindentation, the child count, and a spinner in the expander while the\nlevel's block is in flight:\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, renderComponent } from '@svgrid/grid'\n import { SvGroupCell, serverGroupText } from '@svgrid/enterprise'\n\n const columns = [\n { id: 'group', header: 'Group', width: 280, sortable: false, filterable: false,\n fieldFn: (row) => serverGroupText(row, 'name'),\n cell: (ctx) => renderComponent(SvGroupCell, {\n row: ctx.row.original, onToggle: () => ctl.group.onToggle(ctx.row.original), leafField: 'name',\n }) },\n { field: 'amount', header: 'Amount', align: 'right',\n format: { type: 'number', options: { style: 'currency', currency: 'USD' } } },\n ]\n</script>\n\n<SvGrid rowModel={ctl} {columns} />\n```\n\nA value column shows the subtotal on a group row and the cell value on a leaf,\nbecause each grid row spreads its data. The `fieldFn` is the text behind the\ncell: a group's key, `Total`, `Grand total`, a leaf's `leafField`. Copy,\nexport and the clipboard read it, and so does a grand total pinned with\n`grandTotalRow: 'pinnedBottom'`, which the grid formats from the accessor\nrather than through the cell renderer. Want full control? Every grid row\ncarries a `__group` marker (the display row), so you can skip `SvGroupCell`\nand render your own cell from it.\n\nFooter and grand-total rows carry the same `__groupFooter` / `__grandTotal`\nflags the client-side grouping sets, so the grid styles them the way it\nstyles its own subtotals (`.sv-grid-group-footer-row`,\n`.sv-grid-grand-total-row`): a heavier top rule and weight, in the list\nor pinned.\n\nEvery string the group chrome draws is a message you can replace:\n`SvGroupCell` and `SvRowGroupPanel` take `messages` (a partial\n`ServerGroupMessages`: the \"load more\" row, `Total {label}`, `Grand total`,\n`Group by:`, `Apply`, the chip labels for a screen reader, ...), plus\n`locale` on the cell for its numbers, and `serverGroupText` takes the same\nmap as its third argument so a copy reads the way the cell does. The two\ncomponents are mounted by you, not by the grid, so they cannot read\n`localization.text`; pass one object to all three.\n\n```svelte\n<script lang=\"ts\">\n const groupMessages = { total: 'Summe {label}', grandTotal: 'Gesamtsumme', loadMore: '{count} weitere laden', apply: 'Anwenden', cancel: 'Abbrechen' }\n</script>\n\n<SvRowGroupPanel {columns} {groupBy} onChange={...} messages={groupMessages} />\n<!-- and in the column: renderComponent(SvGroupCell, { ..., messages: groupMessages, locale: 'de-DE' }) -->\n```\n\n## Keyboard and accessibility\n\nThe model's `group` accessor set (`rowModel` wires it as `serverGroup`) makes\nthe grid handle tree navigation itself - no app key handling:\n\n- **ArrowRight** expands the focused group row; **ArrowLeft** collapses it;\n **Ctrl+Enter** toggles it, and on a leaf opens or closes its\n [detail panel](#master-detail) when the grid draws one.\n- The grid takes the `treegrid` role and sets `aria-level` + `aria-expanded` on\n each row, so screen readers announce the depth and expanded state.\n- Group rows do not take edits, whatever the column says; a leaf does.\n\nIt works for [tree mode](./server-tree-data.md) the same way.\n\n## Sticky group rows\n\nA country with 60,000 rows is a long scroll with nothing on screen that\nsays which country it is. `stickyGroupRows` keeps the group a row belongs\nto under the header - the country row, and the region row above it - while\nits rows scroll past, the way a section heading stays put in a long list:\n\n```svelte\n<SvGrid rowModel={ctl} {columns} stickyGroupRows />\n```\n\nUnder virtualization the band holds a copy of each ancestor row, drawn by\nthe same renderers (the expander in the copy collapses the group); without\nvirtualization the rows themselves stick. The band comes off the top of\nthe scrolled rows, so nothing jumps when it appears. It is the same prop\nfor client grouping and tree data.\n\n## Blocks, per level\n\nEvery level - the root and each expanded group - is its own block cache. The\ngrid reports what is on screen and the model asks each level for the blocks\nunder the viewport, `blockSize` rows at a time, keeps at most\n`maxBlocksInCache` loaded blocks per level, runs at most\n`maxConcurrentRequests` requests across all levels, and waits\n`blockLoadDebounceMs` for a scroll to settle. A level shows `skeletonRows`\nplaceholders before its first block lands, so an expand never opens onto\nnothing.\n\n`levelParams(level, route)` tunes one level: a different `blockSize` or\n`maxBlocksInCache`, `infinite: false` to load a level completely as soon as it\nopens (which also allows sorting it in the browser, see `clientSideSort`), or\n`loadMore: true` to load one block per click behind a \"Load N more\" row, or\n`initialRowCount` to claim a level's size before its first block lands - at\nthe root of flat data that makes `api.scrollToRow(900000)` work before\nanything has loaded, with placeholders under the viewport and the block\nbeneath them the one that loads:\n\n```ts\ncreateServerRowModel(source, {\n groupBy: ['region', 'country'],\n blockSize: 100,\n levelParams: (level) => (level === 2 ? { loadMore: true, blockSize: 50 } : {}),\n})\n```\n\n## Refresh, purge, retry\n\n| Method | Does |\n| ------ | ---- |\n| `refresh({ route, purge })` | Re-fetch a level in place (`route: []` is the root), keeping what is open; `purge: true` drops that subtree's cache first. |\n| `retryLoads()` | Re-fetch every failed block. The grid draws a failed block as a tinted band with one full-width message and a Retry button. |\n| `expandAll({ includeUnloaded })` / `collapseAll()` | Open every loaded group, and with `includeUnloaded` every group that arrives later, until the next collapse. |\n| `isGroupOpenByDefault(route, row)` | Open a group as soon as it arrives. |\n| `getLevelState(route)` / `getCacheState()` | Where each level stands: loaded blocks, failed blocks, row count. `debug: true` logs block lifecycle. |\n| `onStoreRefreshed`, `onGroupOpened`, `onLoadError` | Events for the app's own chrome. |\n\n## What re-requests, and what does not\n\nChanging a **group column** (add, remove, reorder) or an **aggregated column**\nreloads the tree: the rows are different rows. Sorting follows the same logic\nper level: a sort on a plain column re-fetches the leaf levels only, a sort on\na group column re-fetches that column's level (the children of a group keep\ntheir own order), a sort on an aggregated column re-fetches everything.\n`sortAllLevels: true` makes every sort a full\nreload; `clientSideSort: true` sorts a fully loaded level in the browser with\nno request at all.\n\nA **filter** purges everything by default, as a filtered group may have\ndifferent children and different totals. `onlyRefreshFilteredGroups: true`\nre-fetches only the levels whose column changed, and accepts stale counts on\nthe others as the trade.\n\n## Subtotal footers and the grand total\n\nTurn on `groupFooters` and each expanded group gets a **Total** row after its\nchildren, carrying the group's aggregates again. `grandTotalRow` asks the\nserver for a total across the whole (filtered) result on the first root\nrequest - `ServerRequest.needsGrandTotal` in, `ServerResult.grandTotal` out -\nand shows it at the `'top'` or `'bottom'` of the list, or pinned with\n`'pinnedTop'` / `'pinnedBottom'`. The row has the fixed id `sv-grand-total`\nand each footer `sv-group-total:<route>`, so [transactions](./server-transactions.md)\ncan address them.\n\n```ts\ncreateServerRowModel(source, { groupBy: ['region', 'country'], aggregations, groupFooters: true, grandTotalRow: 'pinnedBottom' })\n```\n\n## Paging the tree\n\n`pagination` pages the top level instead of scrolling it: a page is\n`pageSize` top-level rows, each shown with whatever is open beneath it, or\nwith `paginateChildRows` a page of the flattened tree, children counted.\nBlocks stay independent of pages. The grid's footer pager drives it through\n`rowModel` (`pageable` on the grid); `pageSizes` fills the page-size selector\nand `autoPageSize` fits the page to the grid's height.\n\n```ts\ncreateServerRowModel(source, {\n groupBy: ['region', 'country'],\n pagination: { pageSize: 25, pageSizes: [10, 25, 50], paginateChildRows: true },\n})\n```\n\n## A row-group panel (drag to group)\n\n`SvRowGroupPanel` is a \"group by\" bar: it shows the current group columns as\nchips you can remove or drag to reorder, plus a menu to add one, and it accepts a\ncolumn drop (`text/sv-column`). Wire its `onChange` to `setGroupBy`. With\n`applyMode=\"deferred\"` it collects edits behind Apply / Cancel, so one\nsession of changes is one reload rather than one per chip. Pending chips\nsurvive a block landing (the model hands out a fresh `groupBy` array on\nevery emit; the panel compares by value) and are dropped only when the\ngrouping really changes from outside:\n\n```svelte\n<script lang=\"ts\">\n import { SvRowGroupPanel } from '@svgrid/enterprise'\n const groupCols = [{ id: 'region', label: 'Region' }, { id: 'country', label: 'Country' }]\n</script>\n\n<SvRowGroupPanel columns={groupCols} groupBy={view.groupBy} onChange={(g) => ctl.setGroupBy(g)} applyMode=\"deferred\" />\n```\n\n`setLayout({ groupBy, aggregations, pivotBy, pivotMode })` changes several of\nthose at once with a single reload; it is what the pivot designer sends.\n\n## Multi-level grouping is automatic\n\nSet `groupBy: ['region', 'industry', 'quarter']` and the model fetches each\nlevel on demand: the top level returns regions, expanding a region fetches its\nindustries, expanding an industry fetches its quarters, and expanding a quarter\nreturns the raw rows. You never configure the levels - each expand is just\nanother `getRows` with a longer `groupKeys`. `ServerRequest.parentRow` carries\nthe row being expanded, for a backend keyed off it, and `context` on the model\nrides along on every request.\n\n## Delivering rows without a request\n\n`applyRowData({ route, rows, rowCount, startRow })` fills a level's store\ndirectly, bypassing the datasource: a tree payload that ships children with\ntheir parent, or a socket that pushes a whole level. The blocks it fills are\nloaded blocks like any other.\n\n## The win\n\nFor a 100,000-row sales table grouped by three dimensions, the top level returns\na handful of group rows instead of 100,000 raw rows. The client groups nothing\nand holds almost nothing. Grouping 100k rows in JS runs in hundreds of\nmilliseconds; asking the server for the pre-grouped result returns a few rows in\ntens of milliseconds, and the payload shrinks by orders of magnitude. The\nflagship demo runs the whole model over one million rows:\n\n<div data-docs-demo=\"467-server-row-model-1m\" data-height=\"640\"></div>\n\nThe options on this page, each with the request log to show what it\ncosts - grand total positions, subtotal footers, open-by-default levels,\nexpand-all over unloaded groups, refresh against purge, and the sort and\nfilter rules:\n\n<div data-docs-demo=\"473-server-grouping-rules\" data-height=\"620\"></div>\n\nAnd the SQL a backend runs for each of these requests, per dialect, from\n`planQuery` and `planToSql`:\n\n<div data-docs-demo=\"472-server-sql-planner\" data-height=\"600\"></div>\n\n## Master-detail\n\nA leaf can open a detail panel under itself - an order and its line items,\na customer and a chart - without the rows leaving the server. The model\nkeeps the set of open details and, when it flattens the tree, puts a\ndisplay row of kind `detail` right under its leaf, carrying the leaf as\n`master`. The grid draws it through the same two props client-side\nmaster-detail uses, plus a height the virtualizer can count on:\n\n```svelte\n<SvGrid\n rowModel={ctl}\n {columns}\n isDetailRow={(row) => row.__group?.kind === 'detail'}\n renderDetailRow={Detail}\n detailRowHeight={200}\n showDetailToggle\n/>\n\n{#snippet Detail({ row })}\n {@const order = row.__group.master}\n <!-- fetch the lines for order.id here; the panel is 200px and scrolls inside -->\n{/snippet}\n```\n\n`ctl.toggleDetail(id, open?)` opens or closes the panel under the leaf with\nthat `getRowId`, `isDetailOpen(id)` reads it, `closeAllDetails()` clears,\nand `state.openDetails` lists them. A panel closes with the group that\nholds its leaf and goes when the leaf is removed by a transaction.\n`showDetailToggle` draws the chevrons as a\n[row-header column](../rows/master-detail.md#the-toggle-column) next to\nthe row numbers: the model tells the grid which rows are leaves and which\npanels are open, so there is nothing to wire; a group row shows no\nchevron, since its expander is in the group column. **Ctrl+Enter** on a\nleaf opens or closes its panel from the keyboard, and on the panel row\ncloses it.\n\n<div data-docs-demo=\"483-server-master-detail\" data-height=\"620\"></div>\n\n## Without the model (manual pattern)\n\nIf your backend or UI needs something bespoke, you can still assemble grouping by\nhand: fetch pre-grouped rows, render them as ordinary rows, and expand each into\na second `<SvGrid data={detailRows}>` or an [expandable detail row](../rows/master-detail.md)\n(`isDetailRow` + `renderDetailRow`), keeping the expanded group id and its lazily\nfetched detail rows in your own state. The [tree toggle pattern](../rows/tree-rows.md)\nis a third option for a single flat, indented list. `createServerRowModel` is\nthe batteries-included version of exactly this.\n\n<div data-docs-demo=\"114-server-grouping\" data-height=\"480\"></div>\n\n## Try it\n\nGrouping on the client is a prop; the point of the server version is that the\nrollup happens where the rows are. This runs the same shape locally so the\ngroup model is visible - your `query` returns pre-grouped rows instead.\n\n```svelte\n<SvGrid\n data={people}\n {columns}\n groupBy={['department']}\n groupable\n summary\n sortable\n/>\n```\n\nCompare that with what a server has to return: one row per group with its\naggregate already computed, plus the leaves for whichever groups are expanded.\n\n## Group headers the server computed\n\nWhen the server does the rollup it sends group rows and leaf rows in one flat\nlist, already ordered. Rendering that is tree data with a parent field - the\ngrid does not need to know a group was computed elsewhere.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Row = { id: string; parentId: string | null; label: string; total: number }\n\n // What the endpoint returned: two group rows with their totals, plus leaves.\n const fromServer: Row[] = [\n { id: 'g-eng', parentId: null, label: 'Engineering', total: 310000 },\n { id: 'p-ada', parentId: 'g-eng', label: 'Ada Lovelace', total: 142000 },\n { id: 'p-grace',parentId: 'g-eng', label: 'Grace Hopper', total: 168000 },\n { id: 'g-plat', parentId: null, label: 'Platform', total: 327000 },\n { id: 'p-linus',parentId: 'g-plat',label: 'Linus Torvalds', total: 155000 },\n { id: 'p-barb', parentId: 'g-plat',label: 'Barbara Liskov', total: 172000 },\n ]\n\n const columns: GridColumns<Row> = [\n { field: 'label', header: 'Group / person', width: 240 },\n { field: 'total', header: 'Total', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid\n data={fromServer}\n {columns}\n treeData={{ parentField: 'parentId', idField: 'id', column: 'label' }}\n/>\n```\n\n## The previous model\n\n`createServerGroupModel`, `serverGroupRows` and `serverGroupNav` - the\nblock-append model with a \"Load N more\" button - still ship, unchanged, and\nare deprecated in favour of `createServerRowModel`. The new model does\neverything the old one did; `levelParams: { loadMore: true }` is the\n\"Load N more\" behaviour where you want to keep it.\n\n## See also\n\n- [Server-Side Row Model](./server-row-model.md) - the datasource contract that grouping, paging, sort, and filter all share.\n- [Server tree data](./server-tree-data.md) - load-on-demand hierarchies (self-referential trees).\n- [Server pivot](./server-pivot.md) - pivot on the server, with the designer driving the model.\n- [Server transactions](./server-transactions.md) - add, update and remove rows in a loaded level without a request.\n- [Server selection](./server-selection.md) - select-all as a rule across rows the grid never loaded.\n- [Tree data](../rows/tree-rows.md) - the client-side flat-list + toggle pattern.\n"
|
|
5080
|
+
"markdown": "# Server grouping - Enterprise\n\nGrouping a hundred thousand rows in the browser means shipping all hundred\nthousand rows first. Server grouping flips that: the backend runs the\n`GROUP BY`, and the grid receives **one pre-aggregated row per group** - a key\nplus its subtotals. Expanding a group then lazily drills into the next level (or\nthe raw rows) for **that group only**, so the network never carries the full\ntable.\n\nThis is **first-class in SvGrid**: grouping flows through the **same\n`ServerDataSource.getRows` contract** as paging, sorting, and filtering.\nThe request carries `groupBy` (the columns grouped on) and `groupKeys` (the path\nof the group being expanded); `createServerRowModel` owns the group tree -\na block cache per level, lazy fetch on expand, race-safety, refresh and retry\nper route - and the grid mounts it through one `rowModel` prop.\n\n<img src=\"/docs-media/server-grouping.svg\" alt=\"Server grouping flow: the backend runs GROUP BY and returns one pre-aggregated row per group instead of the raw table; the grid renders those group rows; expanding one group drills into the next level for that group only.\" width=\"100%\" />\n\n<div data-docs-demo=\"496-server-grouping-model\" data-height=\"480\"></div>\n\n## The contract\n\nOne `getRows`. When `groupKeys.length < groupBy.length` the server returns\n**group rows** (one per distinct key at that level, carrying the group key and\nits aggregates); when they are equal it returns the **leaf rows** under that\npath. Every request carries `startRow` / `endRow` as well: a level loads one\nblock at a time, group rows and leaves alike.\n\nThe row model and its chrome ship in `@svgrid/enterprise`; the datasource\ncontract they run on, and the grid itself, are free in `@svgrid/grid`. The\nexamples on this page import from both:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n import { SvRowGroupPanel } from '@svgrid/enterprise'\n</script>\n```\n\n```ts\nasync function getRows(req) {\n const level = req.groupKeys.length\n if (level < req.groupBy.length) {\n // GROUP row level: GROUP BY the column at this level, within groupKeys.\n const col = req.groupBy[level] // e.g. 'country', then 'city'\n // SELECT country, SUM(amount) AS amount, COUNT(*) AS childCount\n // FROM sales WHERE <groupKeys path> GROUP BY country\n // ORDER BY country LIMIT <endRow - startRow> OFFSET <startRow>\n return { rows: groupRows, rowCount: distinctGroups }\n }\n // LEAF level: the raw rows under the fully-specified path.\n // SELECT * FROM sales WHERE country = $1 AND city = $2 LIMIT ... OFFSET ...\n return { rows: leafRows, rowCount: total }\n}\n```\n\nA group row is a plain object: the group column's value under that column's\nfield, each aggregate under its column (`amount: 12345` for `sum(amount)`),\nand optionally `childCount`, the number of rows the next level holds. The\nmodel reads them straight off the row.\n\n## You probably do not have to write that\n\nMapping the grid's request onto a backend query is the part teams actually\nfind hard, so `@svgrid/enterprise` ships adapters that already do it - SQL,\nREST and Supabase - including the grouped case above.\n\n```ts\nimport { planQuery, planToSql } from '@svgrid/enterprise'\n\n// In your endpoint. `schema` is the EntitySchema for the table.\nconst plan = planQuery(schema, request)\nconst sql = planToSql(plan, { placeholders: '$', ilike: true }) // Postgres\n\nconst rows = await db.query(\n plan.groupBy\n // Grouped level: the plan hands you the SELECT list and GROUP BY.\n ? `SELECT ${sql.select} FROM sales ${sql.whereText} ${sql.groupByText}\n ${sql.orderByText} LIMIT ${sql.limit} OFFSET ${sql.offset}`\n // Leaf level: your own columns.\n : `SELECT * FROM sales ${sql.whereText}\n ${sql.orderByText} LIMIT ${sql.limit} OFFSET ${sql.offset}`,\n sql.params,\n)\n```\n\nFour details the plan handles that are easy to get wrong by hand:\n\n- **The path becomes ordinary predicates.** `groupKeys` arrives as equality\n filters in `plan.where`, so your backend only ever handles \"filter, then\n group by one column\" - never a multi-level GROUP BY.\n- **The count means different things at different levels.** `sql.countText` is\n `COUNT(DISTINCT col)` when grouping and `COUNT(*)` when not, because the grid\n sizes its scrollbar from the number of *groups* at a group level.\n- **Aggregates are aliased back to their source column.** `SUM(\"amount\") AS\n \"amount\"`, because that is the key the grid reads from the group row. The\n grouped SELECT also carries `COUNT(*) AS \"childCount\"`.\n- **Groups have an order.** A grouped level orders by the key unless the\n request sorts by the key or an aggregate, so paging over groups is stable.\n- **Function names are whitelisted, like columns.** `fn` is `sum`, `avg`,\n `min`, `max` or `count` out of the box, and any other name your database\n knows once you list it: `planQuery(schema, request, { aggregators:\n ['median'] })`, or `aggregators` on `createSqlDataSource`. A name that is\n neither is dropped from the plan, and `planToSql` refuses anything that\n is not an identifier, because the name is pasted into the statement.\n `ServerAggregation.fn` is typed to take any string for that reason: the\n request carries `median` untouched, and the backend decides.\n\nOnly fields declared on the `EntitySchema` reach the plan, so a client cannot\ngroup by or aggregate an identifier you did not declare.\n\nFor REST, `createRestDataSource` sends `?groupBy=region&aggregate=sum:amount`\nplus the path as ordinary filter params. For Postgres via PostgREST,\n`createSupabaseDataSource` uses aggregate selects (requires PostgREST 12+ with\naggregates enabled). `createInMemoryDataSource` implements the whole contract\nin memory and is the reference to test your own backend against; its request\nmatrix is a test every shipped backend runs.\n\n\n\n## Wiring the model\n\n```ts\nimport { createServerRowModel, type ServerRowModelState } from '@svgrid/enterprise'\n\nlet view = $state<ServerRowModelState<Sale>>()\nconst ctl = createServerRowModel<Sale>(source, {\n groupBy: ['country', 'city'], // group two levels deep\n aggregations: [{ col: 'amount', fn: 'sum' }], // roll up per group\n childCount: (row) => row.childCount, // the badge beside the key\n onChange: (s) => (view = s),\n})\nctl.refresh() // load the top level\n```\n\n`view.displayRows` is the flattened tree: top-level groups, with each expanded\ngroup's children spliced in beneath it, and placeholder rows where a block is\nstill in flight. Every group row carries `level` (for indentation),\n`expanded`, `loading`, `key`, `childCount` and `aggregates`.\n\n## Rendering the display rows\n\nHand the model to the grid and add one column for the group cell. The model\nsupplies the rows, the loading flag, the sort and filter hooks, the visible\nrange (so each level fetches the blocks on screen), the treegrid keyboard and\nthe placeholder rows; the shipped `SvGroupCell` draws the expander,\nindentation, the child count, and a spinner in the expander while the\nlevel's block is in flight:\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, renderComponent } from '@svgrid/grid'\n import { SvGroupCell, serverGroupText } from '@svgrid/enterprise'\n\n const columns = [\n { id: 'group', header: 'Group', width: 280, sortable: false, filterable: false,\n fieldFn: (row) => serverGroupText(row, 'name'),\n cell: (ctx) => renderComponent(SvGroupCell, {\n row: ctx.row.original, onToggle: () => ctl.group.onToggle(ctx.row.original), leafField: 'name',\n }) },\n { field: 'amount', header: 'Amount', align: 'right',\n format: { type: 'number', options: { style: 'currency', currency: 'USD' } } },\n ]\n</script>\n\n<SvGrid rowModel={ctl} {columns} />\n```\n\nA value column shows the subtotal on a group row and the cell value on a leaf,\nbecause each grid row spreads its data. The `fieldFn` is the text behind the\ncell: a group's key, `Total`, `Grand total`, a leaf's `leafField`. Copy,\nexport and the clipboard read it, and so does a grand total pinned with\n`grandTotalRow: 'pinnedBottom'`, which the grid formats from the accessor\nrather than through the cell renderer. Want full control? Every grid row\ncarries a `__group` marker (the display row), so you can skip `SvGroupCell`\nand render your own cell from it.\n\nFooter and grand-total rows carry the same `__groupFooter` / `__grandTotal`\nflags the client-side grouping sets, so the grid styles them the way it\nstyles its own subtotals (`.sv-grid-group-footer-row`,\n`.sv-grid-grand-total-row`): a heavier top rule and weight, in the list\nor pinned.\n\nEvery string the group chrome draws is a message you can replace:\n`SvGroupCell` and `SvRowGroupPanel` take `messages` (a partial\n`ServerGroupMessages`: the \"load more\" row, `Total {label}`, `Grand total`,\n`Group by:`, `Apply`, the chip labels for a screen reader, ...), plus\n`locale` on the cell for its numbers, and `serverGroupText` takes the same\nmap as its third argument so a copy reads the way the cell does. The two\ncomponents are mounted by you, not by the grid, so they cannot read\n`localization.text`; pass one object to all three.\n\n```svelte\n<script lang=\"ts\">\n const groupMessages = { total: 'Summe {label}', grandTotal: 'Gesamtsumme', loadMore: '{count} weitere laden', apply: 'Anwenden', cancel: 'Abbrechen' }\n</script>\n\n<SvRowGroupPanel {columns} {groupBy} onChange={...} messages={groupMessages} />\n<!-- and in the column: renderComponent(SvGroupCell, { ..., messages: groupMessages, locale: 'de-DE' }) -->\n```\n\n## Keyboard and accessibility\n\nThe model's `group` accessor set (`rowModel` wires it as `serverGroup`) makes\nthe grid handle tree navigation itself - no app key handling:\n\n- **ArrowRight** expands the focused group row; **ArrowLeft** collapses it;\n **Ctrl+Enter** toggles it, and on a leaf opens or closes its\n [detail panel](#master-detail) when the grid draws one.\n- The grid takes the `treegrid` role and sets `aria-level` + `aria-expanded` on\n each row, so screen readers announce the depth and expanded state.\n- Group rows do not take edits, whatever the column says; a leaf does.\n\nIt works for [tree mode](./server-tree-data.md) the same way.\n\n## Sticky group rows\n\nA country with 60,000 rows is a long scroll with nothing on screen that\nsays which country it is. `stickyGroupRows` keeps the group a row belongs\nto under the header - the country row, and the region row above it - while\nits rows scroll past, the way a section heading stays put in a long list:\n\n```svelte\n<SvGrid rowModel={ctl} {columns} stickyGroupRows />\n```\n\nUnder virtualization the band holds a copy of each ancestor row, drawn by\nthe same renderers (the expander in the copy collapses the group); without\nvirtualization the rows themselves stick. The band comes off the top of\nthe scrolled rows, so nothing jumps when it appears. It is the same prop\nfor client grouping and tree data.\n\n## Blocks, per level\n\nEvery level - the root and each expanded group - is its own block cache. The\ngrid reports what is on screen and the model asks each level for the blocks\nunder the viewport, `blockSize` rows at a time, keeps at most\n`maxBlocksInCache` loaded blocks per level, runs at most\n`maxConcurrentRequests` requests across all levels, and waits\n`blockLoadDebounceMs` for a scroll to settle. A level shows `skeletonRows`\nplaceholders before its first block lands, so an expand never opens onto\nnothing.\n\nA sort, a filter or a regroup turns the rows on screen into placeholders\nuntil the new answer arrives. Against a source that answers quickly, that is\na frame of skeletons and an extra render for nothing. `keepRowsWhileLoading`\n(milliseconds) keeps the current rows on screen instead, until the new\nanswer's first block lands or the time runs out, whichever comes first. A\nfailed block ends the wait at once, so its Retry shows. The default, 0, shows\nthe placeholders straight away.\n\n`levelParams(level, route)` tunes one level: a different `blockSize` or\n`maxBlocksInCache`, `infinite: false` to load a level completely as soon as it\nopens (which also allows sorting it in the browser, see `clientSideSort`), or\n`loadMore: true` to load one block per click behind a \"Load N more\" row, or\n`initialRowCount` to claim a level's size before its first block lands - at\nthe root of flat data that makes `api.scrollToRow(900000)` work before\nanything has loaded, with placeholders under the viewport and the block\nbeneath them the one that loads:\n\n```ts\ncreateServerRowModel(source, {\n groupBy: ['region', 'country'],\n blockSize: 100,\n levelParams: (level) => (level === 2 ? { loadMore: true, blockSize: 50 } : {}),\n})\n```\n\n## Refresh, purge, retry\n\n| Method | Does |\n| ------ | ---- |\n| `refresh({ route, purge })` | Re-fetch a level in place (`route: []` is the root), keeping what is open; `purge: true` drops that subtree's cache first. |\n| `retryLoads()` | Re-fetch every failed block. The grid draws a failed block as a tinted band with one full-width message and a Retry button. |\n| `expandAll({ includeUnloaded })` / `collapseAll()` | Open every loaded group, and with `includeUnloaded` every group that arrives later, until the next collapse. |\n| `isGroupOpenByDefault(route, row)` | Open a group as soon as it arrives. |\n| `getLevelState(route)` / `getCacheState()` | Where each level stands: loaded blocks, failed blocks, row count. `debug: true` logs block lifecycle. |\n| `onStoreRefreshed`, `onGroupOpened`, `onLoadError` | Events for the app's own chrome. |\n\n## What re-requests, and what does not\n\nChanging a **group column** (add, remove, reorder) or an **aggregated column**\nreloads the tree: the rows are different rows. Sorting follows the same logic\nper level: a sort on a plain column re-fetches the leaf levels only, a sort on\na group column re-fetches that column's level (the children of a group keep\ntheir own order), a sort on an aggregated column re-fetches everything.\n`sortAllLevels: true` makes every sort a full\nreload; `clientSideSort: true` sorts a fully loaded level in the browser with\nno request at all.\n\nA **filter** purges everything by default, as a filtered group may have\ndifferent children and different totals. `onlyRefreshFilteredGroups: true`\nre-fetches only the levels whose column changed, and accepts stale counts on\nthe others as the trade.\n\n## Subtotal footers and the grand total\n\nTurn on `groupFooters` and each expanded group gets a **Total** row after its\nchildren, carrying the group's aggregates again. `grandTotalRow` asks the\nserver for a total across the whole (filtered) result on the first root\nrequest - `ServerRequest.needsGrandTotal` in, `ServerResult.grandTotal` out -\nand shows it at the `'top'` or `'bottom'` of the list, or pinned with\n`'pinnedTop'` / `'pinnedBottom'`. The row has the fixed id `sv-grand-total`\nand each footer `sv-group-total:<route>`, so [transactions](./server-transactions.md)\ncan address them.\n\n```ts\ncreateServerRowModel(source, { groupBy: ['region', 'country'], aggregations, groupFooters: true, grandTotalRow: 'pinnedBottom' })\n```\n\n## Paging the tree\n\n`pagination` pages the top level instead of scrolling it: a page is\n`pageSize` top-level rows, each shown with whatever is open beneath it, or\nwith `paginateChildRows` a page of the flattened tree, children counted.\nBlocks stay independent of pages. The grid's footer pager drives it through\n`rowModel` (`pageable` on the grid); `pageSizes` fills the page-size selector\nand `autoPageSize` fits the page to the grid's height.\n\n```ts\ncreateServerRowModel(source, {\n groupBy: ['region', 'country'],\n pagination: { pageSize: 25, pageSizes: [10, 25, 50], paginateChildRows: true },\n})\n```\n\n## A row-group panel (drag to group)\n\n`SvRowGroupPanel` is a \"group by\" bar: it shows the current group columns as\nchips you can remove or drag to reorder, plus a menu to add one, and it accepts a\ncolumn drop (`text/sv-column`). Wire its `onChange` to `setGroupBy`. With\n`applyMode=\"deferred\"` it collects edits behind Apply / Cancel, so one\nsession of changes is one reload rather than one per chip. Pending chips\nsurvive a block landing (the model hands out a fresh `groupBy` array on\nevery emit; the panel compares by value) and are dropped only when the\ngrouping really changes from outside:\n\n```svelte\n<script lang=\"ts\">\n import { SvRowGroupPanel } from '@svgrid/enterprise'\n const groupCols = [{ id: 'region', label: 'Region' }, { id: 'country', label: 'Country' }]\n</script>\n\n<SvRowGroupPanel columns={groupCols} groupBy={view.groupBy} onChange={(g) => ctl.setGroupBy(g)} applyMode=\"deferred\" />\n```\n\n`setLayout({ groupBy, aggregations, pivotBy, pivotMode })` changes several of\nthose at once with a single reload; it is what the pivot designer sends.\n\n## Multi-level grouping is automatic\n\nSet `groupBy: ['region', 'industry', 'quarter']` and the model fetches each\nlevel on demand: the top level returns regions, expanding a region fetches its\nindustries, expanding an industry fetches its quarters, and expanding a quarter\nreturns the raw rows. You never configure the levels - each expand is just\nanother `getRows` with a longer `groupKeys`. `ServerRequest.parentRow` carries\nthe row being expanded, for a backend keyed off it, and `context` on the model\nrides along on every request.\n\n## Delivering rows without a request\n\n`applyRowData({ route, rows, rowCount, startRow })` fills a level's store\ndirectly, bypassing the datasource: a tree payload that ships children with\ntheir parent, or a socket that pushes a whole level. The blocks it fills are\nloaded blocks like any other.\n\n## The win\n\nFor a 100,000-row sales table grouped by three dimensions, the top level returns\na handful of group rows instead of 100,000 raw rows. The client groups nothing\nand holds almost nothing. Grouping 100k rows in JS runs in hundreds of\nmilliseconds; asking the server for the pre-grouped result returns a few rows in\ntens of milliseconds, and the payload shrinks by orders of magnitude. The\nflagship demo runs the whole model over one million rows:\n\n<div data-docs-demo=\"467-server-row-model-1m\" data-height=\"640\"></div>\n\nThe options on this page, each with the request log to show what it\ncosts - grand total positions, subtotal footers, open-by-default levels,\nexpand-all over unloaded groups, refresh against purge, and the sort and\nfilter rules:\n\n<div data-docs-demo=\"473-server-grouping-rules\" data-height=\"620\"></div>\n\nAnd the SQL a backend runs for each of these requests, per dialect, from\n`planQuery` and `planToSql`:\n\n<div data-docs-demo=\"472-server-sql-planner\" data-height=\"600\"></div>\n\n## Master-detail\n\nA leaf can open a detail panel under itself - an order and its line items,\na customer and a chart - without the rows leaving the server. The model\nkeeps the set of open details and, when it flattens the tree, puts a\ndisplay row of kind `detail` right under its leaf, carrying the leaf as\n`master`. The grid draws it through the same two props client-side\nmaster-detail uses, plus a height the virtualizer can count on:\n\n```svelte\n<SvGrid\n rowModel={ctl}\n {columns}\n isDetailRow={(row) => row.__group?.kind === 'detail'}\n renderDetailRow={Detail}\n detailRowHeight={200}\n showDetailToggle\n/>\n\n{#snippet Detail({ row })}\n {@const order = row.__group.master}\n <!-- fetch the lines for order.id here; the panel is 200px and scrolls inside -->\n{/snippet}\n```\n\n`ctl.toggleDetail(id, open?)` opens or closes the panel under the leaf with\nthat `getRowId`, `isDetailOpen(id)` reads it, `closeAllDetails()` clears,\nand `state.openDetails` lists them. A panel closes with the group that\nholds its leaf and goes when the leaf is removed by a transaction.\n`showDetailToggle` draws the chevrons as a\n[row-header column](../rows/master-detail.md#the-toggle-column) next to\nthe row numbers: the model tells the grid which rows are leaves and which\npanels are open, so there is nothing to wire; a group row shows no\nchevron, since its expander is in the group column. **Ctrl+Enter** on a\nleaf opens or closes its panel from the keyboard, and on the panel row\ncloses it.\n\n<div data-docs-demo=\"483-server-master-detail\" data-height=\"620\"></div>\n\n## Without the model (manual pattern)\n\nIf your backend or UI needs something bespoke, you can still assemble grouping by\nhand: fetch pre-grouped rows, render them as ordinary rows, and expand each into\na second `<SvGrid data={detailRows}>` or an [expandable detail row](../rows/master-detail.md)\n(`isDetailRow` + `renderDetailRow`), keeping the expanded group id and its lazily\nfetched detail rows in your own state. The [tree toggle pattern](../rows/tree-rows.md)\nis a third option for a single flat, indented list. `createServerRowModel` is\nthe batteries-included version of exactly this.\n\n<div data-docs-demo=\"114-server-grouping\" data-height=\"480\"></div>\n\n## Try it\n\nGrouping on the client is a prop; the point of the server version is that the\nrollup happens where the rows are. This runs the same shape locally so the\ngroup model is visible - your `query` returns pre-grouped rows instead.\n\n```svelte\n<SvGrid\n data={people}\n {columns}\n groupBy={['department']}\n groupable\n summary\n sortable\n/>\n```\n\nCompare that with what a server has to return: one row per group with its\naggregate already computed, plus the leaves for whichever groups are expanded.\n\n## Group headers the server computed\n\nWhen the server does the rollup it sends group rows and leaf rows in one flat\nlist, already ordered. Rendering that is tree data with a parent field - the\ngrid does not need to know a group was computed elsewhere.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Row = { id: string; parentId: string | null; label: string; total: number }\n\n // What the endpoint returned: two group rows with their totals, plus leaves.\n const fromServer: Row[] = [\n { id: 'g-eng', parentId: null, label: 'Engineering', total: 310000 },\n { id: 'p-ada', parentId: 'g-eng', label: 'Ada Lovelace', total: 142000 },\n { id: 'p-grace',parentId: 'g-eng', label: 'Grace Hopper', total: 168000 },\n { id: 'g-plat', parentId: null, label: 'Platform', total: 327000 },\n { id: 'p-linus',parentId: 'g-plat',label: 'Linus Torvalds', total: 155000 },\n { id: 'p-barb', parentId: 'g-plat',label: 'Barbara Liskov', total: 172000 },\n ]\n\n const columns: GridColumns<Row> = [\n { field: 'label', header: 'Group / person', width: 240 },\n { field: 'total', header: 'Total', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid\n data={fromServer}\n {columns}\n treeData={{ parentField: 'parentId', idField: 'id', column: 'label' }}\n/>\n```\n\n## The previous model\n\n`createServerGroupModel`, `serverGroupRows` and `serverGroupNav` - the\nblock-append model with a \"Load N more\" button - still ship, unchanged, and\nare deprecated in favour of `createServerRowModel`. The new model does\neverything the old one did; `levelParams: { loadMore: true }` is the\n\"Load N more\" behaviour where you want to keep it.\n\n## See also\n\n- [Server-Side Row Model](./server-row-model.md) - the datasource contract that grouping, paging, sort, and filter all share.\n- [Server tree data](./server-tree-data.md) - load-on-demand hierarchies (self-referential trees).\n- [Server pivot](./server-pivot.md) - pivot on the server, with the designer driving the model.\n- [Server transactions](./server-transactions.md) - add, update and remove rows in a loaded level without a request.\n- [Server selection](./server-selection.md) - select-all as a rule across rows the grid never loaded.\n- [Tree data](../rows/tree-rows.md) - the client-side flat-list + toggle pattern.\n"
|
|
5057
5081
|
},
|
|
5058
5082
|
{
|
|
5059
5083
|
"slug": "help/server/server-infinite-scroll",
|
|
@@ -5081,7 +5105,7 @@ export const docs = [
|
|
|
5081
5105
|
"path": "docs/help/server/server-row-model.md",
|
|
5082
5106
|
"title": "Server-Side Row Model (SSRM)",
|
|
5083
5107
|
"section": "Server data",
|
|
5084
|
-
"markdown": "# Server-Side Row Model (SSRM)\n\nWhen the data lives on the server - millions of rows in a database - the grid\nshould hold only the page on screen and push sorting, filtering, and paging to\nthe backend. SvGrid packages this as **one datasource contract**: you implement\na single async `getRows`, and `createServerDataSource` owns the request\nlifecycle (paging, sort, filter, race-safety, writes).\n\nNew to the model? [Server-Side Row Model: a walkthrough](./row-model-walkthrough.md) builds it up one step at a time against a server written on the page; this page is the contract in full.\n\n\n\n<div data-docs-demo=\"148-server-row-model\" data-height=\"480\"></div>\n\n> **Scope.** `createServerDataSource` is the free, flat row model: one page at\n> a time in `page` mode, or one long block-cached list in\n> [`infinite` mode](./server-infinite-scroll.md). Grouping, tree data, pivot,\n> transactions and selection across unloaded rows are the Enterprise\n> [Server-Side Row Model](./server-grouping.md), built on the same contract.\n> See [what this does not do](#what-this-does-not-do).\n\n## Quick start\n\n```ts\nimport { createServerDataSource, type ServerDataSource } from '@svgrid/grid'\n\nconst source: ServerDataSource<Row> = {\n async getRows({ startRow, endRow, sortModel, filterModel }) {\n const res = await fetch('/api/rows', {\n method: 'POST',\n body: JSON.stringify({ startRow, endRow, sortModel, filterModel }),\n })\n const { rows, total } = await res.json()\n return { rows, rowCount: total } // rowCount = total AFTER filtering\n },\n}\n\nlet view = $state(/* ServerState */)\nconst ctl = createServerDataSource(source, { pageSize: 50, onChange: (s) => (view = s) })\nctl.refresh()\n```\n\n## The request your server receives\n\nEvery fetch calls `getRows(request)` with this exact shape:\n\n```ts\ntype ServerRequest = {\n startRow: number // first row wanted, inclusive (= pageIndex * pageSize)\n endRow: number // one past the last row wanted, exclusive\n pageIndex: number\n pageSize: number\n sortModel: ServerSortModel\n filterModel: ServerFilterModel\n}\n```\n\n`startRow` / `endRow` are the **page** bounds (they map to `OFFSET` / `LIMIT`),\nnot block-cache bounds. Return `{ rows, rowCount }` where `rowCount` is the total\ncount **after filtering** - the pager needs it to compute the last page.\n\nEvery request also carries `signal`, an `AbortSignal` the grid aborts when it\nno longer wants the answer: a newer page superseded this one, a block was\npurged or scrolled out of the cache, a group was collapsed, the controller\nwas disposed. Hand it to `fetch(url, { signal })` and a query the user has\nscrolled past is cancelled at the server instead of completing for nothing;\nthe grid ignores the rejection the abort produces. The SvelteKit transport\ndoes this for you and keeps the signal out of the posted body.\n\n### The sort model\n\n```ts\ntype ServerSortModel = Array<{ id: string; desc: boolean }>\n```\n\nMulti-column, in priority order. So this:\n\n```json\n[{ \"id\": \"lastName\", \"desc\": false }, { \"id\": \"age\", \"desc\": true }]\n```\n\nmaps to `ORDER BY last_name ASC, age DESC`.\n\n### The filter model\n\n```ts\ntype ServerFilterModel = {\n global?: string // the quick-filter search box\n columns?: Record<string, { // keyed by column id\n operator: string // equals | contains | startsWith | greaterThan | lessThan | between | isBlank\n value: string\n valueTo?: string // second bound, for `between`\n selectedValues?: string[] // set-filter (facet checklist) selection\n }>\n}\n```\n\nA populated model looks like:\n\n```json\n{\n \"global\": \"berlin\",\n \"columns\": {\n \"status\": { \"operator\": \"equals\", \"value\": \"active\" },\n \"age\": { \"operator\": \"between\", \"value\": \"18\", \"valueTo\": \"65\" },\n \"country\": { \"operator\": \"contains\", \"value\": \"\", \"selectedValues\": [\"DE\", \"FR\"] }\n }\n}\n```\n\n## Translating the model to SQL\n\nMap each column filter's `operator` to a predicate:\n\n| `operator` | SQL |\n| -------------- | -------------------------------- |\n| `equals` | `col = $value` |\n| `contains` | `col ILIKE '%' || $value || '%'` |\n| `startsWith` | `col ILIKE $value || '%'` |\n| `greaterThan` | `col > $value` |\n| `lessThan` | `col < $value` |\n| `between` | `col BETWEEN $value AND $valueTo`|\n| `isBlank` | `col IS NULL OR col = ''` |\n| `selectedValues` present | `col IN ($selectedValues)` |\n\n`global` is a free-text search you `OR` across your searchable columns. Always\nbind values as parameters - never string-concatenate them into SQL.\n\nYou do not have to hand-write that switch. `@svgrid/enterprise` ships\n`normalizeFilters(filterModel)`, which returns `{ predicates, search }` with the\noperators already normalized to `in` / `contains` / `startsWith` / `eq` / `gt` /\n`lt` / `between` - the same helper the built-in REST and SQL sources use. For a\ncomplete, runnable server (route + query builder), follow the\n[Postgres CRUD tutorial](../../enterprise/studio/postgres-grid.md).\n\n## Wiring to the grid\n\nThe controller is a **row model**: hand it to the grid and every seam is\nwired - the rows, the loading flag, external sort and filter, the pager in\npage mode, the visible range and the placeholder rows in infinite mode:\n\n```svelte\n<SvGrid rowModel={ctl} {columns} {features} sortable filterable pageable />\n```\n\nA prop written on the grid beats what the model supplies, so you can adopt\nthe model and still override one piece. The long form spells out the same\nseams by hand, which is also how a model of your own plugs in:\n\n```svelte\n<SvGrid\n data={view.rows}\n {columns} {features}\n sortable filterable\n externalSort externalFilter\n loading={view.loading}\n externalPagination rowCount={view.total} pageIndex={view.pageIndex} pageSize={view.pageSize}\n onSortingChange={(sorting) => ctl.setSort(sorting)}\n onFiltersChange={(f) => ctl.setFilter(f)}\n onPaginationChange={(p) => (p.pageSize !== view.pageSize ? ctl.setPageSize(p.pageSize) : ctl.setPage(p.pageIndex))}\n/>\n```\n\n`externalSort` / `externalFilter` tell the grid to emit intent instead of acting\nlocally; `externalPagination` hands the pager the server's count.\n\n## Infinite mode\n\nPass `mode: 'infinite'` and there are no pages: `state.rows` spans the whole\nresult, placeholder rows stand in for blocks nobody has scrolled to, and\nblocks of `blockSize` rows load as the viewport reaches them, with an LRU\ncap, a concurrency cap, retry for failed blocks and an unknown-count mode.\n[Server-side infinite scroll](./server-infinite-scroll.md) covers it.\n\n## The controller\n\n`createServerDataSource(source, options)` returns:\n\n| Method | Does |\n| ------------------- | -------------------------------------------------------- |\n| `refresh()` | Re-fetch the current page (after a mutation). |\n| `setSort(model)` | New sort, jump to page 0, fetch. |\n| `setFilter(model)` | New filter, jump to page 0, fetch. |\n| `setPage(i)` | Fetch page `i`. |\n| `setPageSize(n)` | New page size, page 0, fetch. |\n| `createRow(input)` | Create through the source, then refresh. Throws if the source has no `createRow`. |\n| `updateRow(id, p)` | Update by id. Optimistic when configured (below). |\n| `deleteRow(id)` | Delete by id. Optimistic when configured. |\n| `getState()` | Snapshot of the current `ServerState`. |\n| `dispose()` | Stop accepting in-flight responses. Call on unmount. |\n\n## The state you render from\n\n`onChange` fires on every transition. The full shape:\n\n```ts\ntype ServerState<Row> = {\n rows: ReadonlyArray<Row>\n total: number // total after filtering (drives the pager)\n loading: boolean // a getRows fetch is in flight\n saving: boolean // a create / update / delete is in flight\n error: unknown // the rejection from the last failed getRows (else null)\n pageIndex: number\n pageSize: number\n pageCount: number\n sortModel: ServerSortModel\n filterModel: ServerFilterModel\n}\n```\n\nBind `loading` to the grid's overlay, `saving` to a toolbar spinner, and check\n`error` to render a retry banner.\n\n## Writes and optimistic updates\n\nImplement whichever of `createRow` / `updateRow` / `deleteRow` your backend\nsupports; the matching controller method calls through and then refreshes the\ncurrent page. Calling one whose source counterpart is missing throws a clear\nerror, so a read-only source stays read-only.\n\nBy default writes are **non-optimistic**: the grid reflects the change only\nafter the follow-up refresh lands. For instant feedback, opt in:\n\n```ts\nconst ctl = createServerDataSource(source, {\n pageSize: 50,\n onChange: (s) => (view = s),\n optimistic: true,\n getRowId: (r) => String(r.id), // required, so a row can be located in the page\n})\n```\n\nNow `updateRow` patches the local row immediately and reconciles with the server\nresult (rolling back on error); `deleteRow` removes it and decrements `total`,\nrestoring both if the server rejects.\n\n## Error handling\n\nIf `getRows` rejects, the controller clears `rows`, stores the rejection in\n`state.error`, and drops `loading` - it never throws into your render. Recover\nby re-fetching:\n\n```svelte\n{#if view.error}\n <div role=\"alert\">\n Could not load rows. <button onclick={() => ctl.refresh()}>Retry</button>\n </div>\n{/if}\n```\n\nA successful fetch clears `error` again.\n\n## Race safety\n\nEvery fetch carries a monotonic id; only the **latest** request is allowed to\nland. A slow response for an old sort/filter can never clobber a newer one - the\nclassic SSRM bug, handled for you. `dispose()` drops everything in flight and\nclears `loading`, so an unmounting component can't leave a stuck spinner.\n\n## What this does not do\n\nBeing honest about the edges so you pick the right tool:\n\n- **Flat rows only.** `createServerDataSource` sends `groupBy: []`. Lazy\n grouping per level, tree data and pivot are `createServerRowModel` in\n `@svgrid/enterprise` - the same `getRows`, with `groupBy`, `groupKeys`,\n `aggregations` and `pivotBy` filled in. See [Server grouping](./server-grouping.md).\n- **A selection is the loaded rows.** Select-all over rows the grid never\n fetched, and a bulk edit sent as a rule, are the Enterprise model's\n [selection](./server-selection.md).\n- **Writes refetch.** A write re-reads the page (or the held blocks); rows are\n not spliced in by hand. Route-addressed [transactions](./server-transactions.md)\n are Enterprise.\n- **Writes are non-optimistic unless you opt in** (above).\n\n## Enterprise: the Server-Side Row Model\n\n`createServerRowModel` from `@svgrid/enterprise` is the same contract with\nthe rest of the request filled in, and the same one-prop wiring:\n\n| Page | What it adds |\n| ---- | ------------ |\n| [Server grouping](./server-grouping.md) | lazy grouping per level, a block cache per level, aggregates, child counts, grand totals, footers, refresh and retry per route, paging over the tree |\n| [Server tree data](./server-tree-data.md) | self-referential trees loaded on expand |\n| [Server pivot](./server-pivot.md) | pivot on the server, columns built from `pivotResultFields`, the pivot designer in server mode |\n| [Server transactions](./server-transactions.md) | add, update and remove rows in a loaded level without a request, sync or batched |\n| [Server selection](./server-selection.md) | select-all as a rule across unloaded rows, a bulk edit by rule |\n\nThe flagship demo runs all of it over one million rows through one\n`rowModel` prop:\n\n<div data-docs-demo=\"467-server-row-model-1m\" data-height=\"640\"></div>\n\n### A backend written for a callback-style datasource\n\nSome server-side row models hand the datasource a `getRows(params)` that\nanswers through `params.success({ rowData, rowCount })`, with the request\nspelt `rowGroupCols` / `valueCols` / `pivotCols` and a `sortModel` of\n`{ colId, sort }`. `@svgrid/enterprise` maps that shape both ways, so a\nbackend built for it needs no rewrite:\n\n- `adaptCallbackDatasource(ds)` wraps a datasource object of that shape\n for `createServerRowModel` or `createServerDataSource`.\n- `toCallbackRequest(request)` turns a `ServerRequest` into that request\n JSON, for a `getRows` that is a `fetch` against an endpoint which already\n parses it; `fromCallbackRequest` reads it, for an endpoint of yours that\n has to serve such a client too.\n- `toCallbackSelectionState` / `fromCallbackSelectionState` do the same for\n a saved selection rule (`toggledNodes` instead of `toggled`); see\n [Server selection](./server-selection.md).\n\n## More examples\n\n### Server-side pivot\n\nThe pivot designer in server mode over a million rows: Rows become groupBy, Columns pivotBy, Values aggregations, and every applied layout is one request.\n\n<div data-docs-demo=\"468-server-pivot\" data-height=\"560\"></div>\n\n### Server tree data (row model)\n\nA file tree the grid never holds whole: expanding a folder is one getRows with the folder path as groupKeys, per-folder refresh, and a file added or deleted without a refetch.\n\n<div data-docs-demo=\"469-server-tree-data\" data-height=\"560\"></div>\n\n### Server transactions (live feed)\n\nChanges the server already made, applied without a refetch: in-place patches with a flash, batched add and remove transactions addressed by route, and a status for every result.\n\n<div data-docs-demo=\"470-server-transactions\" data-height=\"560\"></div>\n\n### Server selection: select all, minus these\n\nSelect-all as a rule the server counts, flat and per group, with a bulk edit the server applies by rule.\n\n<div data-docs-demo=\"471-server-selection\" data-height=\"560\"></div>\n\n### Server row model to SQL\n\nThe statements planToSql renders for every request, per dialect: rows, count, grand total and the two-step pivot.\n\n<div data-docs-demo=\"472-server-sql-planner\" data-height=\"560\"></div>\n\n### Server grouping: totals, sort and refresh rules\n\nGrand total positions, subtotal footers, open-by-default levels, expand-all over unloaded groups, refresh against purge, and what a sort or a filter re-requests, with the request log.\n\n<div data-docs-demo=\"473-server-grouping-rules\" data-height=\"560\"></div>\n\n### Server row model: CRUD\n\nThe four writes against a backend that says no: a form under the focused region, inline edits with a version check, a refused delete, an undo, `saving`, and optimistic mode against a slow server.\n\n<div data-docs-demo=\"482-server-crud\" data-height=\"560\"></div>\n\n### Server row model: master-detail\n\nA detail panel under any order of the grouped, virtualized tree, fetched from its own endpoint when it opens, with the region row held under the header.\n\n<div data-docs-demo=\"483-server-master-detail\" data-height=\"560\"></div>\n\n### Server grouping (row model)\n\nServer-side grouping through one getRows contract: the request carries groupBy + groupKeys, and createServerRowModel owns the group tree - a block cache per level, lazy expand, per-group sums and a subtotal footer, race-safety - mounted through the one rowModel prop. Leaves arrive by scroll, behind a Load N more row, or paged across the whole tree, and the group panel regroups on the fly. Here a 63,000-row in-memory server behind 200ms latency; the grid holds only the groups you expand. The row model ships in @svgrid/enterprise.\n\n<div data-docs-demo=\"496-server-grouping-model\" data-height=\"560\"></div>\n\n### Server-side infinite scroll\n\n100k-event audit log behind a mock API. Sparse chunked load on scroll; sort + filter + search pushed to the server.\n\n<div data-docs-demo=\"33-server-infinite\" data-height=\"560\"></div>\n\n### Server-side grouping + aggregates\n\nGROUP BY + SUM/AVG pushed to the server; pre-aggregated buckets with on-demand drill-in.\n\n<div data-docs-demo=\"114-server-grouping\" data-height=\"560\"></div>\n\n### Server-side data\n\nSort/filter/page round-tripped to a mock endpoint with debounce + cancel.\n\n<div data-docs-demo=\"09-server-side\" data-height=\"560\"></div>\n\n### Server-side rendering\n\nSvelteKit-style SSR with a sandboxed pre-hydration snapshot.\n\n<div data-docs-demo=\"19-ssr\" data-height=\"460\"></div>\n\n## See also\n\n- [Server-side infinite scroll](./server-infinite-scroll.md) - `mode: 'infinite'`: blocks, placeholders, retry, unknown counts.\n- [Tutorial: a Postgres CRUD grid](../../enterprise/studio/postgres-grid.md) - a complete server route + query builder.\n- [Tutorial: a REST CRUD grid](../../enterprise/studio/rest-grid.md) - the same contract over an existing JSON API.\n- [Data binding](../../enterprise/studio/data-binding.md) - the ServerDataSource contract across every backend.\n- [Server-side data](../server-side-data.md) - the wider server-mode overview.\n"
|
|
5108
|
+
"markdown": "# Server-Side Row Model (SSRM)\n\nWhen the data lives on the server - millions of rows in a database - the grid\nshould hold only the page on screen and push sorting, filtering, and paging to\nthe backend. SvGrid packages this as **one datasource contract**: you implement\na single async `getRows`, and `createServerDataSource` owns the request\nlifecycle (paging, sort, filter, race-safety, writes).\n\nNew to the model? [Server-Side Row Model: a walkthrough](./row-model-walkthrough.md) builds it up one step at a time against a server written on the page; this page is the contract in full.\n\n\n\n<div data-docs-demo=\"148-server-row-model\" data-height=\"480\"></div>\n\n> **Scope.** `createServerDataSource` is the free, flat row model: one page at\n> a time in `page` mode, or one long block-cached list in\n> [`infinite` mode](./server-infinite-scroll.md). Grouping, tree data, pivot,\n> transactions and selection across unloaded rows are the Enterprise\n> [Server-Side Row Model](./server-grouping.md), built on the same contract.\n> See [what this does not do](#what-this-does-not-do).\n\n## Quick start\n\n```ts\nimport { createServerDataSource, type ServerDataSource } from '@svgrid/grid'\n\nconst source: ServerDataSource<Row> = {\n async getRows({ startRow, endRow, sortModel, filterModel }) {\n const res = await fetch('/api/rows', {\n method: 'POST',\n body: JSON.stringify({ startRow, endRow, sortModel, filterModel }),\n })\n const { rows, total } = await res.json()\n return { rows, rowCount: total } // rowCount = total AFTER filtering\n },\n}\n\nlet view = $state(/* ServerState */)\nconst ctl = createServerDataSource(source, { pageSize: 50, onChange: (s) => (view = s) })\nctl.refresh()\n```\n\n## The request your server receives\n\nEvery fetch calls `getRows(request)` with this exact shape:\n\n```ts\ntype ServerRequest = {\n startRow: number // first row wanted, inclusive (= pageIndex * pageSize)\n endRow: number // one past the last row wanted, exclusive\n pageIndex: number\n pageSize: number\n sortModel: ServerSortModel\n filterModel: ServerFilterModel\n}\n```\n\n`startRow` / `endRow` are the **page** bounds (they map to `OFFSET` / `LIMIT`),\nnot block-cache bounds. Return `{ rows, rowCount }` where `rowCount` is the total\ncount **after filtering** - the pager needs it to compute the last page.\n\nEvery request also carries `signal`, an `AbortSignal` the grid aborts when it\nno longer wants the answer: a newer page superseded this one, a block was\npurged or scrolled out of the cache, a group was collapsed, the controller\nwas disposed. Hand it to `fetch(url, { signal })` and a query the user has\nscrolled past is cancelled at the server instead of completing for nothing;\nthe grid ignores the rejection the abort produces. The SvelteKit transport\ndoes this for you and keeps the signal out of the posted body.\n\n### The sort model\n\n```ts\ntype ServerSortModel = Array<{ id: string; desc: boolean }>\n```\n\nMulti-column, in priority order. So this:\n\n```json\n[{ \"id\": \"lastName\", \"desc\": false }, { \"id\": \"age\", \"desc\": true }]\n```\n\nmaps to `ORDER BY last_name ASC, age DESC`.\n\n### The filter model\n\n```ts\ntype ServerFilterModel = {\n global?: string // the quick-filter search box\n columns?: Record<string, { // keyed by column id\n operator: string // equals | contains | startsWith | greaterThan | lessThan | between | isBlank\n value: string\n valueTo?: string // second bound, for `between`\n selectedValues?: string[] // set-filter (facet checklist) selection\n }>\n}\n```\n\nA populated model looks like:\n\n```json\n{\n \"global\": \"berlin\",\n \"columns\": {\n \"status\": { \"operator\": \"equals\", \"value\": \"active\" },\n \"age\": { \"operator\": \"between\", \"value\": \"18\", \"valueTo\": \"65\" },\n \"country\": { \"operator\": \"contains\", \"value\": \"\", \"selectedValues\": [\"DE\", \"FR\"] }\n }\n}\n```\n\n## Translating the model to SQL\n\nMap each column filter's `operator` to a predicate:\n\n| `operator` | SQL |\n| -------------- | -------------------------------- |\n| `equals` | `col = $value` |\n| `contains` | `col ILIKE '%' || $value || '%'` |\n| `startsWith` | `col ILIKE $value || '%'` |\n| `greaterThan` | `col > $value` |\n| `lessThan` | `col < $value` |\n| `between` | `col BETWEEN $value AND $valueTo`|\n| `isBlank` | `col IS NULL OR col = ''` |\n| `selectedValues` present | `col IN ($selectedValues)` |\n\n`global` is a free-text search you `OR` across your searchable columns. Always\nbind values as parameters - never string-concatenate them into SQL.\n\nYou do not have to hand-write that switch. `@svgrid/enterprise` ships\n`normalizeFilters(filterModel)`, which returns `{ predicates, search }` with the\noperators already normalized to `in` / `contains` / `startsWith` / `eq` / `gt` /\n`lt` / `between` - the same helper the built-in REST and SQL sources use. For a\ncomplete, runnable server (route + query builder), follow the\n[Postgres CRUD tutorial](../../enterprise/studio/postgres-grid.md).\n\n## Wiring to the grid\n\nThe controller is a **row model**: hand it to the grid and every seam is\nwired - the rows, the loading flag, external sort and filter, the pager in\npage mode, the visible range and the placeholder rows in infinite mode:\n\n```svelte\n<SvGrid rowModel={ctl} {columns} {features} sortable filterable pageable />\n```\n\nA prop written on the grid beats what the model supplies, so you can adopt\nthe model and still override one piece. The long form spells out the same\nseams by hand, which is also how a model of your own plugs in:\n\n```svelte\n<SvGrid\n data={view.rows}\n {columns} {features}\n sortable filterable\n externalSort externalFilter\n loading={view.loading}\n externalPagination rowCount={view.total} pageIndex={view.pageIndex} pageSize={view.pageSize}\n onSortingChange={(sorting) => ctl.setSort(sorting)}\n onFiltersChange={(f) => ctl.setFilter(f)}\n onPaginationChange={(p) => (p.pageSize !== view.pageSize ? ctl.setPageSize(p.pageSize) : ctl.setPage(p.pageIndex))}\n/>\n```\n\n`externalSort` / `externalFilter` tell the grid to emit intent instead of acting\nlocally; `externalPagination` hands the pager the server's count.\n\n## Infinite mode\n\nPass `mode: 'infinite'` and there are no pages: `state.rows` spans the whole\nresult, placeholder rows stand in for blocks nobody has scrolled to, and\nblocks of `blockSize` rows load as the viewport reaches them, with an LRU\ncap, a concurrency cap, retry for failed blocks and an unknown-count mode.\n[Server-side infinite scroll](./server-infinite-scroll.md) covers it.\n\n## The controller\n\n`createServerDataSource(source, options)` returns:\n\n| Method | Does |\n| ------------------- | -------------------------------------------------------- |\n| `refresh()` | Re-fetch the current page (after a mutation). |\n| `setSort(model)` | New sort, jump to page 0, fetch. |\n| `setFilter(model)` | New filter, jump to page 0, fetch. |\n| `setPage(i)` | Fetch page `i`. |\n| `setPageSize(n)` | New page size, page 0, fetch. |\n| `createRow(input)` | Create through the source, then refresh. Throws if the source has no `createRow`. |\n| `updateRow(id, p)` | Update by id. Optimistic when configured (below). |\n| `deleteRow(id)` | Delete by id. Optimistic when configured. |\n| `getState()` | Snapshot of the current `ServerState`. |\n| `dispose()` | Stop accepting in-flight responses. Call on unmount. |\n\n## The state you render from\n\n`onChange` fires on every transition. The full shape:\n\n```ts\ntype ServerState<Row> = {\n rows: ReadonlyArray<Row>\n total: number // total after filtering (drives the pager)\n loading: boolean // a getRows fetch is in flight\n saving: boolean // a create / update / delete is in flight\n error: unknown // the rejection from the last failed getRows (else null)\n pageIndex: number\n pageSize: number\n pageCount: number\n sortModel: ServerSortModel\n filterModel: ServerFilterModel\n}\n```\n\nBind `loading` to the grid's overlay, `saving` to a toolbar spinner, and check\n`error` to render a retry banner.\n\n## Writes and optimistic updates\n\nImplement whichever of `createRow` / `updateRow` / `deleteRow` your backend\nsupports; the matching controller method calls through and then refreshes the\ncurrent page. Calling one whose source counterpart is missing throws a clear\nerror, so a read-only source stays read-only.\n\nBy default writes are **non-optimistic**: the grid reflects the change only\nafter the follow-up refresh lands. For instant feedback, opt in:\n\n```ts\nconst ctl = createServerDataSource(source, {\n pageSize: 50,\n onChange: (s) => (view = s),\n optimistic: true,\n getRowId: (r) => String(r.id), // required, so a row can be located in the page\n})\n```\n\nNow `updateRow` patches the local row immediately and reconciles with the server\nresult (rolling back on error); `deleteRow` removes it and decrements `total`,\nrestoring both if the server rejects.\n\n## Error handling\n\nIf `getRows` rejects, the controller clears `rows`, stores the rejection in\n`state.error`, and drops `loading` - it never throws into your render. Recover\nby re-fetching:\n\n```svelte\n{#if view.error}\n <div role=\"alert\">\n Could not load rows. <button onclick={() => ctl.refresh()}>Retry</button>\n </div>\n{/if}\n```\n\nA successful fetch clears `error` again.\n\n## Race safety\n\nEvery fetch carries a monotonic id; only the **latest** request is allowed to\nland. A slow response for an old sort/filter can never clobber a newer one - the\nclassic SSRM bug, handled for you. `dispose()` drops everything in flight and\nclears `loading`, so an unmounting component can't leave a stuck spinner.\n\n## What this does not do\n\nBeing honest about the edges so you pick the right tool:\n\n- **Flat rows only.** `createServerDataSource` sends `groupBy: []`. Lazy\n grouping per level, tree data and pivot are `createServerRowModel` in\n `@svgrid/enterprise` - the same `getRows`, with `groupBy`, `groupKeys`,\n `aggregations` and `pivotBy` filled in. See [Server grouping](./server-grouping.md).\n- **A selection is the loaded rows.** Select-all over rows the grid never\n fetched, and a bulk edit sent as a rule, are the Enterprise model's\n [selection](./server-selection.md).\n- **Writes refetch.** A write re-reads the page (or the held blocks); rows are\n not spliced in by hand. Route-addressed [transactions](./server-transactions.md)\n are Enterprise.\n- **Writes are non-optimistic unless you opt in** (above).\n\n## Enterprise: the Server-Side Row Model\n\n`createServerRowModel` from `@svgrid/enterprise` is the same contract with\nthe rest of the request filled in, and the same one-prop wiring:\n\n| Page | What it adds |\n| ---- | ------------ |\n| [Server grouping](./server-grouping.md) | lazy grouping per level, a block cache per level, aggregates, child counts, grand totals, footers, refresh and retry per route, paging over the tree |\n| [Server tree data](./server-tree-data.md) | self-referential trees loaded on expand |\n| [Server pivot](./server-pivot.md) | pivot on the server, columns built from `pivotResultFields`, the pivot designer in server mode |\n| [Server transactions](./server-transactions.md) | add, update and remove rows in a loaded level without a request, sync or batched |\n| [Server selection](./server-selection.md) | select-all as a rule across unloaded rows, a bulk edit by rule |\n| [Worker data source](./worker-data-source.md) | rows already in the browser, queried in a Web Worker so sorting and grouping do not block the page |\n\nThe flagship demo runs all of it over one million rows through one\n`rowModel` prop:\n\n<div data-docs-demo=\"467-server-row-model-1m\" data-height=\"640\"></div>\n\n### A backend written for a callback-style datasource\n\nSome server-side row models hand the datasource a `getRows(params)` that\nanswers through `params.success({ rowData, rowCount })`, with the request\nspelt `rowGroupCols` / `valueCols` / `pivotCols` and a `sortModel` of\n`{ colId, sort }`. `@svgrid/enterprise` maps that shape both ways, so a\nbackend built for it needs no rewrite:\n\n- `adaptCallbackDatasource(ds)` wraps a datasource object of that shape\n for `createServerRowModel` or `createServerDataSource`.\n- `toCallbackRequest(request)` turns a `ServerRequest` into that request\n JSON, for a `getRows` that is a `fetch` against an endpoint which already\n parses it; `fromCallbackRequest` reads it, for an endpoint of yours that\n has to serve such a client too.\n- `toCallbackSelectionState` / `fromCallbackSelectionState` do the same for\n a saved selection rule (`toggledNodes` instead of `toggled`); see\n [Server selection](./server-selection.md).\n\n## More examples\n\n### Server-side pivot\n\nThe pivot designer in server mode over a million rows: Rows become groupBy, Columns pivotBy, Values aggregations, and every applied layout is one request.\n\n<div data-docs-demo=\"468-server-pivot\" data-height=\"560\"></div>\n\n### Server tree data (row model)\n\nA file tree the grid never holds whole: expanding a folder is one getRows with the folder path as groupKeys, per-folder refresh, and a file added or deleted without a refetch.\n\n<div data-docs-demo=\"469-server-tree-data\" data-height=\"560\"></div>\n\n### Server transactions (live feed)\n\nChanges the server already made, applied without a refetch: in-place patches with a flash, batched add and remove transactions addressed by route, and a status for every result.\n\n<div data-docs-demo=\"470-server-transactions\" data-height=\"560\"></div>\n\n### Server selection: select all, minus these\n\nSelect-all as a rule the server counts, flat and per group, with a bulk edit the server applies by rule.\n\n<div data-docs-demo=\"471-server-selection\" data-height=\"560\"></div>\n\n### Server row model to SQL\n\nThe statements planToSql renders for every request, per dialect: rows, count, grand total and the two-step pivot.\n\n<div data-docs-demo=\"472-server-sql-planner\" data-height=\"560\"></div>\n\n### Server grouping: totals, sort and refresh rules\n\nGrand total positions, subtotal footers, open-by-default levels, expand-all over unloaded groups, refresh against purge, and what a sort or a filter re-requests, with the request log.\n\n<div data-docs-demo=\"473-server-grouping-rules\" data-height=\"560\"></div>\n\n### Server row model: CRUD\n\nThe four writes against a backend that says no: a form under the focused region, inline edits with a version check, a refused delete, an undo, `saving`, and optimistic mode against a slow server.\n\n<div data-docs-demo=\"482-server-crud\" data-height=\"560\"></div>\n\n### Server row model: master-detail\n\nA detail panel under any order of the grouped, virtualized tree, fetched from its own endpoint when it opens, with the region row held under the header.\n\n<div data-docs-demo=\"483-server-master-detail\" data-height=\"560\"></div>\n\n### Server grouping (row model)\n\nServer-side grouping through one getRows contract: the request carries groupBy + groupKeys, and createServerRowModel owns the group tree - a block cache per level, lazy expand, per-group sums and a subtotal footer, race-safety - mounted through the one rowModel prop. Leaves arrive by scroll, behind a Load N more row, or paged across the whole tree, and the group panel regroups on the fly. Here a 63,000-row in-memory server behind 200ms latency; the grid holds only the groups you expand. The row model ships in @svgrid/enterprise.\n\n<div data-docs-demo=\"496-server-grouping-model\" data-height=\"560\"></div>\n\n### Server-side infinite scroll\n\n100k-event audit log behind a mock API. Sparse chunked load on scroll; sort + filter + search pushed to the server.\n\n<div data-docs-demo=\"33-server-infinite\" data-height=\"560\"></div>\n\n### Server-side grouping + aggregates\n\nGROUP BY + SUM/AVG pushed to the server; pre-aggregated buckets with on-demand drill-in.\n\n<div data-docs-demo=\"114-server-grouping\" data-height=\"560\"></div>\n\n### Server-side data\n\nSort/filter/page round-tripped to a mock endpoint with debounce + cancel.\n\n<div data-docs-demo=\"09-server-side\" data-height=\"560\"></div>\n\n### Server-side rendering\n\nSvelteKit-style SSR with a sandboxed pre-hydration snapshot.\n\n<div data-docs-demo=\"19-ssr\" data-height=\"460\"></div>\n\n## See also\n\n- [Server-side infinite scroll](./server-infinite-scroll.md) - `mode: 'infinite'`: blocks, placeholders, retry, unknown counts.\n- [Tutorial: a Postgres CRUD grid](../../enterprise/studio/postgres-grid.md) - a complete server route + query builder.\n- [Tutorial: a REST CRUD grid](../../enterprise/studio/rest-grid.md) - the same contract over an existing JSON API.\n- [Data binding](../../enterprise/studio/data-binding.md) - the ServerDataSource contract across every backend.\n- [Server-side data](../server-side-data.md) - the wider server-mode overview.\n"
|
|
5085
5109
|
},
|
|
5086
5110
|
{
|
|
5087
5111
|
"slug": "help/server/server-selection",
|
|
@@ -5111,6 +5135,13 @@ export const docs = [
|
|
|
5111
5135
|
"section": "Server data",
|
|
5112
5136
|
"markdown": "# Server tree data (load on demand) - Enterprise\n\nWhen a hierarchy is too large to ship up front - a file system, an org\nchart with tens of thousands of people, a geographic drill-down - you do\nnot want to fetch every node before the grid renders. The answer is to\n**seed only the roots**, then fetch each node's children the first time\nthe user expands it.\n\nThis page covers **self-referential tree data** (a node's children are more\nrows of the same shape, found by `parentId`). That is still a pattern you\nassemble over the grid's `data` prop: you keep a flat `allNodes` array plus an\n`expanded` map, derive the visible rows, and run an async `toggle(id)` that\nfetches, appends, and caches. If that flat-tree derivation is new to you, read\n[Tree data](../rows/tree-rows.md) first - this page is its lazy-loading\nextension.\n\n## First-class tree mode\n\nA file tree the grid never holds whole, each folder read on first expand:\n\n<div data-docs-demo=\"469-server-tree-data\" data-height=\"560\"></div>\n\n`createServerRowModel` handles self-referential trees too - turn on\n`treeData` and give it `getRowId` (the node id) and `hasChildren` (whether a\nnode can expand). It owns the lazy expand/collapse, a block cache per node,\nrefresh and retry per route, and race-safety; each expand calls `getRows`\nwith `groupKeys` set to the path of node ids, and you return that node's\ndirect children, one block at a time:\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, renderComponent } from '@svgrid/grid'\n import { createServerRowModel, SvGroupCell } from '@svgrid/enterprise'\n\n type Node = { id: string; name: string; expandable: boolean }\n\n const ctl = createServerRowModel<Node>(source, {\n treeData: true,\n getRowId: (n) => n.id,\n hasChildren: (n) => n.expandable,\n isGroupOpenByDefault: (route) => route.length < 2, // the first two levels open on arrival\n })\n ctl.refresh() // load the roots (getRows with groupKeys: [])\n\n const columns = [\n { id: 'name', header: 'Name',\n cell: (ctx) => renderComponent(SvGroupCell, { row: ctx.row.original, onToggle: () => ctl.group.onToggle(ctx.row.original), leafField: 'name' }) },\n ]\n</script>\n\n<SvGrid rowModel={ctl} {columns} />\n```\n\nThe source's `getRows` returns a node's children for the requested path; a row\nis expandable when `hasChildren` returns true. `rowModel` wires the rows, the\ntreegrid keyboard (ArrowRight / ArrowLeft), the visible range and the\nplaceholder rows; `refresh({ route })` reloads one node's children in place,\n`applyRowData` fills a node from a payload that shipped with its parent, and\n[transactions](./server-transactions.md) add or remove children without a\nrequest. See [Server grouping](./server-grouping.md) for the full contract\nand the aggregate case. The rest of this page is the **roll-your-own**\nalternative, for when you want to own the flat-row derivation yourself.\n\n### Moving rows\n\nA file dragged onto a folder is a move on the server, not a reorder of the\nrows the grid holds. Two pieces do it. The grid prop `onRowDrop` hands you\nevery drop instead of touching the data: the dragged row, the row it landed\non (or `null` below the last one), that row's display index, and a side.\nOver the top or bottom edge the side is `before` or `after`; over the middle\nof a group row it is `into`, and the grid tints the whole row rather than\ndrawing a line, so the user sees the difference. The middle band exists\nonly when `onRowDrop` is set. The model method `moveRow(id, toRoute,\n{ patch?, addIndex? })` then writes the move through the source's\n`updateRow` when you give it a patch, takes the row out of the level it\nwas in and puts it into `toRoute` at `addIndex` (the end when omitted):\n\n```svelte\n<SvGrid\n rowModel={ctl}\n {columns}\n rowDragManaged\n onRowDrop={async ({ row, target, side }) => {\n const t = target?.__group\n // Into a folder: its path. Beside a row: that row's folder.\n const toRoute = !t ? [] : side === 'into' && t.kind === 'group' ? [...t.path] : t.kind === 'group' ? t.path.slice(0, -1) : [...(t.route ?? [])]\n const parent = toRoute.at(-1) ?? 'root'\n await ctl.moveRow(row.id, toRoute, { patch: { parent } })\n }}\n/>\n```\n\nThe result carries the row the server returned and the status of the add.\nA target folder nothing has opened has no cached level to add to, so the\nstatus is `storeNotFound`: the row is gone from its old level, the closed\nfolder's `childCount` badge already moved, and the folder shows the row\nwhen it opens. Demo 469 above expands the folder in that case. `moveRow`\nrejects a row that is not loaded; a folder moves with its subtree (the\ncached levels under its old key are dropped and re-read on expand). Rows\ndragged from another grid still go through `onRowDragEnd` / `rowDropTarget`.\n\n<img src=\"/docs-media/server-tree-lazy.svg\" alt=\"Load-on-demand tree flow: only roots are seeded; expanding a node fires an async fetchChildren call that shows a loading placeholder; the fetched children are appended to the flat rows and cached so re-expanding is instant.\" width=\"100%\" />\n\n<div data-docs-demo=\"31-lazy-tree-load\" data-height=\"480\"></div>\n\n## The moving parts\n\nFour pieces of state, and one async action:\n\n- `allNodes` - a **flat** array of every node loaded *so far*. It starts\n as just the roots and grows as the user expands branches.\n- `expanded` - a `Record<string, boolean>` of which node ids are open.\n- each node carries a `loadState: 'unloaded' | 'loading' | 'loaded'` and\n an `expandable` flag, so you know whether a fetch is still needed.\n- `visibleRows` - derived: a depth-first walk from the roots that stops\n descending at collapsed nodes and injects a placeholder row while a\n node is loading.\n- `toggle(id)` - the async action that expands/collapses and fetches.\n\n## Seeding the roots\n\nLoad only the top level into `allNodes`. Every seeded node is marked\n`unloaded` so the first expand triggers a fetch:\n\n```ts\ntype LoadState = 'unloaded' | 'loading' | 'loaded'\n\ntype Node = {\n id: string\n parentId: string | null\n depth: number\n name: string\n expandable: boolean\n loadState: LoadState\n childIds: string[]\n}\n\nlet allNodes = $state<Node[]>(await fetchChildren('root')) // roots only\nlet expanded = $state<Record<string, boolean>>({})\n```\n\n`fetchChildren(id)` is **your** function - a `fetch()` to your API, a SQL\ncall in a SvelteKit endpoint, whatever. It returns that node's direct\nchildren, each seeded `unloaded` with an empty `childIds`.\n\n## Toggle: fetch on first expand, cache after\n\n`toggle` flips the expanded flag, and - only when opening a node that has\nnot been loaded yet - sets `loadState: 'loading'`, awaits\n`fetchChildren`, then marks it `'loaded'` and appends the children to the\nflat array. A node already `'loaded'` never fetches again, so re-expand\nis instant:\n\n```ts\nasync function toggle(id: string) {\n const isOpen = !!expanded[id]\n expanded = { ...expanded, [id]: !isOpen }\n if (isOpen) return // collapsing - nothing to fetch\n\n const node = allNodes.find((n) => n.id === id)\n if (!node || !node.expandable || node.loadState === 'loaded') return // cached\n\n allNodes = allNodes.map((n) => (n.id === id ? { ...n, loadState: 'loading' } : n))\n\n try {\n const children = await fetchChildren(id)\n allNodes = allNodes\n .map((n) =>\n n.id === id\n ? { ...n, loadState: 'loaded', childIds: children.map((c) => c.id) }\n : n,\n )\n .concat(children)\n } catch (err) {\n // roll back so the user can retry\n allNodes = allNodes.map((n) => (n.id === id ? { ...n, loadState: 'unloaded' } : n))\n console.error('fetchChildren failed', err)\n }\n}\n```\n\nTwo things worth calling out:\n\n- **Immutable updates.** Each step reassigns `allNodes` / `expanded` so\n Svelte's `$state` reactivity fires. Never mutate a node in place.\n- **The `catch` rolls `loadState` back to `'unloaded'`**, so a failed\n request leaves the node collapsible and retryable instead of stuck on\n a spinner.\n\n## The visible rows and the loading placeholder\n\nDerive what the grid renders. Walk from the roots; at an expanded node\nthat is still `loading`, push a synthetic placeholder row instead of\ndescending:\n\n```ts\ntype ViewRow = Node | { id: string; parentId: string; depth: number; placeholder: true }\n\nconst visibleRows = $derived.by(() => {\n const out: ViewRow[] = []\n const byId = new Map(allNodes.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]) return\n if (node.loadState === 'loading') {\n out.push({ id: `${id}__loading`, parentId: id, depth: node.depth + 1, placeholder: true })\n return\n }\n for (const cid of node.childIds) walk(cid)\n }\n for (const root of allNodes.filter((n) => n.parentId === null)) walk(root.id)\n return out\n})\n```\n\nHand `visibleRows` to `<SvGrid data={visibleRows} ...>` like any other\ndataset. Render the placeholder as a spinner row inside the name-column\ncell snippet (see [Tree data](../rows/tree-rows.md) for the chevron +\nindentation cell), keying off a `placeholder` type guard.\n\n## Keyboard navigation\n\nGive the tree column standard tree-grid keys. Intercept at the window\nlevel **with a capture listener** so your handler runs before the grid's\nown arrow-key cell mover, and only act when the active cell is on the\nname column:\n\n```ts\nlet activeCol = $state('')\nlet activeRowIndex = $state(0)\n\n$effect(() => {\n function onKey(e: KeyboardEvent) {\n if (activeCol !== 'name') return\n const row = visibleRows[activeRowIndex]\n if (!row || 'placeholder' in row || !row.expandable) return\n const isOpen = !!expanded[row.id]\n if (e.key === 'ArrowRight' && !isOpen) { e.preventDefault(); void toggle(row.id) }\n else if (e.key === 'ArrowLeft' && isOpen) { e.preventDefault(); void toggle(row.id) }\n else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); void toggle(row.id) }\n }\n window.addEventListener('keydown', onKey, true)\n return () => window.removeEventListener('keydown', onKey, true)\n})\n```\n\n- **Right** expands a collapsed node (firing the fetch on first open).\n- **Left** collapses an expanded one.\n- **Enter** / **Space** toggle.\n\nTrack the active cell by wiring `onActiveCellChange` on the grid:\n\n```svelte\n<SvGrid\n data={visibleRows}\n {columns}\n onActiveCellChange={(args) => { activeCol = args.columnId; activeRowIndex = args.rowIndex }}\n/>\n```\n\nRegular arrow keys still move the active cell on non-tree columns - the\ntree keys only fire when focus is on the name column.\n\n## Why this stays a pattern\n\nBecause the tree lives entirely in your derived state, everything else -\nsorting, custom cells, roll-up totals, styling - is the same code you\nwould write for a fully-seeded tree. The only difference is that\n`allNodes` grows over time. There is no server-tree mode to configure and\nno contract to satisfy; you own the fetch, the cache, and the shape of\nthe rows.\n\n## More examples\n\n### Server grouping (row model)\n\nServer-side grouping through one getRows contract: the request carries groupBy + groupKeys, and createServerRowModel owns the group tree - a block cache per level, lazy expand, per-group sums and a subtotal footer, race-safety - mounted through the one rowModel prop. Leaves arrive by scroll, behind a Load N more row, or paged across the whole tree, and the group panel regroups on the fly. Here a 63,000-row in-memory server behind 200ms latency; the grid holds only the groups you expand. The row model ships in @svgrid/enterprise.\n\n<div data-docs-demo=\"496-server-grouping-model\" data-height=\"560\"></div>\n\n## Try it\n\nServer tree data is the same flat parent/child shape as the client version - the\nonly difference is where the rows come from. Loading a branch means appending\nits children to the array you already hold.\n\n```svelte {runnable}\n<script lang=\"ts\">\n type Node = { id: string; parentId: string | null; name: string; size: string }\n\n // What the server has. A real one returns only the requested branch.\n const store: Node[] = [\n { id: 'src', parentId: null, name: 'src', size: '-' },\n { id: 'src/app', parentId: 'src', name: 'app.ts', size: '4 KB' },\n { id: 'src/lib', parentId: 'src', name: 'lib', size: '-' },\n { id: 'src/lib/a',parentId: 'src/lib', name: 'api.ts', size: '9 KB' },\n { id: 'pub', parentId: null, name: 'public', size: '-' },\n { id: 'pub/ico', parentId: 'pub', name: 'favicon.ico', size: '1 KB' },\n ]\n\n // Start with the roots; \"fetch\" pulls a branch in on demand.\n let loaded = $state<Node[]>(store.filter((n) => n.parentId === null))\n\n function load(parentId: string) {\n const kids = store.filter((n) => n.parentId === parentId)\n const have = new Set(loaded.map((n) => n.id))\n loaded = [...loaded, ...kids.filter((k) => !have.has(k.id))]\n }\n\n const cols: GridColumns<Node> = [\n { field: 'name', header: 'Name', width: 260 },\n { field: 'size', header: 'Size', width: 100 },\n ]\n</script>\n\n<div>\n <button type=\"button\" onclick={() => load('src')}>Load src/</button>\n <button type=\"button\" onclick={() => load('src/lib')}>Load src/lib/</button>\n <button type=\"button\" onclick={() => load('pub')}>Load public/</button>\n</div>\n\n<SvGrid\n data={loaded}\n columns={cols}\n treeData={{ parentField: 'parentId', idField: 'id', column: 'name' }}\n/>\n```\n\n## See also\n\n- [Server-Side Row Model](./server-row-model.md) - the page-based\n datasource contract for flat data that lives on the server.\n- [Server grouping](./server-grouping.md) - the sibling pattern: the\n backend returns pre-aggregated group rows and expanding one drills into\n its detail rows.\n- [Tree data](../rows/tree-rows.md) - the flat-list + derived-visible-rows\n foundation this page extends, including the chevron cell and roll-ups.\n"
|
|
5113
5137
|
},
|
|
5138
|
+
{
|
|
5139
|
+
"slug": "help/server/worker-data-source",
|
|
5140
|
+
"path": "docs/help/server/worker-data-source.md",
|
|
5141
|
+
"title": "Worker data source - Enterprise",
|
|
5142
|
+
"section": "Server data",
|
|
5143
|
+
"markdown": "# Worker data source - Enterprise\n\nWhen every row is already in the browser, a sort or a grouping still has to\nwalk all of them, and on the main thread nothing else happens until it is\ndone: the page does not paint, scroll or take input. `createWorkerDataSource`\nmoves that work into a Web Worker. The worker holds the rows and answers the\ngrid's requests the way a server would, and the\n[server-side row model](./server-row-model.md) asks it for one block of rows\nat a time, so only the rows on screen cross back to the page.\n\n<div data-docs-demo=\"500-worker-data-source\" data-height=\"640\"></div>\n\nThe demo puts the same orders table in two grids. **Free grid** hands the rows to\n`<SvGrid data>` and the grid sorts, filters and groups them itself, on the page,\nwith nothing turned off. **Enterprise: Web Worker** builds the rows in a worker\nand runs the same operations there. Pick a row count, sort, group or search, and\ncompare the meters in the footer: the slowest request and the longest frame. The\ndot next to the row count is moved by JavaScript on every frame, so it stops\nwhenever the page is blocked.\n\n## Two files\n\nThe worker side is one call. Import it from `@svgrid/enterprise/worker`, the\nentry point with no Svelte and no DOM in its import graph:\n\n```ts\n// orders.worker.ts\nimport { serveWorkerDataSource } from '@svgrid/enterprise/worker'\n\nserveWorkerDataSource({\n rows: () => fetch('/api/orders.json').then((r) => r.json()),\n})\n```\n\nThe page creates the worker and hands it to `createWorkerDataSource`, which\nreturns an ordinary `ServerDataSource`:\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n import { createServerRowModel, createWorkerDataSource } from '@svgrid/enterprise'\n\n const worker = new Worker(new URL('./orders.worker.ts', import.meta.url), { type: 'module' })\n const ctl = createServerRowModel(createWorkerDataSource(worker), {\n groupBy: ['region'],\n aggregations: [{ col: 'amount', fn: 'sum' }],\n keepRowsWhileLoading: 500,\n })\n ctl.refresh()\n $effect(() => () => ctl.dispose())\n</script>\n\n<SvGrid rowModel={ctl} {columns} sortable filterable />\n```\n\n`new Worker(new URL(...), { type: 'module' })` written out in full is the form\nVite recognises and bundles. In SvelteKit, create the worker in the browser\nonly (in `onMount`, an `$effect`, or behind `browser`), since there is no\n`Worker` during server rendering.\n\n`ctl.dispose()` calls the source's `destroy()`, which terminates the worker.\n\n`keepRowsWhileLoading` keeps the rows on screen after a sort, a filter or a\nregroup until the worker's answer lands (here for up to 500 ms). The worker\nusually answers well inside that, so the grid goes from the old rows straight to\nthe new ones, with no skeleton frame between them and one render instead of two.\nSee [server grouping](./server-grouping.md#blocks-per-level).\n\nThe free `createServerDataSource` takes the same source if you want flat\npaging or infinite scroll without the Enterprise row model's grouping:\n`createServerDataSource(createWorkerDataSource(worker), { pageSize: 100 })`.\n\n## Where the rows come from\n\n| Rows | How | When to use it |\n| --- | --- | --- |\n| Loaded in the worker | `serveWorkerDataSource({ rows: () => fetch(...) })` | The usual case. The full array never exists on the main thread. |\n| An array in the worker | `serveWorkerDataSource({ rows: data })` | Data the worker generates or imports. |\n| Sent from the page | `createWorkerDataSource(worker, { rows })` | The rows are already on the page. Sending them is a structured clone, which runs on the main thread. |\n| Replaced later | `source.setRows(next)` | A new dataset without a new worker. Resolves with the new row count. |\n\nRequests that arrive before the rows exist wait for them. `source.ready`\nresolves with the row count once the worker holds data, and rejects if the\nworker's loader threw or the worker script failed to load. After a failure,\nevery call rejects with the same error and the grid shows Retry on the\naffected rows.\n\n## What the worker can and cannot do\n\nInside the worker, requests run through a columnar engine that answers exactly as\n`createInMemoryDataSource`, the reference implementation of the datasource\ncontract, does: the same rows, in the same order, with the same aggregate values.\nIt supports everything that contract describes: sort, column filters, set filters, global search,\nthe advanced filter expression, grouping to any depth with aggregates and\nchild counts, the grand total, server pivot, and the write methods\n(`createRow`, `updateRow`, `deleteRow`, `updateWhere`). `getAggregate` is\nthere too, for `SvSchemaChart`.\n\nThe engine keeps each field as a dictionary: its distinct values, and one integer\ncode per row. A filter or a search is decided once per distinct value rather than\nonce per row, a sort is a counting sort over the ranks of those values, and a\ngrouping buckets rows by code. Three cases go to the reference implementation\ninstead, which holds the same rows: the advanced filter expression, pivot, and a\nsort on a field that mixes numbers with other values. Those answer correctly,\nwithout the speed-up.\n\nA worker receives data, not code, so your functions stay on the page. A\ncolumn's `valueGetter`, `comparator` or custom filter function does not reach\nthe worker. Sorting and filtering in the worker go by field value, the way a\ndatabase works. If a column needs a computed value, compute it into the rows\nbefore they reach the worker.\n\nThe schema decides which fields can be filtered, sorted and grouped, and how a\nfilter value typed as text is compared. Pass an `EntitySchema` as `schema`, or\nleave it out and `inferSchemaFromRows` reads one off the first 200 rows:\n\n- A field is `number` when every non-empty sampled value is a number, and\n `boolean` when every one is a boolean. Everything else is `text`.\n- The id field is `id` when the rows have one, otherwise the first field.\n Pass `idField` to choose.\n- Dates should be ISO strings (`2026-09-30`), which sort correctly as text, or\n epoch numbers. A `Date` object is compared by its `toString()`.\n- Global search scans the text fields, skipping the id.\n\n## Requests and the cache\n\nThe worker builds the dictionaries in idle time after the rows load, one field at\na time between requests, so the first sort on a column finds its work done. A\nwrite (`createRow`, `updateRow`, `deleteRow`, `updateWhere`) drops them and the\nworker builds them again once it is idle.\n\nThe worker runs one request at a time. When the grid abandons a block (the\nuser scrolled past it, or the sort changed before it loaded), the request's\n`AbortSignal` fires and the page sends a cancel. The worker drops the request\nif it has not started it, so a fast scroll does not leave a queue of blocks\nnobody will see.\n\nScrolling asks for the same query once per block, with only the row range\nchanging. The worker keeps the last 8 filtered, sorted and grouped results and\nanswers a request for another range of the same query by slicing one. Each\nwrite clears the cache.\n\nThe same cache is available on the main thread as an option of the in-memory\nsource:\n\n```ts\ncreateInMemoryDataSource(rows, schema, { cacheResults: true })\n```\n\nIt is off by default there, because a cached result does not see a row object\nchanged in place. Only changes made through the source's own write methods\nclear it. The worker's engine keeps its own cache of the same kind.\n\n## Sharing a worker\n\nEvery message the data source sends carries a `channel` field, and each side\nignores messages without it. A worker can handle messages of its own next to\nthe data source. The demo uses one to tell the worker how many rows to build:\n\n```ts\n// orders.worker.ts\nlet resolveCount: (n: number) => void = () => {}\nconst count = new Promise<number>((resolve) => (resolveCount = resolve))\nself.addEventListener('message', (e) => {\n if (e.data?.type === 'orders:init') resolveCount(e.data.rows)\n})\nserveWorkerDataSource({ rows: async () => makeOrders(await count) })\n```\n\n## API\n\n`createWorkerDataSource(worker, options?)` (from `@svgrid/enterprise` or\n`@svgrid/enterprise/server`, main thread):\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `rows` | `TData[]` | Rows to copy into the worker. Leave it out when the worker loads its own. |\n| `schema` | `EntitySchema<TData>` | Sent with `rows`. Default: the worker's schema, or one inferred from the rows. |\n| `idField` | `string` | The id field when the schema is inferred. |\n\nIt returns the `ServerDataSource` methods (`getRows`, `createRow`,\n`updateRow`, `deleteRow`, `updateWhere`) plus `getAggregate`, `ready`,\n`setRows(rows, schema?)` and `destroy()`. `worker` can be anything with\n`postMessage` and `addEventListener`, a `MessagePort` included.\n\n`serveWorkerDataSource(options?)` (from `@svgrid/enterprise/worker`, inside\nthe worker):\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `rows` | `TData[]` or a function returning one (or a promise of one) | The rows, loaded in the worker. Leave it out to wait for rows from the page. |\n| `schema` | `EntitySchema<TData>` | Default: `inferSchemaFromRows`. |\n| `idField` | `string` | The id field when the schema is inferred. |\n| `scope` | `WorkerScopeLike` | Where to listen. Default: the worker's global scope. |\n\nIt returns `{ dispose() }`, which stops answering.\n"
|
|
5144
|
+
},
|
|
5114
5145
|
{
|
|
5115
5146
|
"slug": "help/shadcn",
|
|
5116
5147
|
"path": "docs/help/shadcn.md",
|
|
@@ -5165,7 +5196,7 @@ export const docs = [
|
|
|
5165
5196
|
"path": "docs/help/sheet/start.md",
|
|
5166
5197
|
"title": "Spreadsheet: getting started",
|
|
5167
5198
|
"section": "Spreadsheet",
|
|
5168
|
-
"markdown": "\n# Spreadsheet: getting started\n\nThe one component a spreadsheet needs, how it takes data, how a formula\nreaches another sheet, and the document underneath that a save and a\nreload go through. The other spreadsheet tutorials assume this one, and\n[The spreadsheet shell](../cells/spreadsheet-shell.md) is the reference\nbehind all of them: every prop, every dialog, the file formats, the\nlimits.\n\n`SvSheet` is a spreadsheet, not a mode of the grid: it owns a workbook of\nformulas, address-keyed formats, comments and rules, and draws it with\n`<SvGrid>` underneath. It ships in `@svgrid/enterprise` on top of\n`@svgrid/grid`; `jszip` is a peer you add only when a file is saved or\nopened as `.xlsx` or `.ods`.\n\n```bash\nnpm install @svgrid/grid @svgrid/enterprise\n```\n\nThe examples on this page share one setup: a small budget as raw text,\nand the lookup that formats a rectangle of cells by row and column\nindex.\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvSheet, createWorkbook, createSheetDocument, sheetCellsFromRows } from '@svgrid/enterprise'\n\n // Every cell is the text the user would have typed: a number as its\n // digits, a formula starting with `=`, an empty string for nothing.\n const budget: string[][] = [\n ['Line', 'Jan', 'Feb', 'Mar', 'Q1'],\n ['Rent', '2400', '2400', '2400', '=SUM(B2:D2)'],\n ['Payroll', '18500', '18500', '19200', '=SUM(B3:D3)'],\n ['Marketing', '3100', '4800', '2600', '=SUM(B4:D4)'],\n ['Travel', '900', '1350', '400', '=SUM(B5:D5)'],\n ['Total', '=SUM(B2:B5)', '=SUM(C2:C5)', '=SUM(D2:D5)', '=SUM(E2:E5)'],\n ]\n\n // formats.set takes rectangles as [minRow, minCol, maxRow, maxCol],\n // 0-based, and this lookup turns an index into the key the store uses.\n const at = { rowIdAt: (i: number) => `r${i}`, columnIdAt: (i: number) => String.fromCharCode(65 + i) }\n</script>\n```\n\n## The minimum\n\nNo props at all is a working spreadsheet: fifty rows, twelve columns, the\nribbon, the formula bar, the sheet tabs and the status bar. Click a cell\nand type a number, or `=SUM(A1:A5)`, and the Excel keyboard is already\nlive: `Ctrl+Arrow` to the edge of a block, `Ctrl+D` and `Ctrl+R` to fill\ndown and right, `Ctrl+;` for today, `Alt+=` for AutoSum, `Ctrl+Z` for a\nwhole action rather than one cell.\n\n```svelte {runnable}\n<SvSheet />\n```\n\n<div data-docs-demo=\"207-blank-sheet\" data-height=\"520\"></div>\n\n## Seed it with data\n\n`data` takes the sheets to start with, each a name and its cells as raw\ntext, row by row. A ragged row is fine; a missing cell reads as empty.\nNumbers are digits, dates are `yyyy-mm-dd`, and a formula is the text\nthat starts with `=`, exactly as the user would have typed it - the\nsheet works the values out.\n\n```svelte {runnable}\n<SvSheet data={[{ name: 'Budget', cells: budget }]} rows={12} columns={6} />\n```\n\nChange any month and the row's Q1 and the Total row recompute. What the\nsheet keeps is the text, so `getState()` (below) hands back `=SUM(B2:D2)`,\nnot `7200`.\n\n## A formula that reads another sheet\n\nA workbook with more than one sheet is `createWorkbook`, which takes the\nsame list `data` does and gives you the object the formulas run through.\nA reference qualifies the sheet the way Excel writes it: `Prices!B2`, or\n`'Price list'!B2` when the name has a space. `$` pins a row or a column\nwhen the formula is filled down.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const wb = createWorkbook([\n { name: 'Orders', cells: [\n ['Item', 'Qty', 'Unit', 'Amount'],\n ['Desk', '4', '=XLOOKUP(A2,Prices!$A$2:$A$4,Prices!$B$2:$B$4)', '=B2*C2'],\n ['Chair', '12', '=XLOOKUP(A3,Prices!$A$2:$A$4,Prices!$B$2:$B$4)', '=B3*C3'],\n ['Lamp', '6', '=XLOOKUP(A4,Prices!$A$2:$A$4,Prices!$B$2:$B$4)', '=B4*C4'],\n ['Total', '', '', '=SUM(D2:D4)'],\n ] },\n { name: 'Prices', cells: [\n ['Item', 'Unit price'],\n ['Desk', '320'],\n ['Chair', '85'],\n ['Lamp', '40'],\n ] },\n ])\n</script>\n\n<SvSheet workbook={wb} rows={10} columns={6} />\n```\n\nSwitch to the Prices tab, change a price, and switch back: the amounts\nfollowed. `Ctrl+PageUp` and `Ctrl+PageDown` move between sheets from the\nkeyboard, and `Shift+F11` adds one. [Workbooks](../cells/workbooks.md) is\nthe page on sheets that read each other; [Formulas](./formulas.md) is the\npage on what a formula can say.\n\n## A look it opens with\n\nA sheet a person types into can be formatted by hand from the ribbon. A\nsheet you ship should open formatted, or it is a grid of raw numbers\nuntil someone tidies it. `formats` takes cell formats keyed by address\n(`'Orders!F2'` reaches another sheet), and `columnWidths` widths keyed by\nletter:\n\n```svelte {runnable}\n<SvSheet\n data={[{ name: 'Budget', cells: budget }]}\n rows={12}\n columns={6}\n columnWidths={{ A: 130 }}\n formats={{\n A1: { bold: true, fill: '#e2e8f0' }, B1: { bold: true, fill: '#e2e8f0' }, C1: { bold: true, fill: '#e2e8f0' }, D1: { bold: true, fill: '#e2e8f0' }, E1: { bold: true, fill: '#e2e8f0' },\n B2: { numFmt: '#,##0' }, C2: { numFmt: '#,##0' }, D2: { numFmt: '#,##0' }, E2: { numFmt: '#,##0' },\n B3: { numFmt: '#,##0' }, C3: { numFmt: '#,##0' }, D3: { numFmt: '#,##0' }, E3: { numFmt: '#,##0' },\n B4: { numFmt: '#,##0' }, C4: { numFmt: '#,##0' }, D4: { numFmt: '#,##0' }, E4: { numFmt: '#,##0' },\n B5: { numFmt: '#,##0' }, C5: { numFmt: '#,##0' }, D5: { numFmt: '#,##0' }, E5: { numFmt: '#,##0' },\n A6: { bold: true }, B6: { bold: true, numFmt: '#,##0' }, C6: { bold: true, numFmt: '#,##0' }, D6: { bold: true, numFmt: '#,##0' }, E6: { bold: true, numFmt: '#,##0' },\n }}\n/>\n```\n\nOne entry per address gets long by row six, which is what the document\nbelow is for. [Formatting](./formatting.md) covers number formats,\nborders, merged headers and the rules that colour a cell by its value.\n\n## The document\n\nEverything the shell shows beyond the cells lives in a sheet document:\nper sheet its formats, column widths and row heights, hidden lines,\nfrozen panes, comments, protection, page setup, charts, validation and\nconditional formatting rules. `createSheetDocument` builds one around a\nworkbook, `doc.get(name)` is a sheet's part of it, and the `document`\nprop hands the whole thing to the component. `formats.set` formats a\nrectangle at a time, which is how a header row or a money column is one\nline:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const wb = createWorkbook([{ name: 'Budget', cells: budget }])\n const doc = createSheetDocument({ workbook: wb })\n const sheet = doc.get('Budget')\n sheet.formats.set([[0, 0, 0, 4]], { bold: true, fill: '#e2e8f0', color: '#0f172a' }, at)\n sheet.formats.set([[1, 1, 5, 4]], { numFmt: '#,##0' }, at)\n sheet.formats.set([[5, 0, 5, 4]], { bold: true, border: { top: { width: 1 } } }, at)\n sheet.widths.A = 130\n sheet.freeze = { rows: 1, cols: 1 }\n</script>\n\n<SvSheet document={doc} rows={12} columns={6} />\n```\n\nThe header stays put when the sheet scrolls, because `freeze` is part of\nthe document too. So is what the user does next: a format from the\nribbon, a widened column, a new comment all land in `doc`, and\n`doc.getState()` is the whole thing as plain JSON. `onChange` fires once\nper tick with the reasons since the last call, so an autosave is one\nhandler:\n\n```svelte\n<SvSheet document={doc} onChange={(reasons) => save(doc.getState())} />\n```\n\n[Files](./files.md) takes it from there: what the state holds, restoring\nit, `.xlsx` in and out, and the three things a network makes you think\nabout.\n\n## Rows into a sheet\n\nA grid's rows, or an API's, become a sheet with `sheetCellsFromRows`: a\nheader row of labels, a row per record with each value as the text the\nengine reads, and with `totals` a `=SUM` under every column that held a\nnumber. It is how a dataset turns into something the user can add a\nformula to.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const orders = [\n { item: 'Desk', qty: 4, price: 320, shipped: true },\n { item: 'Chair', qty: 12, price: 85, shipped: false },\n { item: 'Lamp', qty: 6, price: 40, shipped: true },\n ]\n const cells = sheetCellsFromRows(orders, [{ field: 'item', label: 'Item' }, 'qty', { field: 'price', label: 'Unit price' }, 'shipped'], { totals: true })\n</script>\n\n<SvSheet data={[{ name: 'Orders', cells }]} rows={10} columns={6} />\n```\n\nA boolean lands as `TRUE` or `FALSE`, a `Date` as `yyyy-mm-dd`, a null as\nnothing.\n\n## The ribbon is Excel's\n\nFile, Home, Insert, Page Layout, Formulas, Data, Review and View, with\nExcel's buttons under them and Excel's shortcuts beside them: `Ctrl+B` and the\nBold button call the same function, and the button lights up because the\ncell is bold, not because it was clicked. Every action reaches an\n`onAction` handler first, so an app can take one over (its own file\npicker on Open, its own dialog on Goal Seek) by returning `true`.\n\n<div data-docs-demo=\"27-spreadsheet-ribbon\" data-height=\"560\"></div>\n\nIn a narrow frame, this page's examples included, a ribbon group that\ndoes not fit folds into one button named for the group, so Home > Cells\n> Format is a click deeper than on a full-width sheet. The `look` prop\npaints the shell with the app's `--sg-*` tokens (`'theme'`, the default)\nor pins Excel's own palette and geometry (`'excel'`). `showRibbon`, `showFormulaBar`, `showTabs` and\n`showStatusBar` hide any part of the chrome, and `height=\"100%\"` fills a\nflex parent.\n\n## See also\n\n- [The spreadsheet shell](../cells/spreadsheet-shell.md) - every prop, every dialog, the file formats and the limits, on one page.\n- [Formulas](./formulas.md) - references, names, LET and LAMBDA, your own functions, auditing.\n- [Formatting](./formatting.md) - number formats, borders, merged headers, conditional formatting.\n- [Files](./files.md) - save, restore, autosave, `.xlsx`, `.ods`, `.xls` and `.csv`.\n- [Excel keyboard shortcuts](../cells/keyboard-shortcuts.md) - the full list, and how to turn them on for a plain grid.\n- [`<sv-sheet>`](../web-components/sv-sheet.md) - the same component as a custom element for React, Vue, Angular or plain HTML.\n"
|
|
5199
|
+
"markdown": "\n# Spreadsheet: getting started\n\nThe one component a spreadsheet needs, how it takes data, how a formula\nreaches another sheet, and the document underneath that a save and a\nreload go through. The other spreadsheet tutorials assume this one, and\n[The spreadsheet shell](../cells/spreadsheet-shell.md) is the reference\nbehind all of them: every prop, every dialog, the file formats, the\nlimits.\n\n`SvSheet` is a spreadsheet, not a mode of the grid: it owns a workbook of\nformulas, address-keyed formats, comments and rules, and draws it with\n`<SvGrid>` underneath. It ships in `@svgrid/enterprise` on top of\n`@svgrid/grid`; `jszip` is a peer you add only when a file is saved or\nopened as `.xlsx` or `.ods`.\n\n```bash\nnpm install @svgrid/grid @svgrid/enterprise\n```\n\n<!-- tutorial:install-spreadsheet -->\n<figure class=\"docs-tutorial docs-tutorial-player\" id=\"tutorial-install-spreadsheet\" data-docs-tutorial=\"install-spreadsheet\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/install-spreadsheet.mp4\" poster=\"/tutorials/install-spreadsheet.poster.webp\" width=\"960\" height=\"540\" controls playsinline preload=\"none\" aria-label=\"Install the SvGrid spreadsheet, 53 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/install-spreadsheet.vtt\">Your browser does not play embedded video. <a href=\"/tutorials/install-spreadsheet.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Install the SvGrid spreadsheet</strong> (53 s, with narration).</figcaption>\n</figure>\n<!-- /tutorial:install-spreadsheet -->\n\nThe examples on this page share one setup: a small budget as raw text,\nand the lookup that formats a rectangle of cells by row and column\nindex.\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvSheet, createWorkbook, createSheetDocument, sheetCellsFromRows } from '@svgrid/enterprise'\n\n // Every cell is the text the user would have typed: a number as its\n // digits, a formula starting with `=`, an empty string for nothing.\n const budget: string[][] = [\n ['Line', 'Jan', 'Feb', 'Mar', 'Q1'],\n ['Rent', '2400', '2400', '2400', '=SUM(B2:D2)'],\n ['Payroll', '18500', '18500', '19200', '=SUM(B3:D3)'],\n ['Marketing', '3100', '4800', '2600', '=SUM(B4:D4)'],\n ['Travel', '900', '1350', '400', '=SUM(B5:D5)'],\n ['Total', '=SUM(B2:B5)', '=SUM(C2:C5)', '=SUM(D2:D5)', '=SUM(E2:E5)'],\n ]\n\n // formats.set takes rectangles as [minRow, minCol, maxRow, maxCol],\n // 0-based, and this lookup turns an index into the key the store uses.\n const at = { rowIdAt: (i: number) => `r${i}`, columnIdAt: (i: number) => String.fromCharCode(65 + i) }\n</script>\n```\n\n## The minimum\n\nNo props at all is a working spreadsheet: fifty rows, twelve columns, the\nribbon, the formula bar, the sheet tabs and the status bar. Click a cell\nand type a number, or `=SUM(A1:A5)`, and the Excel keyboard is already\nlive: `Ctrl+Arrow` to the edge of a block, `Ctrl+D` and `Ctrl+R` to fill\ndown and right, `Ctrl+;` for today, `Alt+=` for AutoSum, `Ctrl+Z` for a\nwhole action rather than one cell.\n\n```svelte {runnable}\n<SvSheet />\n```\n\n<div data-docs-demo=\"207-blank-sheet\" data-height=\"520\"></div>\n\n## Seed it with data\n\n`data` takes the sheets to start with, each a name and its cells as raw\ntext, row by row. A ragged row is fine; a missing cell reads as empty.\nNumbers are digits, dates are `yyyy-mm-dd`, and a formula is the text\nthat starts with `=`, exactly as the user would have typed it - the\nsheet works the values out.\n\n```svelte {runnable}\n<SvSheet data={[{ name: 'Budget', cells: budget }]} rows={12} columns={6} />\n```\n\nChange any month and the row's Q1 and the Total row recompute. What the\nsheet keeps is the text, so `getState()` (below) hands back `=SUM(B2:D2)`,\nnot `7200`.\n\n## A formula that reads another sheet\n\nA workbook with more than one sheet is `createWorkbook`, which takes the\nsame list `data` does and gives you the object the formulas run through.\nA reference qualifies the sheet the way Excel writes it: `Prices!B2`, or\n`'Price list'!B2` when the name has a space. `$` pins a row or a column\nwhen the formula is filled down.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const wb = createWorkbook([\n { name: 'Orders', cells: [\n ['Item', 'Qty', 'Unit', 'Amount'],\n ['Desk', '4', '=XLOOKUP(A2,Prices!$A$2:$A$4,Prices!$B$2:$B$4)', '=B2*C2'],\n ['Chair', '12', '=XLOOKUP(A3,Prices!$A$2:$A$4,Prices!$B$2:$B$4)', '=B3*C3'],\n ['Lamp', '6', '=XLOOKUP(A4,Prices!$A$2:$A$4,Prices!$B$2:$B$4)', '=B4*C4'],\n ['Total', '', '', '=SUM(D2:D4)'],\n ] },\n { name: 'Prices', cells: [\n ['Item', 'Unit price'],\n ['Desk', '320'],\n ['Chair', '85'],\n ['Lamp', '40'],\n ] },\n ])\n</script>\n\n<SvSheet workbook={wb} rows={10} columns={6} />\n```\n\nSwitch to the Prices tab, change a price, and switch back: the amounts\nfollowed. `Ctrl+PageUp` and `Ctrl+PageDown` move between sheets from the\nkeyboard, and `Shift+F11` adds one. [Workbooks](../cells/workbooks.md) is\nthe page on sheets that read each other; [Formulas](./formulas.md) is the\npage on what a formula can say.\n\n## A look it opens with\n\nA sheet a person types into can be formatted by hand from the ribbon. A\nsheet you ship should open formatted, or it is a grid of raw numbers\nuntil someone tidies it. `formats` takes cell formats keyed by address\n(`'Orders!F2'` reaches another sheet), and `columnWidths` widths keyed by\nletter:\n\n```svelte {runnable}\n<SvSheet\n data={[{ name: 'Budget', cells: budget }]}\n rows={12}\n columns={6}\n columnWidths={{ A: 130 }}\n formats={{\n A1: { bold: true, fill: '#e2e8f0' }, B1: { bold: true, fill: '#e2e8f0' }, C1: { bold: true, fill: '#e2e8f0' }, D1: { bold: true, fill: '#e2e8f0' }, E1: { bold: true, fill: '#e2e8f0' },\n B2: { numFmt: '#,##0' }, C2: { numFmt: '#,##0' }, D2: { numFmt: '#,##0' }, E2: { numFmt: '#,##0' },\n B3: { numFmt: '#,##0' }, C3: { numFmt: '#,##0' }, D3: { numFmt: '#,##0' }, E3: { numFmt: '#,##0' },\n B4: { numFmt: '#,##0' }, C4: { numFmt: '#,##0' }, D4: { numFmt: '#,##0' }, E4: { numFmt: '#,##0' },\n B5: { numFmt: '#,##0' }, C5: { numFmt: '#,##0' }, D5: { numFmt: '#,##0' }, E5: { numFmt: '#,##0' },\n A6: { bold: true }, B6: { bold: true, numFmt: '#,##0' }, C6: { bold: true, numFmt: '#,##0' }, D6: { bold: true, numFmt: '#,##0' }, E6: { bold: true, numFmt: '#,##0' },\n }}\n/>\n```\n\nOne entry per address gets long by row six, which is what the document\nbelow is for. [Formatting](./formatting.md) covers number formats,\nborders, merged headers and the rules that colour a cell by its value.\n\n## The document\n\nEverything the shell shows beyond the cells lives in a sheet document:\nper sheet its formats, column widths and row heights, hidden lines,\nfrozen panes, comments, protection, page setup, charts, validation and\nconditional formatting rules. `createSheetDocument` builds one around a\nworkbook, `doc.get(name)` is a sheet's part of it, and the `document`\nprop hands the whole thing to the component. `formats.set` formats a\nrectangle at a time, which is how a header row or a money column is one\nline:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const wb = createWorkbook([{ name: 'Budget', cells: budget }])\n const doc = createSheetDocument({ workbook: wb })\n const sheet = doc.get('Budget')\n sheet.formats.set([[0, 0, 0, 4]], { bold: true, fill: '#e2e8f0', color: '#0f172a' }, at)\n sheet.formats.set([[1, 1, 5, 4]], { numFmt: '#,##0' }, at)\n sheet.formats.set([[5, 0, 5, 4]], { bold: true, border: { top: { width: 1 } } }, at)\n sheet.widths.A = 130\n sheet.freeze = { rows: 1, cols: 1 }\n</script>\n\n<SvSheet document={doc} rows={12} columns={6} />\n```\n\nThe header stays put when the sheet scrolls, because `freeze` is part of\nthe document too. So is what the user does next: a format from the\nribbon, a widened column, a new comment all land in `doc`, and\n`doc.getState()` is the whole thing as plain JSON. `onChange` fires once\nper tick with the reasons since the last call, so an autosave is one\nhandler:\n\n```svelte\n<SvSheet document={doc} onChange={(reasons) => save(doc.getState())} />\n```\n\n[Files](./files.md) takes it from there: what the state holds, restoring\nit, `.xlsx` in and out, and the three things a network makes you think\nabout.\n\n## Rows into a sheet\n\nA grid's rows, or an API's, become a sheet with `sheetCellsFromRows`: a\nheader row of labels, a row per record with each value as the text the\nengine reads, and with `totals` a `=SUM` under every column that held a\nnumber. It is how a dataset turns into something the user can add a\nformula to.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const orders = [\n { item: 'Desk', qty: 4, price: 320, shipped: true },\n { item: 'Chair', qty: 12, price: 85, shipped: false },\n { item: 'Lamp', qty: 6, price: 40, shipped: true },\n ]\n const cells = sheetCellsFromRows(orders, [{ field: 'item', label: 'Item' }, 'qty', { field: 'price', label: 'Unit price' }, 'shipped'], { totals: true })\n</script>\n\n<SvSheet data={[{ name: 'Orders', cells }]} rows={10} columns={6} />\n```\n\nA boolean lands as `TRUE` or `FALSE`, a `Date` as `yyyy-mm-dd`, a null as\nnothing.\n\n## The ribbon is Excel's\n\nFile, Home, Insert, Page Layout, Formulas, Data, Review and View, with\nExcel's buttons under them and Excel's shortcuts beside them: `Ctrl+B` and the\nBold button call the same function, and the button lights up because the\ncell is bold, not because it was clicked. Every action reaches an\n`onAction` handler first, so an app can take one over (its own file\npicker on Open, its own dialog on Goal Seek) by returning `true`.\n\n<div data-docs-demo=\"27-spreadsheet-ribbon\" data-height=\"560\"></div>\n\nIn a narrow frame, this page's examples included, a ribbon group that\ndoes not fit folds into one button named for the group, so Home > Cells\n> Format is a click deeper than on a full-width sheet. The `look` prop\npaints the shell with the app's `--sg-*` tokens (`'theme'`, the default)\nor pins Excel's own palette and geometry (`'excel'`). `showRibbon`, `showFormulaBar`, `showTabs` and\n`showStatusBar` hide any part of the chrome, and `height=\"100%\"` fills a\nflex parent.\n\n## See also\n\n- [The spreadsheet shell](../cells/spreadsheet-shell.md) - every prop, every dialog, the file formats and the limits, on one page.\n- [Formulas](./formulas.md) - references, names, LET and LAMBDA, your own functions, auditing.\n- [Formatting](./formatting.md) - number formats, borders, merged headers, conditional formatting.\n- [Files](./files.md) - save, restore, autosave, `.xlsx`, `.ods`, `.xls` and `.csv`.\n- [Excel keyboard shortcuts](../cells/keyboard-shortcuts.md) - the full list, and how to turn them on for a plain grid.\n- [`<sv-sheet>`](../web-components/sv-sheet.md) - the same component as a custom element for React, Vue, Angular or plain HTML.\n"
|
|
5169
5200
|
},
|
|
5170
5201
|
{
|
|
5171
5202
|
"slug": "help/sheet/validation",
|
|
@@ -5536,7 +5567,7 @@ export const docs = [
|
|
|
5536
5567
|
"path": "docs/help/ui-components/sv-dock-manager.md",
|
|
5537
5568
|
"title": "SvDockManager",
|
|
5538
5569
|
"section": "UI components",
|
|
5539
|
-
"markdown": "# SvDockManager\n\nA full docking manager built on top of [SvDockLayout](sv-dock-layout.md). It\nkeeps everything the tiled layout does - nested resizable splits, tabbed leaves,\ndrag-to-dock - and adds the three features a real IDE workspace needs:\n\n- **Floating / pop-out windows** - drag a tab into open space (or hit the float\n button) to pop it into a movable, resizable window; drag the window back to redock.\n- **Tab reordering** - drag a tab along its own strip to reorder it.\n- **Pinning / auto-hide** - collapse a panel to an edge strip; hovering the edge\n reveals a fly-out, and \"pin\" docks it back.\n\nThe whole workspace - tiled `main` area, `floating` windows, and `autoHide`\nedges - is one serializable `DockManagerState` you `bind`. Every gesture is a\npure transform (from `dock-manager-model`), so a full multi-window workspace\nsaves to JSON and restores exactly, and the logic is unit-tested without a browser.\n\nRelated: [SvDockLayout](sv-dock-layout.md) · [SvSplitter](sv-splitter.md) · [Layout & composite overview](layout.md)\n\n## Installation\n\nAdd it with the CLI - this drops a ready-to-edit `SvDockManager` starter into your app:\n\n<div data-docs-add=\"add dock-manager\"></div>\n\nPrefer to see it first? `npx @svgrid/ui try dock-manager` opens it in a throwaway sandbox - no project needed.\n\nOr install the package and import it directly. `SvDockManager` ships free in\n`@svgrid/grid` (dependency-free):\n\n<div data-docs-install=\"@svgrid/grid\"></div>\n\n```ts\nimport { SvDockManager } from '@svgrid/grid'\n```\n\n## Example\n\nBuild the initial workspace from the base `dockGroup` / `dockTabs` / `dockPane`\nhelpers, start with empty `floating` / `autoHide`, and provide a `pane` snippet.\n\n<div data-docs-demo=\"362-dock-manager\" data-height=\"560\" data-code></div>\n\n```svelte\n<script lang=\"ts\">\n import { SvDockManager, dockGroup, dockTabs, dockPane, type DockManagerState } from '@svgrid/grid'\n\n let workspace = $state<DockManagerState>({\n main: dockGroup('row', [\n dockTabs([dockPane('explorer', 'Explorer')]),\n dockGroup('column', [\n dockTabs([dockPane('editor', 'index.ts'), dockPane('readme', 'README.md')]),\n dockTabs([dockPane('terminal', 'Terminal')]),\n ], [0.7, 0.3]),\n ], [0.22, 0.78]),\n floating: [],\n autoHide: [],\n })\n</script>\n\n<div style=\"height: 520px\">\n <SvDockManager bind:workspace>\n {#snippet pane(p)}\n {#if p.id === 'explorer'}<FileTree />{:else}<div>{p.title}</div>{/if}\n {/snippet}\n </SvDockManager>\n</div>\n```\n\n## The workspace model\n\n`DockManagerState` has three surfaces, each holding the same `DockNode` /\n`DockTabs` pieces as the base layout:\n\n| Field | Type | Meaning |\n| ----- | ---- | ------- |\n| `main` | `DockNode \\| null` | The tiled dock area (a group / tabs tree). `null` when everything has been floated or hidden - a drop placeholder appears. |\n| `floating` | `FloatWindow[]` | Pop-out windows: `{ id, leaf, x, y, width, height, z }`. Each is a single tab-leaf. |\n| `autoHide` | `AutoHideEntry[]` | Collapsed panels: `{ id, side, leaf, size }` on an edge. |\n\nBecause it is plain data, save and restore it:\n\n```ts\nlocalStorage.setItem('ws', JSON.stringify(workspace))\nworkspace = JSON.parse(localStorage.getItem('ws')!) // tiled + floating + hidden, all restored\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n| ---------------- | ---------------------------------- | ------- | -------------------------------------------- |\n| `workspace` | `DockManagerState` | - | The whole workspace. **Bindable.** |\n| `pane` | `Snippet<[DockPane]>` | - | Renders each pane's content, keyed by `DockPane`. |\n| `onChange` | `(workspace: DockManagerState) => void` | - | Notified after any change. |\n| `onEvent` | `(event: DockEvent) => void` | - | Granular lifecycle events (see below). |\n| `onReady` | `(api: DockManagerApi) => void` | - | Receive the imperative API once, on mount. |\n| `minSize` | `number` | `80` | Minimum pane size in px along a split. |\n| `reorderEnabled` | `boolean` | `true` | Allow dragging tabs to reorder within a strip. |\n| `headerPosition` | `'top' \\| 'bottom' \\| 'left' \\| 'right'` | `'top'` | Which side of each panel its tab strip sits on (left/right render vertical tabs). |\n| `keepAlive` | `boolean` | `false` | Keep inactive tabs mounted (hidden) so their DOM state (scroll, form input) persists across switches. |\n| `hideSingleTab` | `boolean` | `false` | Hide the tab button on a single-pane leaf (no redundant title bar; the panel actions still show). |\n| `allowPopout` | `boolean` | `false` | Offer the pop-out-to-a-new-window panel action. Off by default; the `popout()` API still works. |\n| `locked` | `boolean` | `false` | Locked / split mode: a fixed set of resizable panes. Tabs can't be dragged (no reorder / float / dock) and the panel action buttons are hidden - only splitter resize + tab switching remain. |\n\n### Events\n\n`onEvent` fires a typed `DockEvent` for each user action, alongside the\nwhole-state `onChange`:\n\n```ts\ntype DockEvent =\n | { type: 'activePane'; paneId: string; tabsId: string }\n | { type: 'close'; paneId: string }\n | { type: 'float'; paneId: string }\n | { type: 'popout'; paneId: string }\n | { type: 'autoHide'; paneId: string; side: 'left' | 'right' | 'top' | 'bottom' }\n | { type: 'pin'; paneId: string }\n | { type: 'maximize'; tabsId: string; maximized: boolean }\n | { type: 'window'; windowId: string; action: 'minimize' | 'maximize' | 'close' | 'autoHide' }\n | { type: 'focus'; paneId: string }\n```\n\nThe **focused** panel (last engaged) is highlighted with an accent header; drive\nit from `api.focus(paneId)` and listen via the `focus` event.\n\n## Gestures\n\n- **Float** - drag a tab off into open space, or click the tab's float button\n (`□`). Drag a floating window by its title bar; drag the corner to resize. Each\n window's title bar carries **auto-hide** (`▽`), **minimize** (collapse to the\n bar), **maximize / restore** (double-click the bar too) and **close** controls.\n- **Redock** - drag a floating window's title bar over the tiled area and drop\n on an edge (split) or centre (tab).\n- **Reorder** - drag a tab along its own strip; an insertion line marks where it\n will land.\n- **Stack header controls** - each tiled panel's strip carries **maximize**\n (fills the docked area in place; restore returns), **auto-hide**, and **float**\n buttons on the right (plus **pop-out** when you opt in with `allowPopout`).\n- **Pop-out to a native window** - set `allowPopout` to add a pop-out button\n (`⤡`) that opens the panel in a real browser window (styles + theme carried\n across); closing that window docks the panel back in. Falls back to a floating\n window if the browser blocks pop-ups. Off by default - most apps only need the\n in-app floating windows - but the `popout()` API works regardless of the prop.\n- **Auto-hide** - click a tab's auto-hide button (`▽`) to collapse its leaf to\n the **nearest** edge (left / right / top / bottom, inferred from where it\n sits), or drag a tab to any of the four manager borders to auto-hide it there.\n Each collapsed panel becomes one edge tab; click it to **slide** the panel out\n (animated, capped so it never covers the whole workspace, and **resizable** by\n its inner edge; click away or press Escape to dismiss), then click **pin**\n (`📌`) to dock it back on that side at a sensible width.\n- **Close** - the tab's `×`, wherever the pane lives.\n\n### Drop indicators\n\nWhile you drag, the target shows live feedback so the outcome is never a\nsurprise:\n\n- a **5-direction dock guide** (a plus of targets) appears on the pane under the\n pointer. **Drop on a guide chip to dock** there (with a translucent preview of\n the region the panel will take); **release off the guide and the tab floats**\n into a new window. Floating windows show only the centre chip (they accept\n tabs, not splits).\n- a **reorder insertion line** appears when you drag a tab along its own strip;\n- an **edge bar** lights up along a manager border when a drop will auto-hide to\n that side.\n\nDragging a floating window's title bar over the tiled area follows the same rule:\ndrop on a guide chip to redock, or release off it to leave the window floating.\n\n## Programmatic control\n\nEvery surface transform is exported (all immutable, all returning a new\n`DockManagerState`) for toolbars, \"reset workspace\" buttons, or server-driven\nlayouts:\n\n```ts\nimport {\n floatPane, dockPaneOnto, dockWindowOnto, reorderTab,\n autoHideLeaf, pinAutoHidden, dockManagerClosePane, allManagerPaneIds,\n} from '@svgrid/grid'\n\nlet seq = 0\nconst genId = () => `w${seq++}`\n\nworkspace = floatPane(workspace, 'terminal', { x: 60, y: 60, width: 360, height: 240 }, genId)\nworkspace = autoHideLeaf(workspace, leafId, 'left')\nworkspace = dockManagerClosePane(workspace, 'problems')\nconst ids = allManagerPaneIds(workspace)\n```\n\n## Imperative API\n\nPass `onReady` to receive a handle for driving the manager from code (toolbars,\nmenus, keyboard shortcuts):\n\n```svelte\n<script lang=\"ts\">\n import { SvDockManager, type DockManagerApi } from '@svgrid/grid'\n let api: DockManagerApi\n</script>\n\n<SvDockManager bind:workspace onReady={(a) => (api = a)}>...</SvDockManager>\n\n<!-- later -->\napi.float('terminal') // pop into a floating window\napi.popout('editor') // pop out to a native browser window\napi.autoHide('explorer') // collapse to its nearest edge\napi.maximize(leafId) // maximize a tiled leaf in place (toggle)\napi.close('problems') // close a pane anywhere\napi.getState() / api.setState(s) // snapshot / restore the whole workspace\n```\n\n## Touch\n\nEvery gesture is built on pointer events with `touch-action: none` on the tabs,\nsplitters, window bars and resize handles, so dragging to dock, reorder, resize,\nmove windows and pull out fly-outs all work on touch devices without the browser\nhijacking the drag to scroll.\n\n## Accessibility\n\n`SvDockManager` inherits the [SvDockLayout](sv-dock-layout.md#accessibility) tab /\nsplitter / panel semantics for every docked area, and adds window-level roles on\ntop:\n\n- Tab strips use `role=\"tablist\"` / `role=\"tab\"` with `aria-selected`; the active\n tab is keyboard-focusable and `Enter` / `Space` activate it.\n- Splitters expose `role=\"separator\"` with `aria-orientation`; content areas are\n `role=\"tabpanel\"`.\n- Floating windows are labelled dialog-style regions with focusable title bars, so\n keyboard users can reach and move them; close / pin / auto-hide controls are real\n buttons with accessible labels.\n\n## Headless\n\nLike [SvDockLayout](sv-dock-layout.md), the manager is headless in the\ndata-model form: `DockManagerState` **is** the framework-free core, and every\nsurface transform is a pure, immutable function (`floatPane`, `dockPaneOnto`,\n`reorderTab`, `autoHideLeaf`, `pinAutoHidden`, `dockManagerClosePane`,\n`allManagerPaneIds`) unit-tested without a browser. You can compute, migrate, or\nrestore an entire multi-window workspace on the server or in a worker and hand\nthe finished state to the component to render. See\n[Headless editors](headless-editors.md#data-model-cores-dock).\n\n## Relationship to SvDockLayout\n\n`SvDockManager` renders `SvDockLayout` in \"manager mode\": the tiled area and each\nfloating window are `SvDockLayout` instances that delegate their gestures up to\nthe manager, so a single drag can flow across the main area, floating windows and\nedge strips. Reach for `SvDockLayout` directly when you only need a tiled,\nin-place docking layout; reach for `SvDockManager` when you need floating\nwindows, reordering, or auto-hide.\n\n## More examples\n\n### Docking: API & events\n\nSvDockManager imperative API (onReady) + event stream (onEvent): a toolbar floats / pops out / maximizes / auto-hides / focuses panes from code, with every action logged live.\n\n<div data-docs-demo=\"364-dock-api\" data-height=\"520\"></div>\n\n### Docking: pop out to a window\n\nSvDockManager allowPopout (off by default): pop a panel out to a real OS window for a second monitor - a live monitoring console where Live log / Metrics keep streaming in the popped-out window, and closing it docks the panel back. Falls back to an in-app floating window when pop-ups are blocked.\n\n<div data-docs-demo=\"367-dock-popout\" data-height=\"520\"></div>\n\n## See also\n\n- [SvDockLayout](sv-dock-layout.md) - the tiled docking layout underneath.\n- [SvSplitter](sv-splitter.md) - a single two-pane resizable split.\n"
|
|
5570
|
+
"markdown": "# SvDockManager\n\nA full docking manager built on top of [SvDockLayout](sv-dock-layout.md). It\nkeeps everything the tiled layout does - nested resizable splits, tabbed leaves,\ndrag-to-dock - and adds the three features a real IDE workspace needs:\n\n- **Floating / pop-out windows** - drag a tab into open space (or hit the float\n button) to pop it into a movable, resizable window; drag the window back to redock.\n- **Tab reordering** - drag a tab along its own strip to reorder it.\n- **Pinning / auto-hide** - collapse a panel to an edge strip; hovering the edge\n reveals a fly-out, and \"pin\" docks it back.\n\nThe whole workspace - tiled `main` area, `floating` windows, and `autoHide`\nedges - is one serializable `DockManagerState` you `bind`. Every gesture is a\npure transform (from `dock-manager-model`), so a full multi-window workspace\nsaves to JSON and restores exactly, and the logic is unit-tested without a browser.\n\nRelated: [SvDockLayout](sv-dock-layout.md) · [SvSplitter](sv-splitter.md) · [Layout & composite overview](layout.md)\n\n## Installation\n\nAdd it with the CLI - this drops a ready-to-edit `SvDockManager` starter into your app:\n\n<div data-docs-add=\"add dock-manager\"></div>\n\nPrefer to see it first? `npx @svgrid/ui try dock-manager` opens it in a throwaway sandbox - no project needed.\n\nOr install the package and import it directly. `SvDockManager` ships free in\n`@svgrid/grid` (dependency-free):\n\n<div data-docs-install=\"@svgrid/grid\"></div>\n\n```ts\nimport { SvDockManager } from '@svgrid/grid'\n```\n\n## Example\n\nBuild the initial workspace from the base `dockGroup` / `dockTabs` / `dockPane`\nhelpers, start with empty `floating` / `autoHide`, and provide a `pane` snippet.\n\n<div data-docs-demo=\"362-dock-manager\" data-height=\"560\" data-code></div>\n\n```svelte\n<script lang=\"ts\">\n import { SvDockManager, dockGroup, dockTabs, dockPane, type DockManagerState } from '@svgrid/grid'\n\n let workspace = $state<DockManagerState>({\n main: dockGroup('row', [\n dockTabs([dockPane('explorer', 'Explorer')]),\n dockGroup('column', [\n dockTabs([dockPane('editor', 'index.ts'), dockPane('readme', 'README.md')]),\n dockTabs([dockPane('terminal', 'Terminal')]),\n ], [0.7, 0.3]),\n ], [0.22, 0.78]),\n floating: [],\n autoHide: [],\n })\n</script>\n\n<div style=\"height: 520px\">\n <SvDockManager bind:workspace>\n {#snippet pane(p)}\n {#if p.id === 'explorer'}<FileTree />{:else}<div>{p.title}</div>{/if}\n {/snippet}\n </SvDockManager>\n</div>\n```\n\n## The workspace model\n\n`DockManagerState` has three surfaces, each holding the same `DockNode` /\n`DockTabs` pieces as the base layout:\n\n| Field | Type | Meaning |\n| ----- | ---- | ------- |\n| `main` | `DockNode \\| null` | The tiled dock area (a group / tabs tree). `null` when everything has been floated or hidden - a drop placeholder appears. |\n| `floating` | `FloatWindow[]` | Pop-out windows: `{ id, leaf, x, y, width, height, z }`. Each is a single tab-leaf. |\n| `autoHide` | `AutoHideEntry[]` | Collapsed panels: `{ id, side, leaf, size }` on an edge. |\n\nBecause it is plain data, save and restore it:\n\n```ts\nlocalStorage.setItem('ws', JSON.stringify(workspace))\nworkspace = JSON.parse(localStorage.getItem('ws')!) // tiled + floating + hidden, all restored\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n| ---------------- | ---------------------------------- | ------- | -------------------------------------------- |\n| `workspace` | `DockManagerState` | - | The whole workspace. **Bindable.** |\n| `pane` | `Snippet<[DockPane]>` | - | Renders each pane's content, keyed by `DockPane`. |\n| `onChange` | `(workspace: DockManagerState) => void` | - | Notified after any change. |\n| `onEvent` | `(event: DockEvent) => void` | - | Granular lifecycle events (see below). |\n| `onReady` | `(api: DockManagerApi) => void` | - | Receive the imperative API once, on mount. |\n| `minSize` | `number` | `80` | Minimum pane size in px along a split. |\n| `reorderEnabled` | `boolean` | `true` | Allow dragging tabs to reorder within a strip. |\n| `headerPosition` | `'top' \\| 'bottom' \\| 'left' \\| 'right'` | `'top'` | Which side of each panel its tab strip sits on (left/right render vertical tabs). |\n| `keepAlive` | `boolean` | `false` | Keep inactive tabs mounted (hidden) so their DOM state (scroll, form input) persists across switches. |\n| `hideSingleTab` | `boolean` | `false` | Hide the tab button on a single-pane leaf (no redundant title bar; the panel actions still show). |\n| `allowPopout` | `boolean` | `false` | Offer the pop-out-to-a-new-window panel action. Off by default; the `popout()` API still works. |\n| `locked` | `boolean` | `false` | Locked / split mode: a fixed set of resizable panes. Tabs can't be dragged (no reorder / float / dock) and the panel action buttons are hidden - only splitter resize + tab switching remain. |\n\n### Events\n\n`onEvent` fires a typed `DockEvent` for each user action, alongside the\nwhole-state `onChange`:\n\n```ts\ntype DockEvent =\n | { type: 'activePane'; paneId: string; tabsId: string }\n | { type: 'close'; paneId: string }\n | { type: 'float'; paneId: string }\n | { type: 'popout'; paneId: string }\n | { type: 'autoHide'; paneId: string; side: 'left' | 'right' | 'top' | 'bottom' }\n | { type: 'pin'; paneId: string }\n | { type: 'maximize'; tabsId: string; maximized: boolean }\n | { type: 'window'; windowId: string; action: 'minimize' | 'maximize' | 'close' | 'autoHide' }\n | { type: 'focus'; paneId: string }\n```\n\nThe **focused** panel (last engaged) is highlighted with an accent header; drive\nit from `api.focus(paneId)` and listen via the `focus` event.\n\n## Gestures\n\n- **Float** - drag a tab off into open space, or click the tab's float button\n (`□`). Drag a floating window by its title bar; drag the corner to resize. Each\n window's title bar carries **auto-hide** (`▽`), **minimize** (collapse to the\n bar), **maximize / restore** (double-click the bar too) and **close** controls.\n- **Redock** - drag a floating window's title bar over the tiled area and drop\n on an edge (split) or centre (tab).\n- **Reorder** - drag a tab along its own strip; an insertion line marks where it\n will land.\n- **Stack header controls** - each tiled panel's strip carries **maximize**\n (fills the docked area in place; restore returns), **auto-hide**, and **float**\n buttons on the right (plus **pop-out** when you opt in with `allowPopout`).\n- **Pop-out to a native window** - set `allowPopout` to add a pop-out button\n (`⤡`) that opens the panel in a real browser window (styles + theme carried\n across); closing that window docks the panel back in. Falls back to a floating\n window if the browser blocks pop-ups. Off by default - most apps only need the\n in-app floating windows - but the `popout()` API works regardless of the prop.\n- **Auto-hide** - click a tab's auto-hide button (`▽`) to collapse its leaf to\n the **nearest** edge (left / right / top / bottom, inferred from where it\n sits), or drag a tab to any of the four manager borders to auto-hide it there.\n Each collapsed panel becomes one edge tab; click it to **slide** the panel out\n (animated, capped so it never covers the whole workspace, and **resizable** by\n its inner edge; click away or press Escape to dismiss), then click **pin**\n (`📌`) to dock it back on that side at a sensible width.\n- **Close** - the tab's `×`, wherever the pane lives.\n\n### Drop indicators\n\nWhile you drag, the target shows live feedback so the outcome is never a\nsurprise:\n\n- a **5-direction dock guide** (a plus of targets) appears on the pane under the\n pointer. **Drop on a guide chip to dock** there (with a translucent preview of\n the region the panel will take); **release off the guide and the tab floats**\n into a new window. Floating windows show only the centre chip (they accept\n tabs, not splits).\n- a **reorder insertion line** appears when you drag a tab along its own strip;\n- an **edge bar** lights up along a manager border when a drop will auto-hide to\n that side.\n\nDragging a floating window's title bar over the tiled area follows the same rule:\ndrop on a guide chip to redock, or release off it to leave the window floating.\n\n## Programmatic control\n\nEvery surface transform is exported (all immutable, all returning a new\n`DockManagerState`) for toolbars, \"reset workspace\" buttons, or server-driven\nlayouts:\n\n```ts\nimport {\n floatPane, dockPaneOnto, dockWindowOnto, reorderTab,\n autoHideLeaf, pinAutoHidden, dockManagerClosePane, allManagerPaneIds,\n} from '@svgrid/grid'\n\nlet seq = 0\nconst genId = () => `w${seq++}`\n\nworkspace = floatPane(workspace, 'terminal', { x: 60, y: 60, width: 360, height: 240 }, genId)\nworkspace = autoHideLeaf(workspace, leafId, 'left')\nworkspace = dockManagerClosePane(workspace, 'problems')\nconst ids = allManagerPaneIds(workspace)\n```\n\n## Imperative API\n\nPass `onReady` to receive a handle for driving the manager from code (toolbars,\nmenus, keyboard shortcuts):\n\n```svelte\n<script lang=\"ts\">\n import { SvDockManager, type DockManagerApi } from '@svgrid/grid'\n let api: DockManagerApi\n</script>\n\n<SvDockManager bind:workspace onReady={(a) => (api = a)}>...</SvDockManager>\n\n<!-- later -->\napi.float('terminal') // pop into a floating window\napi.popout('editor') // pop out to a native browser window\napi.autoHide('explorer') // collapse to its nearest edge\napi.maximize(leafId) // maximize a tiled leaf in place (toggle)\napi.close('problems') // close a pane anywhere\napi.getState() / api.setState(s) // snapshot / restore the whole workspace\n```\n\n## Touch\n\nEvery gesture is built on pointer events with `touch-action: none` on the tabs,\nsplitters, window bars and resize handles, so dragging to dock, reorder, resize,\nmove windows and pull out fly-outs all work on touch devices without the browser\nhijacking the drag to scroll.\n\n## Accessibility\n\n`SvDockManager` inherits the [SvDockLayout](sv-dock-layout.md#accessibility) tab /\nsplitter / panel semantics for every docked area, and adds window-level roles on\ntop:\n\n- Tab strips use `role=\"tablist\"` / `role=\"tab\"` with `aria-selected`; the active\n tab is keyboard-focusable and `Enter` / `Space` activate it.\n- Splitters expose `role=\"separator\"` with `aria-orientation`; content areas are\n `role=\"tabpanel\"`.\n- Floating windows are labelled dialog-style regions with focusable title bars, so\n keyboard users can reach and move them; close / pin / auto-hide controls are real\n buttons with accessible labels.\n\n## Headless\n\nLike [SvDockLayout](sv-dock-layout.md), the manager is headless in the\ndata-model form: `DockManagerState` **is** the framework-free core, and every\nsurface transform is a pure, immutable function (`floatPane`, `dockPaneOnto`,\n`reorderTab`, `autoHideLeaf`, `pinAutoHidden`, `dockManagerClosePane`,\n`allManagerPaneIds`) unit-tested without a browser. You can compute, migrate, or\nrestore an entire multi-window workspace on the server or in a worker and hand\nthe finished state to the component to render. See\n[Headless editors](headless-editors.md#data-model-cores-dock).\n\n## Relationship to SvDockLayout\n\n`SvDockManager` renders `SvDockLayout` in \"manager mode\": the tiled area and each\nfloating window are `SvDockLayout` instances that delegate their gestures up to\nthe manager, so a single drag can flow across the main area, floating windows and\nedge strips. Reach for `SvDockLayout` directly when you only need a tiled,\nin-place docking layout; reach for `SvDockManager` when you need floating\nwindows, reordering, or auto-hide.\n\n## More examples\n\n### Ops workspace\n\nAn on-call workspace on SvDockManager that runs a whole incident: a deploy rolls out and the service degrades, an alert rule tints its row and opens an incident, the on-call primary is paged and acknowledges, and rolling the deploy back recovers it. Panes hold a live service grid, p50 / p95 / p99 latency and error charts, an incident board (one SvGrid, Board or Table), deploys, on-call and an alert feed; an incident opens a drawer with its timeline, notes, severity and status. Panes drag, tab, float and save.\n\n<div data-docs-demo=\"502-ops-workspace\" data-height=\"780\"></div>\n\n\n### Docking: API & events\n\nSvDockManager imperative API (onReady) + event stream (onEvent): a toolbar floats / pops out / maximizes / auto-hides / focuses panes from code, with every action logged live.\n\n<div data-docs-demo=\"364-dock-api\" data-height=\"520\"></div>\n\n### Docking: pop out to a window\n\nSvDockManager allowPopout (off by default): pop a panel out to a real OS window for a second monitor - a live monitoring console where Live log / Metrics keep streaming in the popped-out window, and closing it docks the panel back. Falls back to an in-app floating window when pop-ups are blocked.\n\n<div data-docs-demo=\"367-dock-popout\" data-height=\"520\"></div>\n\n## See also\n\n- [SvDockLayout](sv-dock-layout.md) - the tiled docking layout underneath.\n- [SvSplitter](sv-splitter.md) - a single two-pane resizable split.\n"
|
|
5540
5571
|
},
|
|
5541
5572
|
{
|
|
5542
5573
|
"slug": "help/ui-components/sv-drawer",
|
|
@@ -5690,7 +5721,7 @@ export const docs = [
|
|
|
5690
5721
|
"path": "docs/help/ui-components/sv-nav-pane.md",
|
|
5691
5722
|
"title": "SvNavPane",
|
|
5692
5723
|
"section": "UI components",
|
|
5693
|
-
"markdown": "# SvNavPane\n\nAn Outlook-style navigation pane: collapsible sections, items with icons and\nbadge counts, one level of nested sub-items, and an optional bottom module strip.\n\n`SvNavPane` is the app-shell sidebar. Sections group items and can collapse;\nitems carry an icon, a badge (unread counts), and optionally one level of\nchildren. It supports a single-select highlight, arrow-key navigation, an\nicon-only `collapsed` rail, and RTL. The bottom `modules` strip reproduces\nOutlook's Mail / Calendar / People switch, complete with a drag splitter that\nmoves modules between full rows and an icon rail. Colors come from the `--sg-*`\ntokens.\n\nRelated: [SvTree](sv-tree.md) · [SvBreadcrumb](sv-breadcrumb.md) · [Navigation & rich overview](navigation.md)\n\n## Installation\n\nAdd it with the CLI - this drops a ready-to-edit `SvNavPane` starter into your app:\n\n<div data-docs-add=\"add nav-pane\"></div>\n\nPrefer to see it first? `npx @svgrid/ui try nav-pane` opens it in a throwaway sandbox - no project needed.\n\nOr install the package and import it directly. `SvNavPane` ships free in\n`@svgrid/grid` (dependency-free):\n\n<div data-docs-install=\"@svgrid/grid\"></div>\n\nThe examples on this page import from `@svgrid/grid`:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvButton, SvNavPane } from '@svgrid/grid'\n</script>\n```\n\n```ts\nimport { SvNavPane } from '@svgrid/grid'\n```\n\n## Example\n\n<div data-docs-demo=\"327-navpane\" data-height=\"440\" data-code></div>\n\n```svelte\n<script lang=\"ts\">\n import { SvNavPane, type NavSection } from '@svgrid/grid'\n let value = $state<string | null>('inbox')\n const sections: NavSection[] = [\n { id: 'mail', label: 'Mail', items: [\n { id: 'inbox', label: 'Inbox', badge: 12 },\n { id: 'sent', label: 'Sent' },\n ] },\n ]\n</script>\n\n<SvNavPane {sections} bind:value onSelect={(id) => load(id)} />\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n| ------------------ | ------------------------------- | -------------- | ---------------------------------------------------------------- |\n| `sections` | `ReadonlyArray<NavSection>` | - | The grouped navigation items. |\n| `value` | `string \\| null` | `null` | Selected item id (bindable). |\n| `onSelect` | `(id: string) => void` | - | Fires when a leaf item is chosen. |\n| `expandedSections` | `string[]` | all open | Open section ids (bindable). Default opens every section. |\n| `expandedItems` | `string[]` | `[]` | Open nested-item ids (bindable). |\n| `collapsed` | `boolean` | `false` | Icon-only rail; labels and badges hidden. |\n| `ariaLabel` | `string` | `Navigation` | Accessible name for the `<nav>` landmark. |\n| `dir` | `EditorDir` | - | Text direction; `rtl` mirrors the pane. |\n| `modules` | `ReadonlyArray<NavModule>` | - | Outlook-style bottom module buttons. |\n| `moduleValue` | `string \\| null` | `null` | Selected module id (bindable). |\n| `onModuleSelect` | `(id: string) => void` | - | Fires when a module is chosen. |\n| `moduleRows` | `number` | all | How many modules show as full rows; the rest collapse to a rail (bindable). |\n| `height` | `number` | - | Pane height in px; lets the module splitter redistribute space. |\n\nExported helper types:\n- `NavItem`: `{ id, label, icon?: Snippet, badge?: string | number, disabled?, children?: NavItem[] }`.\n- `NavSection`: `{ id, label?, items: NavItem[], collapsible? }`.\n- `NavModule`: `{ id, label, icon?: Snippet, badge?: string | number }`.\n\n## Examples\n\n### Icons and badge counts\n\nPass a `Snippet` per item for an icon, and a `badge` for a count:\n\n```svelte\n{#snippet inboxIcon()}<MailIcon />{/snippet}\nconst items = [{ id: 'inbox', label: 'Inbox', icon: inboxIcon, badge: 12 }]\n```\n\n### Collapsible icon rail\n\nBind `collapsed` to a toggle for a slim, icon-only sidebar. Item labels move into\n`title` tooltips automatically:\n\n```svelte\n<SvNavPane {sections} bind:value collapsed={railOpen} />\n```\n\n### The Outlook module strip\n\nGive `modules` and a fixed `height`, then the drag splitter lets the user pull\nmodules between labelled rows and the compact icon rail (bindable `moduleRows`):\n\n```svelte\n<SvNavPane {sections} bind:value {modules} bind:moduleValue height={520} />\n```\n\n### A workspace sidebar wired to routing\n\nA full app-shell pane: labelled sections, an icon and unread badge, one level of\nnested folders, a disabled item, and a collapse toggle. Route on `onSelect` and\nbind `value` so the highlight follows the active page:\n\n```svelte\n<script lang=\"ts\">\n import { SvNavPane, SvButton, type NavSection } from '@svgrid/grid'\n import { goto } from '$app/navigation'\n\n let value = $state<string | null>('inbox')\n let rail = $state(false)\n\n const sections: NavSection[] = [\n { id: 'mail', label: 'Mail', items: [\n { id: 'inbox', label: 'Inbox', icon: inboxIcon, badge: 12 },\n { id: 'drafts', label: 'Drafts', badge: 3 },\n { id: 'folders', label: 'Folders', children: [\n { id: 'work', label: 'Work' },\n { id: 'personal', label: 'Personal' },\n ] },\n ] },\n { id: 'settings', label: 'Settings', collapsible: true, items: [\n { id: 'account', label: 'Account' },\n { id: 'billing', label: 'Billing', disabled: true },\n ] },\n ]\n</script>\n\n{#snippet inboxIcon()}<span>📥</span>{/snippet}\n\n<SvButton size=\"sm\" onclick={() => (rail = !rail)}>{rail ? 'Expand' : 'Collapse'}</SvButton>\n<SvNavPane {sections} bind:value collapsed={rail} onSelect={(id) => goto(`/mail/${id}`)} />\n```\n\nTip: an item that has `children` toggles its sub-list on click instead of\nselecting, so a parent item never becomes the highlighted `value` and never\nfires `onSelect`. Give routable destinations to the leaf items (`work`,\n`personal`), not to the folder that contains them.\n\n## Accessibility\n\n- Renders a `<nav aria-label>` landmark; each item and module is a `<button>`.\n- The selected item and module carry `aria-current=\"page\"`.\n- Section headers and expandable items expose `aria-expanded`.\n- Arrow Up / Down move focus between items; the module splitter is a focusable\n `separator` with `aria-valuenow` that Arrow Up / Down resize.\n\n## See also\n\n- [Navigation overview](navigation.md) - the wayfinding family at a glance.\n- [SvTree](sv-tree.md) - a hierarchical tree view for deeper structures.\n- [SvBreadcrumb](sv-breadcrumb.md) - show the path to the current page.\n"
|
|
5724
|
+
"markdown": "# SvNavPane\n\nAn Outlook-style navigation pane: collapsible sections, items with icons and\nbadge counts, one level of nested sub-items, and an optional bottom module strip.\n\n`SvNavPane` is the app-shell sidebar. Sections group items and can collapse;\nitems carry an icon, a badge (unread counts), and optionally one level of\nchildren. It supports a single-select highlight, arrow-key navigation, an\nicon-only `collapsed` rail, and RTL. The bottom `modules` strip reproduces\nOutlook's Mail / Calendar / People switch, complete with a drag splitter that\nmoves modules between full rows and an icon rail. Colors come from the `--sg-*`\ntokens.\n\nRelated: [SvTree](sv-tree.md) · [SvBreadcrumb](sv-breadcrumb.md) · [Navigation & rich overview](navigation.md)\n\n## Installation\n\nAdd it with the CLI - this drops a ready-to-edit `SvNavPane` starter into your app:\n\n<div data-docs-add=\"add nav-pane\"></div>\n\nPrefer to see it first? `npx @svgrid/ui try nav-pane` opens it in a throwaway sandbox - no project needed.\n\nOr install the package and import it directly. `SvNavPane` ships free in\n`@svgrid/grid` (dependency-free):\n\n<div data-docs-install=\"@svgrid/grid\"></div>\n\nThe examples on this page import from `@svgrid/grid`:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvButton, SvNavPane } from '@svgrid/grid'\n</script>\n```\n\n```ts\nimport { SvNavPane } from '@svgrid/grid'\n```\n\n## Example\n\n<div data-docs-demo=\"327-navpane\" data-height=\"440\" data-code></div>\n\n```svelte\n<script lang=\"ts\">\n import { SvNavPane, type NavSection } from '@svgrid/grid'\n let value = $state<string | null>('inbox')\n const sections: NavSection[] = [\n { id: 'mail', label: 'Mail', items: [\n { id: 'inbox', label: 'Inbox', badge: 12 },\n { id: 'sent', label: 'Sent' },\n ] },\n ]\n</script>\n\n<SvNavPane {sections} bind:value onSelect={(id) => load(id)} />\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n| ------------------ | ------------------------------- | -------------- | ---------------------------------------------------------------- |\n| `sections` | `ReadonlyArray<NavSection>` | - | The grouped navigation items. |\n| `value` | `string \\| null` | `null` | Selected item id (bindable). |\n| `onSelect` | `(id: string) => void` | - | Fires when a leaf item is chosen. |\n| `expandedSections` | `string[]` | all open | Open section ids (bindable). Default opens every section. |\n| `expandedItems` | `string[]` | `[]` | Open nested-item ids (bindable). |\n| `collapsed` | `boolean` | `false` | Icon-only rail; labels and badges hidden. |\n| `ariaLabel` | `string` | `Navigation` | Accessible name for the `<nav>` landmark. |\n| `dir` | `EditorDir` | - | Text direction; `rtl` mirrors the pane. |\n| `modules` | `ReadonlyArray<NavModule>` | - | Outlook-style bottom module buttons. |\n| `moduleValue` | `string \\| null` | `null` | Selected module id (bindable). |\n| `onModuleSelect` | `(id: string) => void` | - | Fires when a module is chosen. |\n| `moduleRows` | `number` | all | How many modules show as full rows; the rest collapse to a rail (bindable). |\n| `height` | `number` | - | Pane height in px; lets the module splitter redistribute space. |\n\nExported helper types:\n- `NavItem`: `{ id, label, icon?: Snippet, badge?: string | number, disabled?, children?: NavItem[] }`.\n- `NavSection`: `{ id, label?, items: NavItem[], collapsible? }`.\n- `NavModule`: `{ id, label, icon?: Snippet, badge?: string | number }`.\n\n## Examples\n\n### Icons and badge counts\n\nPass a `Snippet` per item for an icon, and a `badge` for a count:\n\n```svelte\n{#snippet inboxIcon()}<MailIcon />{/snippet}\nconst items = [{ id: 'inbox', label: 'Inbox', icon: inboxIcon, badge: 12 }]\n```\n\n### Collapsible icon rail\n\nBind `collapsed` to a toggle for a slim, icon-only sidebar. Item labels move into\n`title` tooltips automatically:\n\n```svelte\n<SvNavPane {sections} bind:value collapsed={railOpen} />\n```\n\n### The Outlook module strip\n\nGive `modules` and a fixed `height`, then the drag splitter lets the user pull\nmodules between labelled rows and the compact icon rail (bindable `moduleRows`):\n\n```svelte\n<SvNavPane {sections} bind:value {modules} bind:moduleValue height={520} />\n```\n\n### A workspace sidebar wired to routing\n\nA full app-shell pane: labelled sections, an icon and unread badge, one level of\nnested folders, a disabled item, and a collapse toggle. Route on `onSelect` and\nbind `value` so the highlight follows the active page:\n\n```svelte\n<script lang=\"ts\">\n import { SvNavPane, SvButton, type NavSection } from '@svgrid/grid'\n import { goto } from '$app/navigation'\n\n let value = $state<string | null>('inbox')\n let rail = $state(false)\n\n const sections: NavSection[] = [\n { id: 'mail', label: 'Mail', items: [\n { id: 'inbox', label: 'Inbox', icon: inboxIcon, badge: 12 },\n { id: 'drafts', label: 'Drafts', badge: 3 },\n { id: 'folders', label: 'Folders', children: [\n { id: 'work', label: 'Work' },\n { id: 'personal', label: 'Personal' },\n ] },\n ] },\n { id: 'settings', label: 'Settings', collapsible: true, items: [\n { id: 'account', label: 'Account' },\n { id: 'billing', label: 'Billing', disabled: true },\n ] },\n ]\n</script>\n\n{#snippet inboxIcon()}<span>📥</span>{/snippet}\n\n<SvButton size=\"sm\" onclick={() => (rail = !rail)}>{rail ? 'Expand' : 'Collapse'}</SvButton>\n<SvNavPane {sections} bind:value collapsed={rail} onSelect={(id) => goto(`/mail/${id}`)} />\n```\n\nTip: an item that has `children` toggles its sub-list on click instead of\nselecting, so a parent item never becomes the highlighted `value` and never\nfires `onSelect`. Give routable destinations to the leaf items (`work`,\n`personal`), not to the folder that contains them.\n\n## Accessibility\n\n- Renders a `<nav aria-label>` landmark; each item and module is a `<button>`.\n- The selected item and module carry `aria-current=\"page\"`.\n- Section headers and expandable items expose `aria-expanded`.\n- Arrow Up / Down move focus between items; the module splitter is a focusable\n `separator` with `aria-valuenow` that Arrow Up / Down resize.\n\n## More examples\n\n### Mail client\n\nAn Outlook-shaped mail client in one file. The inbox is a SvGrid with sender avatars, conversation counts, quick filters, hover actions and bulk actions on ticked rows; the reading pane shows the whole conversation with quoted text folded; the SvRichText composer has recipient chips with contact suggestions, Cc, attachments, a signature and a five-second Undo send. New mail arrives while the page is open, Gmail-style keys work (press ?), an accepted invite lands on the Calendar module (the same grid as a scheduler), and Export writes a folder to Excel, CSV or PDF.\n\n<div data-docs-demo=\"501-mail-app\" data-height=\"780\"></div>\n\n## See also\n\n- [Navigation overview](navigation.md) - the wayfinding family at a glance.\n- [SvTree](sv-tree.md) - a hierarchical tree view for deeper structures.\n- [SvBreadcrumb](sv-breadcrumb.md) - show the path to the current page.\n"
|
|
5694
5725
|
},
|
|
5695
5726
|
{
|
|
5696
5727
|
"slug": "help/ui-components/sv-number-input",
|
|
@@ -7062,8 +7093,8 @@ export const apiReference = {
|
|
|
7062
7093
|
]
|
|
7063
7094
|
};
|
|
7064
7095
|
export const apiSurface = {
|
|
7065
|
-
"gridVersion": "3.0.
|
|
7066
|
-
"enterpriseVersion": "3.1.
|
|
7096
|
+
"gridVersion": "3.0.8",
|
|
7097
|
+
"enterpriseVersion": "3.1.2",
|
|
7067
7098
|
"grid": {
|
|
7068
7099
|
"values": [
|
|
7069
7100
|
"ALL_FILTER_OPERATORS",
|
|
@@ -8434,6 +8465,7 @@ export const apiSurface = {
|
|
|
8434
8465
|
"createSupabaseRealtime",
|
|
8435
8466
|
"createTableRegistry",
|
|
8436
8467
|
"createWorkbook",
|
|
8468
|
+
"createWorkerDataSource",
|
|
8437
8469
|
"criticalPath",
|
|
8438
8470
|
"cronMatches",
|
|
8439
8471
|
"cronMatchesParsed",
|
|
@@ -9092,6 +9124,7 @@ export const apiSurface = {
|
|
|
9092
9124
|
"ImportResult",
|
|
9093
9125
|
"ImportRowError",
|
|
9094
9126
|
"ImportValidator",
|
|
9127
|
+
"InMemoryDataSourceOptions",
|
|
9095
9128
|
"Interval",
|
|
9096
9129
|
"IntrospectSupabaseOptions",
|
|
9097
9130
|
"IntrospectedColumn",
|
|
@@ -9346,6 +9379,9 @@ export const apiSurface = {
|
|
|
9346
9379
|
"Well",
|
|
9347
9380
|
"Workbook",
|
|
9348
9381
|
"WorkbookOptions",
|
|
9382
|
+
"WorkerDataSource",
|
|
9383
|
+
"WorkerDataSourceOptions",
|
|
9384
|
+
"WorkerLike",
|
|
9349
9385
|
"WorkingCalendar",
|
|
9350
9386
|
"WritableDataSource",
|
|
9351
9387
|
"ZoomLevel",
|
|
@@ -9365,7 +9401,8 @@ export const apiSurface = {
|
|
|
9365
9401
|
"@svgrid/enterprise/studio",
|
|
9366
9402
|
"@svgrid/enterprise/wc",
|
|
9367
9403
|
"@svgrid/enterprise/wc/react",
|
|
9368
|
-
"@svgrid/enterprise/wc/vue"
|
|
9404
|
+
"@svgrid/enterprise/wc/vue",
|
|
9405
|
+
"@svgrid/enterprise/worker"
|
|
9369
9406
|
]
|
|
9370
9407
|
},
|
|
9371
9408
|
"props": [
|