@svgrid/mcp 3.0.5 → 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 +78 -48
- package/dist/preview.d.ts +1 -1
- package/dist/preview.js +1 -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> | --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
|
|
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<!-- tutorial:install-studio -->\n<figure class=\"docs-tutorial docs-tutorial-player\" id=\"tutorial-install-studio\" data-docs-tutorial=\"install-studio\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/install-studio.mp4\" poster=\"/tutorials/install-studio.poster.webp\" width=\"960\" height=\"540\" controls playsinline preload=\"none\" aria-label=\"SvGrid Studio: database to CRUD app, 69 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/install-studio.vtt\">Your browser does not play embedded video. <a href=\"/tutorials/install-studio.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>SvGrid Studio: database to CRUD app</strong> (69 s, with narration).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>SvGrid Studio turns tables into a SvelteKit app. From the command line, one create command scaffolds a runnable app with sample tables.</p>\n<p>Point init at a real database and it reads the tables, then writes a list, an edit form and a record page for each one.</p>\n<p>The same generator runs in the browser. The designer opens with a wizard: start from sample data, your own database, or blank.</p>\n<p>Pick a dataset, choose which pages each table gets, and open the app.</p>\n<p>Two tables became six screens: an overview with a KPI and a chart, a list, a manage page and a detail page per table, all on the canvas to rearrange.</p>\n<p>Preview runs the app as a user sees it: the grid, the new record button, the navigation.</p>\n<p>Generate app writes the SvelteKit project: twenty-seven files, typed schemas, routes and a CI workflow. Download the zip or open it in StackBlitz.</p>\n</details>\n</figure>\n<!-- /tutorial:install-studio -->\n\n## Guided path - answer a few questions, get the app\n\nIf you already know where your data lives, let Studio ask:\n\n```bash\nnpx @svgrid/studio init\n```\n\nIt asks four things - where the data comes from (sample data, your database,\nin-browser Postgres, or a REST API), which tables you want, which pages each\ntable gets, and what it should look like - then writes a runnable SvelteKit app\nwith a list, an edit form and a record page per table, plus an overview\ndashboard.\n\nPointing it at a real database is one line, and Studio installs the driver for\nyou:\n\n```bash\nnpx @svgrid/studio init --db postgres --url $DATABASE_URL --out my-app\n```\n\nOn Supabase there is no driver to install at all - it is read over the project's\nREST API:\n\n```bash\nnpx @svgrid/studio init --supabase-url https://xxxx.supabase.co --supabase-key $SUPABASE_ANON_KEY\n```\n\nPrefer clicking? The visual designer has the same wizard behind its **New app**\nbutton - or open [svgrid.com/studio/new](https://svgrid.com/studio/new) to start\none in the browser. Both paths run the same generator, so they produce the same\napp. See [The Studio CLI](./cli.md#init) for every flag.\n\n---\n\n## Fastest path - a downloadable, ready-to-run example\n\nRather have a working project on your machine than type code into a blank\nfile? One command scaffolds a complete SvelteKit app with everything already\nwired up:\n\n```bash\nnpm create @svgrid/studio@latest my-app\n```\n\nYou'll be asked to pick a **theme** - one of `@svgrid/grid`'s 19 built-in\npresets (shadcn, Tailwind, Material, Excel, Fluent, and more) - and whether to\nstart in **light or dark** mode. Scripting this instead? Both are flags:\n\n```bash\nnpm create @svgrid/studio@latest my-app -- --theme material --dark\n```\n\nThen:\n\n```bash\ncd my-app\nnpm install\nnpm run dev\n```\n\nOpen `http://localhost:5173`. You get:\n\n- A **nav shell** and a home page (`src/routes/+layout.svelte`).\n- Two linked entities - **Customers** and **Orders** - each a full grid +\n modal create/edit/delete screen. Orders has a searchable lookup back to\n Customers, so you can see how relations work.\n- **Seeded in-memory data** - nothing to install or configure, no database.\n- The **theme and mode you picked**, applied to the whole app - not just the grid.\n\nIt is a real project, not a read-only demo - edit it, add fields, connect a\ndatabase, deploy it. A few places to start:\n\n| Want to... | Edit |\n| --- | --- |\n| Add or change a field | `src/lib/schemas.ts` - the grid and the form update together |\n| See how a screen is built | `src/lib/EntityScreen.svelte` - the reusable grid + modal CRUD screen every route uses |\n| Connect a real database | `src/lib/data.ts` - swap `createInMemoryDataSource` for `createSqlDataSource` / `createSupabaseDataSource` (see [Databases](./databases.md)) |\n| Add another screen from a live table | `npx @svgrid/studio add invoices --db postgres --url \"$DATABASE_URL\"` |\n\nThe template's own `README.md` covers the same ground once you're in the\nproject. Prefer to see each piece built up by hand instead, or add a screen to\nan *existing* app rather than a fresh one? Continue below.\n\n---\n\n## Three ways to build - pick yours\n\n\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## 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,70 +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 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
|
|
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<!-- 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> (
|
|
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\n<!-- tutorial:learn-2-data-and-columns -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-2-data-and-columns\" data-docs-tutorial=\"learn-2-data-and-columns\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-2-data-and-columns.mp4\" poster=\"/tutorials/learn-2-data-and-columns.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 2: your data, your columns, 88 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-2-data-and-columns.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-2-data-and-columns.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 2: your data, your columns</strong> (88 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>In lesson one the rows were made up. In this one they are yours, they stay reactive, and the columns start looking like a real application.</p>\n<p>Start from your own row type. The grid never asks you to convert anything: whatever shape your records have, that is the shape it takes. Declare the array with the state rune and it stays reactive.</p>\n<p>Columns map a field to a header, and width fixes one that would otherwise stretch. Then a plain function that pushes a record onto the array.</p>\n<p>Watch what happens when that button runs. Pushing onto the array is all it does, and the grid follows, because the rows are a rune and not a copy the grid took at startup.</p>\n<p>Now formatting. This is the same page of the docs, a few blocks further down, and it is the thing people most often do by hand and regret.</p>\n<p>A currency format, a percent, a date. Each one renders the value for the reader and leaves your data alone, which matters because sorting and exporting both use the raw number underneath, not the string on screen.</p>\n<p>One habit worth forming: type the column array as GridColumns of your row type. The compiler then checks every field name against the row, so a typo is a build error instead of an empty column nobody notices.</p>\n<p>Both files are on the page below, and they run there too. See you in lesson three.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-2-data-and-columns -->\n"
|
|
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\n<!-- tutorial:learn-3-sort-filter-page -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-3-sort-filter-page\" data-docs-tutorial=\"learn-3-sort-filter-page\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-3-sort-filter-page.mp4\" poster=\"/tutorials/learn-3-sort-filter-page.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 3: sorting, filtering and paging, 143 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-3-sort-filter-page.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-3-sort-filter-page.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 3: sorting, filtering and paging</strong> (143 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Your grid renders. It does not sort, filter or page yet, and that is deliberate. This lesson turns those on and explains the trade the library is making for you.</p>\n<p>Here is a wider row type and a realistic set of records, the kind of table you would actually ship. Nothing new yet.</p>\n<p>Columns, with a currency format on salary from the last lesson.</p>\n<p>And now the part that matters. Sortable, filterable, editable, groupable, pageable. Each is one boolean, and each one pulls in only the code that feature needs, which is why a grid you never configured stays small.</p>\n<p>That is the whole file: the row type, the records, the columns, and the component with those five words on it. Nothing else is configured, and nothing is hidden above the fold.</p>\n<p>Click a header and it sorts. Click again for descending, and a third time to clear it and get your original order back.</p>\n<p>Shift-click a second header to sort by two columns at once: department first, then name inside each department.</p>\n<p>The funnel next to each header is filtering, switched on by that same one word. How it looks is a choice, and the next screen has the version most people want.</p>\n<p>Five rows will not show you paging, so here is the same set of props over five thousand. Filterable on its own gives you a menu per header; filter mode row puts an input under each header instead, and it narrows the table as you type.</p>\n<p>And the pager cuts what is left into pages, with the range and the count kept honest as the filter changes them. None of this needed a callback from you.</p>\n<p>Those five booleans are a shorthand. When you need finer control, register the features explicitly with tableFeatures and pass them in, which is the same machinery with the wiring exposed; the docs page below shows both forms side by side. Lesson four lets people change the table.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-3-sort-filter-page -->\n"
|
|
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\n<!-- tutorial:learn-7-theme -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-7-theme\" data-docs-tutorial=\"learn-7-theme\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-7-theme.mp4\" poster=\"/tutorials/learn-7-theme.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 7: themes, dark mode and density, 113 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-7-theme.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-7-theme.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 7: themes, dark mode and density</strong> (113 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>A grid that looks like a grid library is a grid that never ships. Theming here is CSS custom properties, not utility classes and not a config object.</p>\n<p>Twenty presets ship with the package, and importing one stylesheet is the whole setup. What a preset sets is a list of custom properties, which means you can set them yourself when one is close but not right. Same five people as before.</p>\n<p>A wrapper, and a handful of properties on it. Background, text, border, the header colours, the accent, the hover. They cascade like any other custom property, so this theme is scoped to this one grid rather than the page.</p>\n<p>The whole file, so nothing is hidden: the rows, the columns, a wrapper around the grid, and the properties on that wrapper.</p>\n<p>Here is that file running. Nine declarations, and nothing about the grid was configured to accept them.</p>\n<p>Density is separate from colour, and it is the setting people actually argue about. Row height is a prop, so the compact table and the comfortable one are the same component with one number changed.</p>\n<p>Dark mode is not a second stylesheet you maintain. Every preset declares a light palette and a dark one, and an attribute on the document element picks which is live. Every surface follows, including the parts you never styled.</p>\n<p>The density control here is the same row height prop behind a dropdown, which is what it usually becomes: a preference you save per user rather than a decision you make once for everybody.</p>\n<p>The theme builder on the site edits these tokens live and hands you the CSS at the end. Every property is listed on the page below. See you in lesson eight.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-7-theme -->\n"
|
|
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\n<!-- tutorial:learn-10-production -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-10-production\" data-docs-tutorial=\"learn-10-production\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-10-production.mp4\" poster=\"/tutorials/learn-10-production.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 10: going to production, 108 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-10-production.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-10-production.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 10: going to production</strong> (108 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Your grid works. This last lesson is the review a colleague would give it: the four things that decide whether it survives contact with real data and real users.</p>\n<p>First, scale. Virtualization is on by default, so only the rows in view have DOM nodes. Fifty thousand rows by seventy-seven columns scroll in both directions, and the count of nodes stays roughly the same as it was with ten rows.</p>\n<p>What you do have to give it is a height. A grid inside a container with no height cannot know what is on screen, and that is the single most common reason a large table feels slow.</p>\n<p>Second, accessibility, which you mostly already have. It is a real table element carrying the ARIA grid pattern, so a screen reader announces rows and columns, and every cell is reachable from the keyboard.</p>\n<p>What is on you is the rest of the page: a label on the grid, contrast that passes, and not removing the focus ring. There is a high-contrast preset in the box if procurement asks.</p>\n<p>Third, the markup server-renders, so a viewport of real rows reaches the browser before hydration. And fourth, the habit that costs nothing: type the column array as GridColumns of your row. A bare GridColumns widens the row and stops checking field names, and a typo becomes a blank column nobody notices until a user does.</p>\n<p>That is the course. You can build a grid, shape its columns, sort and filter it, edit it, group it, theme it, feed it from a server and ship it. Everything past this point is a feature you turn on when you need it, and the demos on the site show each one running.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-10-production -->\n\n<!-- tutorial:learn-8-server-data -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-8-server-data\" data-docs-tutorial=\"learn-8-server-data\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-8-server-data.mp4\" poster=\"/tutorials/learn-8-server-data.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 8: data from a server, 106 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-8-server-data.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-8-server-data.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 8: data from a server</strong> (106 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Everything so far held every row in memory. That works to tens of thousands of rows. Past that, the sorting and filtering have to move to the server, and the grid has to ask for what it needs.</p>\n<p>The state the grid hands you is small: which column is sorted, which filters are set, which page you are on. Keep those in runes.</p>\n<p>Then one function that turns that state into a request. Notice the abort controller: when the user types another letter, the request already in flight is cancelled, so a slow earlier response cannot overwrite a newer one. That race is the most common bug in a server-backed table.</p>\n<p>An effect reruns it whenever sort, filters or page change, and the component passes the rows down with the total, so the pager knows how many pages there are without counting rows it never received.</p>\n<p>Here is that pattern running against a real endpoint. Sorting goes to the server and comes back sorted; typing in the search box is debounced, so one request goes out rather than one per keystroke.</p>\n<p>The loading state lives on the rows rather than as a spinner over the page, so the header and the controls stay usable while the next page arrives.</p>\n<p>Past a million rows there is a dedicated row model that lazily loads groups and blocks; it is linked from the page below. Lesson nine puts all of this in a SvelteKit app.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-8-server-data -->\n"
|
|
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"
|
|
3958
3975
|
},
|
|
3959
3976
|
{
|
|
3960
3977
|
"slug": "getting-started/course",
|
|
3961
3978
|
"path": "docs/getting-started/course.md",
|
|
3962
3979
|
"title": "Learn SvGrid: the video course",
|
|
3963
3980
|
"section": "Getting started",
|
|
3964
|
-
"markdown": "# Learn SvGrid: the video course\n\nTen short lessons that take you from an empty project to a grid you would put\nin front of users. Each one is a few minutes, each ends where the next begins,\nand every line of code on screen is a file you can open: the lessons are\nrecorded against the runnable examples on these pages, so what you watch is\nwhat runs.\n\nWatch them in order the first time. After that they work as reference, since\neach lesson sits on the docs page for its subject and the page carries the\nsame code in text.\n\n## What you need\n\nA project with Svelte 5 and the package installed. [Install](./1-install.md)\ncovers both, and takes about a minute. Everything else is in the lessons.\n\nThe course assumes you know Svelte 5 runes well enough to read `$state`, and\nnothing about data grids.\n\n## The lessons\n\n<!-- course:learn -->\n\n| # | Lesson | What it covers | Length |\n| --- | --- | --- | --- |\n| 1 | [Install and your first grid](./2-first-grid.md#tutorial-learn-1-first-grid) | Install the package, write a twenty-line component, and get a styled, keyboard-navigable table. | 2 min 01 s |\n| 2 | [Your data, your columns](./3-data-and-columns.md#tutorial-learn-2-data-and-columns) | Put your own row type in, keep it reactive, then set headers, widths and currency, percent and date formats. | 1 min 28 s |\n| 3 | [Sorting, filtering and paging](./4-features.md#tutorial-learn-3-sort-filter-page) | Switch on sorting, filtering, grouping and pagination with boolean props, and see what each one does to the same table. | 2 min 23 s |\n| 4 | [Editing rows, and validating them](../help/editing/validation.md#tutorial-learn-4-editing) | Make cells editable, pick an editor per column, and use validate with rejectInvalid so bad input never reaches your data. | 1 min 31 s |\n| 5 | [Selection and bulk actions](../help/rows/selection-bar.md#tutorial-learn-5-selection) | Add row checkboxes, give the grid a stable row id, and float a bulk-action bar over the rows the user picked. | 1 min 26 s |\n| 6 | [Grouping and totals](../help/grouping-aggregation.md#tutorial-learn-6-grouping) | Group rows by a column, average and sum per group with the aggregate option, and add a summary row for the whole table. | 1 min 34 s |\n| 7 | [Themes, dark mode and density](./5-theme-and-density.md#tutorial-learn-7-theme) | Set the CSS custom properties yourself, flip dark mode with one attribute, and change row density with a prop. | 1 min 53 s |\n| 8 | [Data from a server](./6-going-to-production.md#tutorial-learn-8-server-data) | Move sorting, filtering and paging to the backend, send one request per change, and cancel the one that is now stale. | 1 min 46 s |\n| 9 | [SvelteKit end to end](./sveltekit.md#tutorial-learn-9-sveltekit) | Query in a load function, hand the rows to the page, and get real server-rendered HTML instead of an empty shell. | 1 min 21 s |\n| 10 | [Going to production](./6-going-to-production.md#tutorial-learn-10-production) | Virtualization, the accessibility you already have, server-rendered markup, and the TypeScript habit that prevents blank columns. | 1 min 48 s |\n\nTen lessons, 17 min 10 s in all.\n\n<!-- /course:learn -->\n\n## How the lessons are made\n\nThe code you see typed in the editor is not a screenshot or a re-typed copy.\nEach lesson reads a runnable block straight out of these docs, types that\nfile's own source, and mounts that same file in the browser pane beside it. So\nthe code shown and the result shown cannot drift apart, and when a doc changes\nthe lesson is re-recorded from it rather than edited to match.\n\nEvery claim the narration makes is also checked while recording. When a lesson\nsays a button adds a row, the recorder counts the rows before and after and\nfails the take if the count did not move. That check exists because a take\nonce shipped where it had not.\n\n## See also\n\n- [Install](./1-install.md) - the one-minute setup the course assumes.\n- [Starters](./starters.md) - a scaffolded project if you would rather not\n wire one up by hand.\n- [SvelteKit](./sveltekit.md) - the same grid in a SvelteKit app, end to end.\n- [Going to production](./6-going-to-production.md) - the checks before you\n ship, which is where lesson ten ends.\n"
|
|
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"
|
|
3965
3982
|
},
|
|
3966
3983
|
{
|
|
3967
3984
|
"slug": "getting-started/starters",
|
|
3968
3985
|
"path": "docs/getting-started/starters.md",
|
|
3969
3986
|
"title": "Starters & scaffolding",
|
|
3970
3987
|
"section": "Getting started",
|
|
3971
|
-
"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"
|
|
3972
3989
|
},
|
|
3973
3990
|
{
|
|
3974
3991
|
"slug": "getting-started/sveltekit",
|
|
3975
3992
|
"path": "docs/getting-started/sveltekit.md",
|
|
3976
3993
|
"title": "SvGrid with SvelteKit",
|
|
3977
3994
|
"section": "Getting started",
|
|
3978
|
-
"markdown": "# SvGrid with SvelteKit\n\nA grid in a SvelteKit app is not the same job as a grid in a Vite SPA. The data\nusually comes from a `+page.server.ts` load, the page has to server-render for\ncrawlers, sorting should survive a refresh and a shared link, and edits go back\nthrough a form action. This page walks all four.\n\nEverything here was run against a real SvelteKit app before it was written -\n`sv create`, `sv add`, `svelte-check`, `vite build`, and the server HTML checked\nover HTTP.\n\n## Run it before you read it\n\nEverything this page builds is also a template, so you can have it on your\nmachine in three commands:\n\n```bash\nnpm create @svgrid@latest people -- --template sveltekit\ncd people && npm install && npm run dev\n```\n\nThat is the app from [the end-to-end section](#the-whole-thing-end-to-end)\nbelow, plus a theme picker in the header - all 20 presets, light and dark,\nswitched live while the grid is running. Pick the starting theme up front if you\nprefer:\n\n```bash\nnpm create @svgrid@latest people -- --template sveltekit --theme dracula --dark\n```\n\n`--theme` writes the preset into `src/app.css`, so the first paint and the\nserver-rendered HTML are already themed rather than flashing a frame of the\ndefault. Leave `--dark`/`--light` off and the app follows the visitor's OS\nsetting instead. `npm create @svgrid@latest -- --help` lists every preset id.\n\nThe rest of this page builds the same thing by hand, which is the better way to\nunderstand it.\n\n## 1. Scaffold\n\nSvGrid ships an add-on for the [Svelte CLI](https://svelte.dev/docs/cli), so a\nnew app is two commands:\n\n```bash\nnpx sv create myapp # pick SvelteKit, TypeScript\ncd myapp\nnpx sv add @svgrid\n```\n\n`sv add` resolves `@svgrid` to `@svgrid/sv`, adds `@svgrid/grid` to your\ndependencies, and - if you say yes to the demo - writes a working grid to\n`src/routes/svgrid-demo/+page.svelte`. To skip the prompts:\n\n```bash\nnpx sv add \"@svgrid=demo:yes+enterprise:no\" --no-download-check\n```\n\nTwo things worth knowing. `sv add` must run **inside** a project - in an empty\ndirectory it fails with *\"Invalid workspace\"*, which is the CLI's own guard, not\nthe add-on. And community add-ons are still marked experimental upstream, so if\n`sv` changes under you, `npm install @svgrid/grid` does the same job.\n\nAdding to an existing app is just the install:\n\n```bash\nnpm install @svgrid/grid\n```\n\n## 2. Load data on the server\n\nPut the query in `+page.server.ts` and hand the rows to the page. Nothing about\nSvGrid is special here - it takes a plain array.\n\n```ts\n// src/routes/people/+page.server.ts\nimport type { PageServerLoad } from './$types'\nimport { listPeople } from '$lib/db'\n\nexport const load: PageServerLoad = () => {\n return { rows: listPeople() }\n}\n```\n\n```svelte\n<!-- src/routes/people/+page.svelte -->\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import type { Person } from '$lib/db'\n\n let { data } = $props()\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name' },\n { field: 'role', header: 'Role' },\n { field: 'year', header: 'Year' },\n ]\n</script>\n\n<SvGrid data={data.rows} {columns} sortable containerHeight={320} />\n```\n\nType the column array against your row type. `GridColumns<Person>` checks every\n`field` against real keys; a bare `GridColumns` widens the row to\n`Record<string, unknown>` and stops checking them.\n\n## 3. What actually server-renders\n\nThe server HTML contains the header and a **viewport-sized window of rows** with\ntheir real cell values. That is what a crawler indexes and what a reader with JS\ndisabled sees.\n\nIt does *not* contain every row. Virtualization survives SSR, so a 5,000-row\ngrid does not serialise 5,000 rows into the page - the rest arrive when the\nclient measures the viewport and takes over. Hydration is clean because the\nserver and the first client render produce the same markup.\n\nYou can check it yourself on any page:\n\n```bash\nnpm run build && npm run preview\ncurl -s http://localhost:4173/people | grep -c 'data-svgrid-row'\n```\n\nA non-zero count means rows are in the HTML.\n\n> This genuinely did not work before `@svgrid/grid@2.6.8`. Both virtualizers\n> learned their row count from an `$effect`, and effects never run during SSR,\n> so the server emitted an empty `<tbody>`. If you are on an older version and\n> SEO matters, upgrade.\n\n## 4. Sorting that survives a refresh\n\nThe idiomatic SvelteKit move is to keep sort state in the URL. The link is then\nshareable and bookmarkable, the back button works, and the sorted page is\nserver-rendered - so it works with JS disabled too.\n\nSort on the server from the query string:\n\n```ts\n// src/routes/people/+page.server.ts\nexport const load: PageServerLoad = ({ url }) => {\n const sortBy = (url.searchParams.get('sort') ?? 'name') as keyof Person\n const desc = url.searchParams.get('dir') === 'desc'\n return { rows: listPeople(sortBy, desc), sortBy, desc }\n}\n```\n\nThen tell the grid the server owns the ordering, and push header clicks into the\nURL:\n\n```svelte\n<script lang=\"ts\">\n import { goto } from '$app/navigation'\n import { page } from '$app/state'\n\n function onSortingChange(sorting: Array<{ id: string; desc: boolean }>) {\n const next = new URL(page.url)\n if (sorting.length === 0) {\n next.searchParams.delete('sort')\n next.searchParams.delete('dir')\n } else {\n next.searchParams.set('sort', sorting[0]!.id)\n next.searchParams.set('dir', sorting[0]!.desc ? 'desc' : 'asc')\n }\n goto(next, { keepFocus: true, noScroll: true })\n }\n</script>\n\n<SvGrid\n data={data.rows}\n {columns}\n sortable\n externalSort\n initialSorting={[{ id: data.sortBy, desc: data.desc }]}\n {onSortingChange}\n/>\n```\n\n`externalSort` is the important prop: the grid keeps rendering sort indicators\nand cycling on header click, but stops reordering rows itself - because the rows\narriving from `load` are already in the right order. Without it the grid sorts\nthe page a second time, which is wasted work and goes wrong the moment the\nserver is paginating.\n\n`keepFocus` and `noScroll` stop the navigation stealing focus from the header\nyou just clicked or jumping the page to the top.\n\n## 5. Saving an edit through a form action\n\nTurn on editing, then push each committed change at an action:\n\n```ts\n// +page.server.ts\nexport const actions: Actions = {\n rename: async ({ request }) => {\n const form = await request.formData()\n renamePerson(Number(form.get('id')), String(form.get('name')))\n return { success: true }\n },\n}\n```\n\n```svelte\n<script lang=\"ts\">\n async function onCellValueChange(e: { row: Person; columnId: string; newValue: unknown }) {\n if (e.columnId !== 'name') return\n const body = new FormData()\n body.set('id', String(e.row.id))\n body.set('name', String(e.newValue))\n await fetch('?/rename', { method: 'POST', body })\n }\n</script>\n\n<SvGrid data={data.rows} {columns} editable {onCellValueChange} />\n```\n\nThe grid updates its own copy of the row immediately, so the cell shows the new\nvalue while the request is in flight. Handle a rejected save by reloading\n(`invalidateAll()`) or by writing the old value back.\n\nSvelteKit's CSRF protection rejects a cross-origin POST to an action. Browsers\nsend the `Origin` header themselves so this is invisible in an app - but if you\nscript the endpoint from Node or curl, set `Origin` or you will get\n`403 Cross-site POST form submissions are forbidden`.\n\n## The whole thing, end to end\n\nFour commands and three files. Every line below was run from a clean scaffold\nbefore it was published here: `svelte-check` reports 0 errors, the page\nserver-renders its rows, clicking a header rewrites the URL, and an edit\nsurvives a reload.\n\nIf you would rather run it than type it,\n`npm create @svgrid@latest people -- --template sveltekit` gives you this app\nwith a theme picker already wired in. The files below are what it contains.\n\n```bash\nnpx sv create people --template minimal --types ts\ncd people\nnpx sv add \"@svgrid=demo:no+enterprise:no\" --no-download-check\nnpm install\n```\n\n**`src/lib/people.ts`** - stands in for your database.\n\n```ts\nexport type Person = { id: number; name: string; role: string; year: number }\n\nconst people: Person[] = [\n { id: 1, name: 'Ada Lovelace', role: 'Mathematician', year: 1843 },\n { id: 2, name: 'Grace Hopper', role: 'Rear Admiral', year: 1952 },\n { id: 3, name: 'Karen Sparck Jones', role: 'Computer Scientist', year: 1972 },\n { id: 4, name: 'Barbara Liskov', role: 'Computer Scientist', year: 1968 },\n { id: 5, name: 'Margaret Hamilton', role: 'Software Engineer', year: 1969 },\n]\n\nexport function listPeople(sortBy: keyof Person = 'name', desc = false): Person[] {\n const rows = [...people]\n rows.sort((a, b) => (a[sortBy] > b[sortBy] ? 1 : a[sortBy] < b[sortBy] ? -1 : 0))\n return desc ? rows.reverse() : rows\n}\n\nexport function renamePerson(id: number, name: string): void {\n const row = people.find((p) => p.id === id)\n if (row) row.name = name\n}\n```\n\n**`src/routes/people/+page.server.ts`** - sorts from the query string, and takes\nthe edit.\n\n```ts\nimport type { Actions, PageServerLoad } from './$types'\nimport { listPeople, renamePerson, type Person } from '$lib/people'\n\nexport const load: PageServerLoad = ({ url }) => {\n const sortBy = (url.searchParams.get('sort') ?? 'name') as keyof Person\n const desc = url.searchParams.get('dir') === 'desc'\n return { rows: listPeople(sortBy, desc), sortBy, desc }\n}\n\nexport const actions: Actions = {\n rename: async ({ request }) => {\n const data = await request.formData()\n renamePerson(Number(data.get('id')), String(data.get('name')))\n return { success: true }\n },\n}\n```\n\n**`src/routes/people/+page.svelte`**\n\n```svelte\n<script lang=\"ts\">\n import { goto } from '$app/navigation'\n import { page } from '$app/state'\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import type { Person } from '$lib/people'\n\n let { data } = $props()\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', editable: true },\n { field: 'role', header: 'Role' },\n { field: 'year', header: 'Year' },\n ]\n\n // Header click -> URL -> server sorts -> load returns ordered rows.\n function onSortingChange(sorting: Array<{ id: string; desc: boolean }>) {\n const next = new URL(page.url)\n if (sorting.length === 0) {\n next.searchParams.delete('sort')\n next.searchParams.delete('dir')\n } else {\n next.searchParams.set('sort', sorting[0]!.id)\n next.searchParams.set('dir', sorting[0]!.desc ? 'desc' : 'asc')\n }\n goto(next, { keepFocus: true, noScroll: true })\n }\n\n // Committed edit -> form action -> database.\n async function onCellValueChange(e: { row: Person; columnId: string; newValue: unknown }) {\n if (e.columnId !== 'name') return\n const body = new FormData()\n body.set('id', String(e.row.id))\n body.set('name', String(e.newValue))\n await fetch('?/rename', { method: 'POST', body })\n }\n</script>\n\n<h1>People</h1>\n<p>Click a header to sort - the order lives in the URL. Double-click a name to edit it.</p>\n\n<SvGrid\n data={data.rows}\n {columns}\n sortable\n editable\n externalSort\n initialSorting={[{ id: data.sortBy, desc: data.desc }]}\n {onSortingChange}\n {onCellValueChange}\n containerHeight={320}\n/>\n```\n\nThen:\n\n```bash\nnpm run dev # http://localhost:5173/people\n```\n\nThree things to try, in this order:\n\n1. **Click the `Year` header.** The URL becomes `?sort=year&dir=asc`. Copy that\n link into a new tab - it opens already sorted, because the server did it.\n2. **Double-click a name, change it, press Enter, then reload.** The edit went\n through the form action and survived.\n3. **View source, or `curl localhost:5173/people`.** The rows are in the HTML,\n not just injected by JS. That is what a crawler sees.\n\n## 6. Going further\n\n- **Server-side paging and filtering** for large tables: `externalFilter` and\n `externalPagination` are the equivalents of `externalSort` above. See\n [server paging](../help/server/server-paging.md),\n [server filtering](../help/server/server-filtering.md) and the\n [server row model](../help/server/server-row-model.md), which wraps the whole\n request shape rather than wiring each prop by hand.\n- **Owning the markup.** If you need a table only you could write, the headless\n engine is importable on its own from `@svgrid/grid/core` and runs anywhere,\n including inside a load function. See [Why headless?](../why-headless.md).\n- **Themes and dark mode.** Set the theme attribute before first paint from an\n inline script in `app.html`, or the page renders in the wrong palette for a\n frame. The `sveltekit` template above ships that script plus a runtime picker\n built on `resolveThemeTokens` from `@svgrid/grid/themes`, which is the short\n way to see the pattern. [Theme and density](./5-theme-and-density.md) covers\n it properly.\n- **Gating it behind a login.** The `sveltekit` template ships an auth scaffold:\n a cookie session, PBKDF2 password hashing over Web Crypto (so it still runs on\n an edge adapter), and one `PROTECTED` list in `hooks.server.ts` that gates routes\n before any load runs. The role check sits in the form action, not only in the\n UI - hiding an edit button stops nobody from posting the action by hand.\n- **Deployment.** SvGrid is a normal client dependency with no build step or\n server runtime of its own, so any SvelteKit adapter works unchanged.\n\n<!-- tutorial:learn-9-sveltekit -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-9-sveltekit\" data-docs-tutorial=\"learn-9-sveltekit\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-9-sveltekit.mp4\" poster=\"/tutorials/learn-9-sveltekit.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 9: SvelteKit end to end, 81 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-9-sveltekit.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-9-sveltekit.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 9: SvelteKit end to end</strong> (81 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>SvelteKit gives you a place to put the query that is already on the server. The grid does not need to know about any of it: it takes an array.</p>\n<p>There is a SvelteKit template, so the whole app is two commands. If you have a project already, installing the package is enough.</p>\n<p>The load function runs on the server. Your database credentials are already there, so the query belongs there too, and the browser never sees it.</p>\n<p>The page takes what that returned and hands it straight to the grid. Type the columns against your row type, as in lesson two, and the compiler checks the field names for you.</p>\n<p>Here is a grid over a real endpoint, which is what your page becomes once the load function points at a database instead of a demo API.</p>\n<p>The part worth knowing: the server HTML contains a viewport of real rows, not an empty container waiting for hydration. A crawler sees data, and a reader sees the table before the JavaScript arrives.</p>\n<p>URL-driven sorting and form actions for edits are on the page below. One lesson to go.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-9-sveltekit -->\n\n## See also\n\n- [Going to production](./6-going-to-production.md) - CSP, SSR, accessibility\n- [First grid](./2-first-grid.md) - the framework-agnostic walkthrough\n- [Why headless?](../why-headless.md) - when to skip the render component\n"
|
|
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"
|
|
3979
3996
|
},
|
|
3980
3997
|
{
|
|
3981
3998
|
"slug": "help/accessibility",
|
|
@@ -4451,7 +4468,7 @@ export const docs = [
|
|
|
4451
4468
|
"path": "docs/help/editing/validation.md",
|
|
4452
4469
|
"title": "Validation",
|
|
4453
4470
|
"section": "Editing",
|
|
4454
|
-
"markdown": "# Validation\n\nA column's `validate` hook flags a bad value: the cell turns red and the\nmessage becomes its tooltip. On its own it does not refuse anything, so\na form can be filled in any order and fixed afterwards; `rejectInvalid`\non the same column turns the flag into a refusal, and an edit the rule\nfails against is never written. Rules that need more than one row, or\nthat should be logged, run in `onCellValueChange` instead.\n\n## Flag with `validate`\n\n`validate` runs for every rendered cell, not only on edit, so bad data\nalready in the source is red on load, and it re-runs as the user types.\nIt receives `{ value, row, rowIndex, column }` and returns `null`,\n`undefined` or `true` for a valid value, `false` for invalid without a\nmessage, or a string that becomes the tooltip. Salary, email and age\nbelow each carry a rule; try a salary of 500 or an age of 12.\n\n<div data-docs-demo=\"206-cell-validation\" data-height=\"460\"></div>\n\n```svelte\nconst columns: GridColumns<Person> = [\n { field: 'salary', header: 'Salary', editorType: 'number',\n validate: ({ value }) => (Number(value) < 1000 ? 'Salary must be at least $1,000' : null) },\n { field: 'email', header: 'Email', editorType: 'text',\n validate: ({ value }) => (/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(String(value)) ? null : 'Not a valid email address') },\n]\n```\n\nA rule can read the row, so a column can be checked against another:\n`({ value, row }) => (value > row.budget ? 'Over budget' : null)`.\n\n## Refuse with `rejectInvalid`\n\n`rejectInvalid: true` makes the same rule a veto. The value is checked\nafter `valueParser` has run, so the rule sees what would be stored; when\nit fails, the editor closes, the cell keeps its old value, no undo step\nis recorded and `onCellValueChange` does not fire. The red highlight\nstill shows while the value is typed, so the user sees why before Enter\nputs the old value back. Type an age of 200 or an email without an\nat-sign:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { id: number; name: string; email: string; age: number }\n\n let rows = $state<Person[]>([\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', age: 36 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', age: 45 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', age: 54 },\n ])\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 170, editorType: 'text' },\n { field: 'email', header: 'Email', width: 210, editorType: 'text', rejectInvalid: true,\n validate: ({ value }) => (/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(String(value)) ? null : 'That is not an email address') },\n { field: 'age', header: 'Age', width: 90, editorType: 'number', rejectInvalid: true,\n validate: ({ value }) => (Number(value) < 16 || Number(value) > 100 ? 'Age must be between 16 and 100' : null) },\n ]\n</script>\n\n<SvGrid data={rows} {columns} editable containerHeight={200} />\n```\n\nThe inline editor, a commit to a selected range and a full-row edit all\ngo through the check. Paste does not open an editor and is not checked;\na pasted block that must be clean is checked in `onCellValueChange`, as\nbelow.\n\n## Built-in soft validation\n\n`parseEditorValue` already does light validation before either hook runs:\n\n- `number`: rejects non-finite results and stores `null`\n- `date` / `datetime`: rejects unparseable strings and stores `null`\n\nThe cell goes blank rather than red. A `validate` rule that treats `null`\nas invalid turns that into a message.\n\n## Rules over the whole row, and a log of rejections\n\nWhen the rule needs to see the row after the edit, or the app wants a\nrecord of what was refused, check the committed value in\n`onCellValueChange` and put the old one back. The event carries the row\nobject, so the revert is one assignment. Try an age of 200 or an email\nwithout an at-sign:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { id: number; name: string; email: string; age: number }\n\n let rows = $state<Person[]>([\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', age: 36 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', age: 45 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', age: 54 },\n ])\n let rejected = $state<string[]>([])\n\n function check(row: Person): string | null {\n if (row.age < 16 || row.age > 100) return 'Age must be between 16 and 100'\n if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(row.email)) return 'That is not an email address'\n return null\n }\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 170, editorType: 'text' },\n { field: 'email', header: 'Email', width: 210, editorType: 'text' },\n { field: 'age', header: 'Age', width: 90, editorType: 'number' },\n ]\n</script>\n\n<SvGrid\n data={rows}\n {columns}\n editable\n onCellValueChange={(e) => {\n const problem = check(e.row)\n if (!problem) return\n (e.row as Record<string, unknown>)[e.columnId] = e.oldValue\n rejected = [problem, ...rejected].slice(0, 4)\n }}\n/>\n\n<ul>\n {#each rejected as r}<li>{r}</li>{/each}\n</ul>\n```\n\nThe same pattern with a rollback through `api.setCellValue`, a red flash\nand a \"Recent rejections\" panel:\n\n<div data-docs-demo=\"24-validation\" data-height=\"500\"></div>\n\n## Marking the row instead of reverting\n\nReverting is right when the value is unusable. When it is merely wrong,\nkeeping it and marking the row lets someone fix a form in the order they want\nrather than the order the grid demands.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n city: string\n age: number\n salary: number\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', city: 'Portland', age: 54, salary: 155000 },\n ]\n\n import type { ConditionalFormat } from '@svgrid/grid'\n\n let rows = $state<Person[]>(seed.map((p) => ({ ...p })))\n\n const bad = (p: Person) => p.age < 16 || p.age > 100\n\n const problems = $derived(rows.filter(bad).length)\n\n const formats = $derived<ConditionalFormat<Person>[]>([\n { type: 'rule', columns: ['age'], when: (ctx) => bad(ctx.row),\n background: '#fee2e2', color: '#991b1b' },\n ])\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180, editorType: 'text' },\n { field: 'age', header: 'Age', width: 100, editorType: 'number' },\n ]\n</script>\n\n<SvGrid data={rows} {columns} editable conditionalFormats={formats} />\n\n<p aria-live=\"polite\">{problems === 0 ? 'All ages valid.' : problems + ' row(s) out of range.'}</p>\n```\n\n<!-- tutorial:learn-4-editing -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-4-editing\" data-docs-tutorial=\"learn-4-editing\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-4-editing.mp4\" poster=\"/tutorials/learn-4-editing.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 4: editing rows, and validating them, 91 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-4-editing.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-4-editing.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 4: editing rows, and validating them</strong> (
|
|
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"
|
|
4455
4472
|
},
|
|
4456
4473
|
{
|
|
4457
4474
|
"slug": "help/errors",
|
|
@@ -4465,7 +4482,7 @@ export const docs = [
|
|
|
4465
4482
|
"path": "docs/help/export.md",
|
|
4466
4483
|
"title": "Data export and printing",
|
|
4467
4484
|
"section": "Import & export",
|
|
4468
|
-
"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"
|
|
4469
4486
|
},
|
|
4470
4487
|
{
|
|
4471
4488
|
"slug": "help/export/import-spreadsheet",
|
|
@@ -4570,14 +4587,14 @@ export const docs = [
|
|
|
4570
4587
|
"path": "docs/help/glossary.md",
|
|
4571
4588
|
"title": "Glossary",
|
|
4572
4589
|
"section": "Help",
|
|
4573
|
-
"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"
|
|
4574
4591
|
},
|
|
4575
4592
|
{
|
|
4576
4593
|
"slug": "help/grouping-aggregation",
|
|
4577
4594
|
"path": "docs/help/grouping-aggregation.md",
|
|
4578
4595
|
"title": "Grouping & aggregation",
|
|
4579
4596
|
"section": "Help",
|
|
4580
|
-
"markdown": "# Grouping & aggregation\n\nRoll rows up by one or more columns and compute aggregates (sum, avg,\ncount, min, max, custom) at each group level. Powered by\n`columnGroupingFeature` plus per-column `aggregate` config.\n\n\n\nTry it: drag a column into the group-by lane, then change aggregators\nper column:\n\n<div data-docs-demo=\"07-grouping-aggregation\" data-height=\"500\"></div>\n\n## Minimal example\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n SvGrid, tableFeatures, rowSortingFeature, columnGroupingFeature,\n type ColumnDef,\n } from '@svgrid/grid'\n\n type Employee = {\n id: number; name: string; department: string;\n salary: number; performance: number\n }\n\n const features = tableFeatures({ rowSortingFeature, columnGroupingFeature })\n\n const columns: ColumnDef<typeof features, Employee>[] = [\n { field: 'department', header: 'Department' },\n { field: 'name', header: 'Name' },\n { field: 'salary', header: 'Salary',\n aggregate: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n { field: 'performance', header: 'Performance',\n aggregate: 'avg' },\n ]\n\n const rows: Employee[] = [\n { id: 1, name: 'Ada', department: 'Engineering', salary: 180_000, performance: 4.8 },\n { id: 2, name: 'Linus', department: 'Engineering', salary: 195_000, performance: 4.6 },\n { id: 3, name: 'Grace', department: 'Operations', salary: 165_000, performance: 4.9 },\n ]\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n groupBy={['department']}\n/>\n```\n\nThe grid emits one group row per unique department value, with the\ngroup cell showing the rolled-up salary sum + average performance.\nClick the chevron on a group row to expand its children.\n\n## Setting the group-by\n\nThree ways, ranked by ergonomic order:\n\n1. **The column menu.** When the user opens a header's menu, \"Group\n by this column\" toggles that column in/out of the group-by list.\n2. **The `groupBy` prop.** Seeds the group-by list and re-applies\n whenever the prop's own value changes, so it works both as initial\n state and as a controlled value. A group-by set from the menu or the\n API is not clobbered while the prop stays put.\n3. **The imperative API.** `api.setGroupBy(['department', 'role'])`\n for toolbars / saved views.\n\n`groupBy` is ignored when `treeData` is set - a row cannot be both a\nhierarchy node and bucketed under a group banner.\n\nThe group order is significant - `['region', 'country']` rolls up\ncountry inside region; reverse the array to flip the hierarchy.\n\n## Built-in aggregators\n\n| Aggregator | Returns | Behaviour on empty groups |\n| ---------- | ---------------------------------------------------- | ------------------------- |\n| `'sum'` | Sum of numeric cell values | `0` |\n| `'avg'` | Arithmetic mean (with safe divide-by-zero) | `null` |\n| `'count'` | Number of leaf rows | `0` |\n| `'min'` | Smallest value (numeric or `Intl.Collator`-comparable) | `null` |\n| `'max'` | Largest value | `null` |\n\n`'sum'` / `'avg'` / `'min'` / `'max'` cast values to `Number`. If the\ncolumn has non-numeric values mixed in, those rows are skipped.\n\n## Custom aggregator\n\nPass a function instead of a string for any group-aware computation:\n\n```ts\n{\n field: 'orders',\n header: 'Top customer',\n aggregate: (rows) => {\n const top = rows.reduce<Employee | null>(\n (acc, r) => !acc || r.orders > acc.orders ? r : acc,\n null,\n )\n return top?.name ?? '-'\n },\n}\n```\n\nThe callback gets every leaf row in the group (already filtered).\nReturn whatever the cell should display - string, number, or a\nformatted value.\n\n## Custom group cell rendering\n\nBy default the group cell shows `key (n)` - e.g. \"Engineering (12)\".\nOverride via the column's `cell` template:\n\n```svelte\n{#snippet GroupCell(props: { row: GroupRow<Employee> })}\n <span class=\"font-semibold\">\n {props.row.groupKey}\n <span class=\"text-sm opacity-60\">({props.row.subRows.length} reports)</span>\n </span>\n{/snippet}\n```\n\nThe `row.groupKey` is the unique group value (the department name in\nthe example). `row.subRows` is the children. `row.depth` is the\nnesting level (useful for indentation when you group by multiple\ncolumns).\n\n## Aggregating string columns\n\nStrings work with `'count'`, `'min'`, `'max'`, and any custom\naggregator. For sum / avg you'll get `NaN` because the cast to\n`Number` fails - the grid renders this as `-` by default.\n\nA useful custom aggregator for strings:\n\n```ts\n{\n field: 'tags',\n aggregate: (rows) => {\n const set = new Set<string>()\n for (const r of rows) for (const t of r.tags) set.add(t)\n return Array.from(set).join(', ')\n },\n}\n```\n\n## Performance\n\nAggregation runs once per group-by change, NOT per scroll frame. The\ncost is O(n) for `count` / `sum` / `avg`, O(n log n) for `min` / `max`\nbecause the engine sorts to find the extreme.\n\nFor a 100k-row dataset grouped by two columns with three aggregators,\nthe pipeline adds ~36 ms to the initial paint (see\n[Performance benchmarks](./benchmarks.md)). After that, scroll is\nunaffected - the renderer hands each visible group its precomputed\nvalue.\n\n## Group expansion state\n\n`expanded` is owned by the engine by default; you can hoist it for\nsaved-views purposes:\n\n```svelte\n<SvGrid\n ...\n expanded={controlledExpanded}\n onExpandedChange={(next) => (controlledExpanded = next)}\n/>\n```\n\n`onExpandedChange` fires for every path that changes expansion: a click\non a group banner, `api.setRowExpanded()`, and\n`api.expandAllGroups()` / `api.collapseAllGroups()`. It receives the\nfull next map, so writing it straight back into `expanded` (as above) is\nsafe and will not loop.\n\nThe shape is `Record<rowId, boolean>`. Group row ids are built from the\ngrouping path rather than the display label - grouping by `department`\ngives `group_department_Engineering`, and adding `role` beneath it gives\n`group_department_Engineering_role_Senior`. Tree rows key off the\nengine's row id instead, so set `getRowId` if you want those keys to be\nyour own ids. Capture the map from `onExpandedChange` rather than\nhand-building the keys.\n\n## Sticky group rows\n\n`stickyGroupRows` keeps the banner of the group a row belongs to (and the\nbanners of the groups above it) under the header while the group's rows\nscroll past, so a screen deep inside a large group still says where it\nis. Under virtualization the band shows a copy of each ancestor banner,\nclickable like the original; without virtualization the banners\nthemselves stick. Off by default; the same prop serves tree data and the\n[server-side row model](./server/server-grouping.md#sticky-group-rows).\n\n## Group sort vs leaf sort\n\nThe sort UI sorts within the active sort scope:\n\n- When grouping is OFF, sort applies to all rows.\n- When grouping is ON, sort applies WITHIN each group - groups stay\n in alphabetical (or group-aggregator) order; only the leaves inside\n each group reorder.\n\nTo sort the groups themselves by their rolled-up value, set the sort\non the aggregated column AFTER setting the group-by. The grid\nrecognises that the column is aggregated and sorts the group rows\ninstead of the leaves.\n\n## Filtering vs grouping\n\nFilters run BEFORE grouping (see [Architecture](./architecture.md) for\nthe pipeline order). The aggregator only sees rows that passed the\nfilter. This is what makes \"department salary sum, filtered to active\nemployees only\" work without any extra config.\n\n## Pivot vs group-by\n\nWhen the question is \"group by row dimensions, also group by column\ndimensions, also pick aggregators per measure\" - that's a pivot. The\n[pivot helpers](./pivot.md) build a different data structure\noptimised for that shape. Use group-by when you only roll up rows;\nuse pivot when you also roll up columns.\n\n## Display modes\n\n`groupDisplayMode` decides where group state is drawn:\n\n| Mode | Result |\n| --- | --- |\n| `groupRows` (default) | A full-width banner row per group. Unchanged behaviour. |\n| `singleColumn` | One synthetic **Group** column holding every level, indented by depth. |\n| `multipleColumns` | One synthetic column per grouped field. |\n\n```svelte\n<SvGrid {data} {columns} groupable groupDisplayMode=\"singleColumn\" />\n```\n\nBoth column modes hide the grouped **source** columns, because their values\nmove into the auto column - showing both would just duplicate them. They also\nrender the group row as an ordinary row, which is the real reason to use them:\nits aggregate cells then line up under the columns they belong to instead of\nsitting in a full-width strip.\n\nTune the combined column with `autoGroupColumnHeader` (default `\"Group\"`) and\n`autoGroupColumnWidth` (default `220`). In `multipleColumns` each column takes\nits name from the source column's header.\n\n## Group footers (subtotal rows)\n\n`groupFooters` closes each group with a subtotal row:\n\n```svelte\n<SvGrid {data} {columns} groupable groupFooters />\n```\n\nThe footer is a clone of the group banner, so it already carries that group's\naggregates and renders through the normal cell path - each total lands under\nits own column instead of in a full-width strip. It is not expandable and has\nno expander.\n\nOnly columns with an `aggregate` produce a value, the same ones that populate\nthe banner.\n\n## Grand total row\n\n`grandTotalRow` appends a single totals row for the whole filtered set:\n\n```svelte\n<SvGrid {data} {columns} grandTotalRow />\n```\n\nIt is independent of `groupFooters` - use it on a flat grid for a bottom totals\nline, or together for subtotals *and* a total:\n\n```svelte\n<SvGrid {data} {columns} groupable groupFooters grandTotalRow />\n```\n\nThree things to know:\n\n- It aggregates the **leaf** rows, so turning grouping on does not double-count\n (the group banners already carry subtotals).\n- It follows the **filtered** set, not the raw data - filter the grid and the\n total moves with it.\n- With `pageable`, it is appended only on the **last** page, so a total never\n appears mid-dataset. The value still covers every row, not just that page.\n\nColumns without an `aggregate` render blank, and if no column declares one the\nrow is skipped entirely.\n\n## Grouping with pagination\n\n`pageSize` budgets **data** rows. Group banners and footers do not count\nagainst it:\n\n```svelte\n<SvGrid {data} {columns} groupable groupFooters pageable pageSize={10} />\n```\n\nA page holds `pageSize` real rows and reprints the banners those rows sit\nunder, so a group split across a page boundary is labelled on both pages - the\nway a spreadsheet repeats group headers across a page break. Footers are\ninserted after paging, so switching them on never pushes data onto the next\npage.\n\nA collapsed group is the visible unit and takes one page slot itself; an\nexpanded one is a header and takes none.\n\n## Frequently asked questions\n\n### How do I group rows in SvGrid?\n\nRegister `columnGroupingFeature` and group by one or more columns. Each group\nrenders a collapsible header row, and you attach an `aggregate` per column to\ncompute sum, avg, count, min, max, or a custom reducer at every group level.\n\n### What aggregation functions does SvGrid support?\n\nBuilt-in `sum`, `avg`, `count`, `min`, and `max`, plus custom aggregators -\nany function that reduces a group's rows to a single value. Aggregates compute\nat each group level and at the grand-total footer.\n\n### Is grouping the same as a pivot table?\n\nNo. Grouping rolls rows up along the row axis. A pivot table also spreads a\nfield across the column axis with nested headers - that is the `@svgrid/enterprise`\npivot model. See [Pivot tables](./pivot.md) for the column-axis version.\n\n## More examples\n\n### Group display modes + footers\n\nSwitch between groupRows banners, a single combined Group column, and one column per grouped field. groupFooters closes each group with a subtotal row under the real columns. Paging counts DATA rows, so pageSize means what it says: a page reprints the banners its rows sit under, and footers never eat the budget.\n\n<div data-docs-demo=\"427-group-footers\" data-height=\"460\"></div>\n\n\n### Group panel (drag & drop)\n\nDevExpress / Kendo-style Group Panel: drag chips into the panel to group, drag inside to reorder grouping levels, × to ungroup. Drives api.setGroupBy() under the hood.\n\n<div data-docs-demo=\"89-group-panel\" data-height=\"460\"></div>\n\n## Try it\n\nGrouping is a prop, and the aggregate shown on each group header comes from the\ncolumn. Add `summary` and the same reducers total the whole filtered set in the\nfooter.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const agg: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80, aggregate: 'avg', summary: 'avg' },\n { field: 'salary', header: 'Salary', width: 130, aggregate: 'sum', summary: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={people} columns={agg} groupBy={['department']} groupable summary sortable />\n```\n\n## Two levels deep\n\n`groupBy` is an array, and the order is the nesting order. Every level gets its\nown aggregate row, computed over the leaf rows beneath it rather than over the\nlevel above.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const nested: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'age', header: 'Age', width: 80, aggregate: 'avg' },\n { field: 'salary', header: 'Salary', width: 130, aggregate: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={people} columns={nested} groupBy={['department', 'city']} groupable summary />\n```\n\n<!-- tutorial:learn-6-grouping -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-6-grouping\" data-docs-tutorial=\"learn-6-grouping\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-6-grouping.mp4\" poster=\"/tutorials/learn-6-grouping.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 6: grouping and totals, 94 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-6-grouping.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-6-grouping.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 6: grouping and totals</strong> (94 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Once a table has more than a screenful of rows, people stop reading it and start asking it questions. Grouping and totals are how it answers.</p>\n<p>The same people as before, with a department and a city to group by, and a salary worth adding up.</p>\n<p>Two options do the work here. Aggregate says what a group header shows for that column: an average age, a summed salary. Summary does the same thing for the whole table at the bottom. Both read the raw value, so a formatted currency still adds up correctly.</p>\n<p>Then groupBy on the component names the starting grouping, and groupable lets the user change it later.</p>\n<p>Read it back whole: the same people, one column list with the aggregate and summary options on it, and groupBy on the component. That is everything this needs.</p>\n<p>Every department is a group header carrying its own totals, and the row at the bottom totals the table. Open one and the people inside are ordinary rows: still sortable, still editable, still yours.</p>\n<p>Grouping is part of the row model rather than a display trick, which is why the same thing works over server data: the server returns the groups and the grid asks for a group's rows only when someone opens it. Lesson seven makes all of this look like your product.</p>\n<p>The file is on the page below. See you in lesson seven.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-6-grouping -->\n\n## See also\n\n- [Architecture overview](./architecture.md) - where grouping sits in\n the pipeline.\n- [Pivot tables](./pivot.md) - the column-axis version.\n- [Row pagination](./rows/row-pagination.md) - the paging stage runs\n AFTER grouping, so group rows count toward the page size.\n- [Demo #07 Grouping + aggregation](https://svgrid.com/demos/07-grouping-aggregation/)\n - the source for the example above.\n"
|
|
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"
|
|
4581
4598
|
},
|
|
4582
4599
|
{
|
|
4583
4600
|
"slug": "help/grouping/aggregators",
|
|
@@ -4591,42 +4608,42 @@ export const docs = [
|
|
|
4591
4608
|
"path": "docs/help/headless/build-a-table.md",
|
|
4592
4609
|
"title": "Build a table from scratch",
|
|
4593
4610
|
"section": "Headless",
|
|
4594
|
-
"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"
|
|
4595
4612
|
},
|
|
4596
4613
|
{
|
|
4597
4614
|
"slug": "help/headless/controlled-state",
|
|
4598
4615
|
"path": "docs/help/headless/controlled-state.md",
|
|
4599
4616
|
"title": "Controlled state",
|
|
4600
4617
|
"section": "Headless",
|
|
4601
|
-
"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"
|
|
4602
4619
|
},
|
|
4603
4620
|
{
|
|
4604
4621
|
"slug": "help/headless/overview",
|
|
4605
4622
|
"path": "docs/help/headless/overview.md",
|
|
4606
4623
|
"title": "Headless overview",
|
|
4607
4624
|
"section": "Headless",
|
|
4608
|
-
"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"
|
|
4609
4626
|
},
|
|
4610
4627
|
{
|
|
4611
4628
|
"slug": "help/headless/row-models",
|
|
4612
4629
|
"path": "docs/help/headless/row-models.md",
|
|
4613
4630
|
"title": "Row models",
|
|
4614
4631
|
"section": "Headless",
|
|
4615
|
-
"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"
|
|
4616
4633
|
},
|
|
4617
4634
|
{
|
|
4618
4635
|
"slug": "help/headless/server-side",
|
|
4619
4636
|
"path": "docs/help/headless/server-side.md",
|
|
4620
4637
|
"title": "Server-side data (load on demand)",
|
|
4621
4638
|
"section": "Headless",
|
|
4622
|
-
"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"
|
|
4623
4640
|
},
|
|
4624
4641
|
{
|
|
4625
4642
|
"slug": "help/headless/styling",
|
|
4626
4643
|
"path": "docs/help/headless/styling.md",
|
|
4627
4644
|
"title": "Styling a headless table",
|
|
4628
4645
|
"section": "Headless",
|
|
4629
|
-
"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"
|
|
4630
4647
|
},
|
|
4631
4648
|
{
|
|
4632
4649
|
"slug": "help/headless/virtualization",
|
|
@@ -4682,7 +4699,7 @@ export const docs = [
|
|
|
4682
4699
|
"path": "docs/help/mcp-server.md",
|
|
4683
4700
|
"title": "MCP server",
|
|
4684
4701
|
"section": "Help",
|
|
4685
|
-
"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"
|
|
4686
4703
|
},
|
|
4687
4704
|
{
|
|
4688
4705
|
"slug": "help/migrating-from-ag-grid",
|
|
@@ -4717,7 +4734,7 @@ export const docs = [
|
|
|
4717
4734
|
"path": "docs/help/migrating-from-handsontable.md",
|
|
4718
4735
|
"title": "Migrating from Handsontable",
|
|
4719
4736
|
"section": "Help",
|
|
4720
|
-
"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"
|
|
4721
4738
|
},
|
|
4722
4739
|
{
|
|
4723
4740
|
"slug": "help/migrating-from-jqxgrid",
|
|
@@ -4969,7 +4986,7 @@ export const docs = [
|
|
|
4969
4986
|
"path": "docs/help/rows/selection-bar.md",
|
|
4970
4987
|
"title": "Bulk-action bar",
|
|
4971
4988
|
"section": "Rows",
|
|
4972
|
-
"markdown": "# Bulk-action bar\n\nTick some rows and a bar floats over the grid holding the count, the actions\nthat apply to the whole selection, and a way to clear it - the pattern an issue\ntracker uses for bulk edit.\n\n<div data-docs-demo=\"430-selection-bar\" data-height=\"560\"></div>\n\nEvery example below runs against this setup. The bar *renderer* ships in\n`@svgrid/enterprise`, so `enableSelectionBar()` is what lights it up - the\n`selectionBar` prop and its types are part of the free grid. Without the\nrenderer the grid shows a note in the bar's place saying it is an Enterprise\nfeature and pointing at [licensing](https://svgrid.com/pricing/).\n\nLike the rest of Enterprise this comes with the 30-day evaluation period:\nwith the package installed the bar works whether or not a license key is\nset, and without one the grid displays the watermark and the console\nmessage about the Enterprise package. Set a key with `setLicenseKey()`\nbefore shipping.\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SelectionBarAction } from '@svgrid/grid'\n import { enableSelectionBar } from '@svgrid/enterprise'\n\n enableSelectionBar()\n\n type Task = {\n id: number\n key: string\n title: string\n assignee: string\n status: 'Open' | 'In progress' | 'Done'\n }\n\n const tasks: Task[] = [\n { id: 1, key: 'PLAT-412', title: 'Virtualize the audit log', assignee: 'A. Osei', status: 'Open' },\n { id: 2, key: 'PLAT-413', title: 'Retry webhook delivery', assignee: 'R. Vance', status: 'In progress' },\n { id: 3, key: 'PLAT-414', title: 'Cache the tenant lookup', assignee: 'M. Iqbal', status: 'Done' },\n { id: 4, key: 'PLAT-415', title: 'Fix timezone drift', assignee: 'J. Lindqvist', status: 'Open' },\n { id: 5, key: 'PLAT-416', title: 'Batch the settings writes', assignee: 'D. Okonkwo', status: 'Done' },\n ]\n\n const columns: GridColumns<Task> = [\n { field: 'key', header: 'Key', width: 110 },\n { field: 'title', header: 'Summary', width: 240 },\n { field: 'assignee', header: 'Assignee', width: 150 },\n { field: 'status', header: 'Status', width: 130 },\n ]\n</script>\n```\n\n## The smallest version\n\n`selectionBar` on its own gives the count and a clear button. Nothing else is\nrequired - `showRowSelection` puts the checkboxes there, and the bar reacts to\nwhatever ends up selected:\n\n```svelte {runnable}\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar />\n```\n\n## Adding actions\n\nAn array is the shorthand for `{ actions }`. Each action gets the whole\nselection as `{ rows, ids }`, in **display order** - so it reads the same order\nthe user is looking at, even after a sort:\n\n```svelte {runnable}\n<script lang=\"ts\">\n let log = $state<string[]>([])\n\n const actions: SelectionBarAction<Task>[] = [\n {\n key: 'done',\n label: 'Mark done',\n icon: 'M20 6 9 17l-5-5',\n action: ({ ids }) => (log = [`Marked ${ids.length} done`, ...log]),\n },\n {\n key: 'delete',\n label: 'Delete',\n icon: 'M3 6h18M8 6V4h8v2m-1 0v14H9V6',\n danger: true,\n action: ({ ids }) => (log = [`Deleted ${ids.join(', ')}`, ...log]),\n },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={actions} />\n\n<ul>\n {#each log as line, i (i)}<li>{line}</li>{/each}\n</ul>\n```\n\n`icon` is the `d` of a single 24x24 SVG path, so an action array stays plain\ndata you can lift into its own module next to the columns.\n\n## Built-in actions\n\nStrings in the array are built-ins, so the common buttons need no wiring:\n\n| Key | Does |\n| --- | --- |\n| `'selectAll'` | Selects every row in the view, then disables itself |\n| `'editFields'` | Opens the bulk-edit drawer - see below |\n| `'separator'` | Draws a divider. Not counted against `maxVisible` |\n\nMix them with your own actions in one array:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const bar: Array<SelectionBarAction<Task> | 'selectAll' | 'editFields' | 'separator'> = [\n 'selectAll',\n 'editFields',\n 'separator',\n { key: 'watch', label: 'Watch', action: () => {} },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={bar} />\n```\n\n## Bulk edit\n\n`'editFields'` opens a **drawer** - the same `SvDrawer` + `SvForm` the grid\nuses for single-row editing - holding one control per editable column. Change\nas many fields as you like and they apply to **every selected row**, as a\nsingle undo run.\n\nThe rule that makes that safe: **only fields you actually change are written.**\n\n| The field | Opens showing | Leave it alone and |\n| --- | --- | --- |\n| Every selected row agrees | That shared value | Nothing is written |\n| They disagree | Blank, labelled \"Multiple values\" | Each row keeps its own value |\n\nSo editing Status across twelve issues does not flatten their summaries to\nwhichever one happened to be first.\n\nThe field list is the grid's own editable, field-backed columns. A column with\n`editable: false` is not offered, because writing it in bulk would be a\nfootgun, and `editorOptions` carry over - a status column with a fixed set\nbecomes a picker in the drawer rather than a free-text box.\n\nWhat it does per cell:\n\n- skips rows whose per-row `editable` rule refuses the write,\n- skips cells already holding the value, so they do not become empty undo steps,\n- fires `onCellValueChange` for every cell it does change,\n- appends the whole run's undo steps together, so `Ctrl+Z` walks the edit back\n rather than needing one press per cell.\n\nThe engine is exported if you want the same write from your own UI:\n\n```ts\nimport { applyBulkEdit, bulkEditableFields, bulkEditInitialValues } from '@svgrid/enterprise'\n\nconst fields = bulkEditableFields(ctrl) // what can be targeted\nconst { values, mixed } = bulkEditInitialValues(ctrl, fields) // what to show\nconst { changed, skipped, fields: n } = applyBulkEdit(ctrl, { status: 'Done', points: 8 })\n```\n\n## On a server-side row model\n\nUnder `<SvGrid rowModel={ctl}>` with the Enterprise row model's selection\nturned on, the bar reads the model instead of the loaded rows: the count\nchip says what the rule selects - a million under select-all, even with\ntwenty rows on screen - and **Edit fields** sends the patch and the rule\nto the datasource's `updateWhere` as one request, rather than writing the\nloaded cells. See [Server selection](../server/server-selection.md).\n\n## Where it floats\n\n`position` pins the bar to the `'bottom'` (default) or `'top'` edge of the\ngrid.\n\nThe bar **floats** - it never resizes the grid, so a layout holding one does\nnot jump when a checkbox is ticked. To stop it hiding anything, the scroll area\ngains bottom padding the height of the bar while a selection is live: the last\nrow can always be scrolled clear, and the bar overlays that padding rather than\na row.\n\n```svelte {runnable}\n<SvGrid\n data={tasks}\n {columns}\n getRowId={(t) => String(t.id)}\n showRowSelection\n selectionBar={{ position: 'top', actions: [{ key: 'x', label: 'Export', action: () => {} }] }}\n/>\n```\n\n## Buttons that come and go\n\n`hidden` and `disabled` are re-checked on every render against the live\nselection, so buttons appear and grey out as the set changes rather than\nfreezing at first paint. Select one row and **Merge** is not there; select two\nand it appears:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const conditional: SelectionBarAction<Task>[] = [\n {\n key: 'merge',\n label: 'Merge',\n // Merging one task into itself is meaningless.\n hidden: ({ ids }) => ids.length < 2,\n action: () => {},\n },\n {\n key: 'close',\n label: 'Close',\n // Nothing to do when everything picked is already closed.\n disabled: ({ rows }) => rows.every((t) => t.status === 'Done'),\n action: () => {},\n },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={conditional} />\n```\n\n## More actions than fit\n\nPast `maxVisible` (6 by default) the tail folds into a `···` menu, which opens\n**away** from the edge the bar is pinned to - upward from a bottom bar, downward\nfrom a top one - so it never falls off the grid. A bar wide enough to scroll is\na bar whose last button nobody finds:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const many: SelectionBarAction<Task>[] = [\n 'Mark done', 'Assign', 'Watch', 'Export', 'Move', 'Archive',\n ].map((label) => ({ key: label, label, action: () => {} }))\n</script>\n\n<SvGrid\n data={tasks}\n {columns}\n getRowId={(t) => String(t.id)}\n showRowSelection\n selectionBar={{ actions: many, maxVisible: 3 }}\n/>\n```\n\n## Keyboard and screen readers\n\nThe bar is a `role=\"toolbar\"`, which is a promise about how it behaves, so it\nkeeps that promise:\n\n| Key | Does |\n| --- | --- |\n| `Tab` | One stop for the whole bar, not one per button |\n| `Left` / `Right` | Move between buttons, wrapping at each end |\n| `Esc` | Closes the overflow menu if it is open, otherwise clears the selection |\n\nThese are handled on the bar, so they apply while focus is inside it. `Esc`\npressed in the grid body still does whatever the grid does with it - the bar\ndoes not install a global key handler that would fight cell editing or an open\nmenu.\n\nThe count carries `aria-live=\"polite\"` so it is read as the selection changes.\nThe bar itself is not live - that would re-read every button on each tick.\n\n## Options\n\n| Option | Default | Does |\n| --- | --- | --- |\n| `actions` | `[]` | The buttons, left to right |\n| `position` | `'bottom'` | Which edge the bar floats against |\n| `maxVisible` | `6` | How many stay on the bar before the rest collapse. Separators do not count |\n| `hideClear` | `false` | Drop the trailing clear button |\n\n`selectionBar={true}` is shorthand for all defaults with no actions, and\n`selectionBar={[...]}` for `{ actions: [...] }`.\n\n## Related\n\nDemo `23-bulk-actions` builds the same idea by hand - a toolbar of your own\nmarkup driven by `onRowSelectionChange`. Reach for `selectionBar` when you want\nit working out of the box; hand-roll it when you want full control of the\nlayout.\n\n<!-- tutorial:learn-5-selection -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-5-selection\" data-docs-tutorial=\"learn-5-selection\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-5-selection.mp4\" poster=\"/tutorials/learn-5-selection.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 5: selection and bulk actions, 86 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-5-selection.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-5-selection.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 5: selection and bulk actions</strong> (86 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Selecting rows is easy. Knowing which rows are selected after a sort, a filter or a refresh is the part that catches people out, and it comes down to one prop.</p>\n<p>A small backlog of tasks and the columns to show them.</p>\n<p>Three things on the component. showRowSelection adds the checkbox column. selectionBar floats the action bar. And getRowId is the one that matters: it tells the grid what identifies a row, so a selection survives sorting, filtering and new data arriving. Without it the grid falls back to position, and position changes.</p>\n<p>Tick a couple of rows and the bar appears with a live count and the actions you defined. It stays out of the way until there is something to act on.</p>\n<p>Now sort the table while those rows are ticked. The same three stay selected, because the grid is tracking ids rather than row numbers.</p>\n<p>Over server data the same idea scales: select-all becomes a rule, every row except these, rather than a list of ids the browser would have to hold. Lesson six groups the rows and adds totals.</p>\n<p>The file is on the page below. See you in lesson six.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-5-selection -->\n\n## See also\n\n- [Row selection](../accessibility.md)\n- [Server selection](../server/server-selection.md) - the bar over a million rows the grid never loaded\n- [Kanban board mode](./kanban-board.md)\n- [Scheduler](./scheduler.md)\n"
|
|
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"
|
|
4973
4990
|
},
|
|
4974
4991
|
{
|
|
4975
4992
|
"slug": "help/rows/styling-rows",
|
|
@@ -5060,7 +5077,7 @@ export const docs = [
|
|
|
5060
5077
|
"path": "docs/help/server/server-grouping.md",
|
|
5061
5078
|
"title": "Server grouping - Enterprise",
|
|
5062
5079
|
"section": "Server data",
|
|
5063
|
-
"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"
|
|
5064
5081
|
},
|
|
5065
5082
|
{
|
|
5066
5083
|
"slug": "help/server/server-infinite-scroll",
|
|
@@ -5088,7 +5105,7 @@ export const docs = [
|
|
|
5088
5105
|
"path": "docs/help/server/server-row-model.md",
|
|
5089
5106
|
"title": "Server-Side Row Model (SSRM)",
|
|
5090
5107
|
"section": "Server data",
|
|
5091
|
-
"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"
|
|
5092
5109
|
},
|
|
5093
5110
|
{
|
|
5094
5111
|
"slug": "help/server/server-selection",
|
|
@@ -5118,6 +5135,13 @@ export const docs = [
|
|
|
5118
5135
|
"section": "Server data",
|
|
5119
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"
|
|
5120
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
|
+
},
|
|
5121
5145
|
{
|
|
5122
5146
|
"slug": "help/shadcn",
|
|
5123
5147
|
"path": "docs/help/shadcn.md",
|
|
@@ -5172,7 +5196,7 @@ export const docs = [
|
|
|
5172
5196
|
"path": "docs/help/sheet/start.md",
|
|
5173
5197
|
"title": "Spreadsheet: getting started",
|
|
5174
5198
|
"section": "Spreadsheet",
|
|
5175
|
-
"markdown": "\n# Spreadsheet: getting started\n\nThe one component a spreadsheet needs, how it takes data, how a formula\nreaches another sheet, and the document underneath that a save and a\nreload go through. The other spreadsheet tutorials assume this one, and\n[The spreadsheet shell](../cells/spreadsheet-shell.md) is the reference\nbehind all of them: every prop, every dialog, the file formats, the\nlimits.\n\n`SvSheet` is a spreadsheet, not a mode of the grid: it owns a workbook of\nformulas, address-keyed formats, comments and rules, and draws it with\n`<SvGrid>` underneath. It ships in `@svgrid/enterprise` on top of\n`@svgrid/grid`; `jszip` is a peer you add only when a file is saved or\nopened as `.xlsx` or `.ods`.\n\n```bash\nnpm install @svgrid/grid @svgrid/enterprise\n```\n\n<!-- tutorial:install-spreadsheet -->\n<figure class=\"docs-tutorial docs-tutorial-player\" id=\"tutorial-install-spreadsheet\" data-docs-tutorial=\"install-spreadsheet\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/install-spreadsheet.mp4\" poster=\"/tutorials/install-spreadsheet.poster.webp\" width=\"960\" height=\"540\" controls playsinline preload=\"none\" aria-label=\"Install the SvGrid spreadsheet, 53 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/install-spreadsheet.vtt\">Your browser does not play embedded video. <a href=\"/tutorials/install-spreadsheet.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Install the SvGrid spreadsheet</strong> (53 s, with narration).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>The spreadsheet ships in the enterprise package. Install it next to the grid; it runs unlicensed for evaluation.</p>\n<p>A workbook is plain data: sheets with a name and a cells matrix. Formulas are strings that start with an equals sign.</p>\n<p>Hand it to SvSheet. That is the whole API.</p>\n<p>The result is the full surface: a ribbon, the name box and formula bar, lettered columns, sheet tabs and a status bar, with the totals already computed.</p>\n<p>It behaves like a spreadsheet. Type a formula into a cell, press Enter, and the value appears while the formula bar keeps the source.</p>\n<p>Every Excel shortcut, fill handle, number formats, conditional formatting and xlsx import and export come with it. The docs list the whole surface.</p>\n</details>\n</figure>\n<!-- /tutorial:install-spreadsheet -->\n\nThe examples on this page share one setup: a small budget as raw text,\nand the lookup that formats a rectangle of cells by row and column\nindex.\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvSheet, createWorkbook, createSheetDocument, sheetCellsFromRows } from '@svgrid/enterprise'\n\n // Every cell is the text the user would have typed: a number as its\n // digits, a formula starting with `=`, an empty string for nothing.\n const budget: string[][] = [\n ['Line', 'Jan', 'Feb', 'Mar', 'Q1'],\n ['Rent', '2400', '2400', '2400', '=SUM(B2:D2)'],\n ['Payroll', '18500', '18500', '19200', '=SUM(B3:D3)'],\n ['Marketing', '3100', '4800', '2600', '=SUM(B4:D4)'],\n ['Travel', '900', '1350', '400', '=SUM(B5:D5)'],\n ['Total', '=SUM(B2:B5)', '=SUM(C2:C5)', '=SUM(D2:D5)', '=SUM(E2:E5)'],\n ]\n\n // formats.set takes rectangles as [minRow, minCol, maxRow, maxCol],\n // 0-based, and this lookup turns an index into the key the store uses.\n const at = { rowIdAt: (i: number) => `r${i}`, columnIdAt: (i: number) => String.fromCharCode(65 + i) }\n</script>\n```\n\n## The minimum\n\nNo props at all is a working spreadsheet: fifty rows, twelve columns, the\nribbon, the formula bar, the sheet tabs and the status bar. Click a cell\nand type a number, or `=SUM(A1:A5)`, and the Excel keyboard is already\nlive: `Ctrl+Arrow` to the edge of a block, `Ctrl+D` and `Ctrl+R` to fill\ndown and right, `Ctrl+;` for today, `Alt+=` for AutoSum, `Ctrl+Z` for a\nwhole action rather than one cell.\n\n```svelte {runnable}\n<SvSheet />\n```\n\n<div data-docs-demo=\"207-blank-sheet\" data-height=\"520\"></div>\n\n## Seed it with data\n\n`data` takes the sheets to start with, each a name and its cells as raw\ntext, row by row. A ragged row is fine; a missing cell reads as empty.\nNumbers are digits, dates are `yyyy-mm-dd`, and a formula is the text\nthat starts with `=`, exactly as the user would have typed it - the\nsheet works the values out.\n\n```svelte {runnable}\n<SvSheet data={[{ name: 'Budget', cells: budget }]} rows={12} columns={6} />\n```\n\nChange any month and the row's Q1 and the Total row recompute. What the\nsheet keeps is the text, so `getState()` (below) hands back `=SUM(B2:D2)`,\nnot `7200`.\n\n## A formula that reads another sheet\n\nA workbook with more than one sheet is `createWorkbook`, which takes the\nsame list `data` does and gives you the object the formulas run through.\nA reference qualifies the sheet the way Excel writes it: `Prices!B2`, or\n`'Price list'!B2` when the name has a space. `$` pins a row or a column\nwhen the formula is filled down.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const wb = createWorkbook([\n { name: 'Orders', cells: [\n ['Item', 'Qty', 'Unit', 'Amount'],\n ['Desk', '4', '=XLOOKUP(A2,Prices!$A$2:$A$4,Prices!$B$2:$B$4)', '=B2*C2'],\n ['Chair', '12', '=XLOOKUP(A3,Prices!$A$2:$A$4,Prices!$B$2:$B$4)', '=B3*C3'],\n ['Lamp', '6', '=XLOOKUP(A4,Prices!$A$2:$A$4,Prices!$B$2:$B$4)', '=B4*C4'],\n ['Total', '', '', '=SUM(D2:D4)'],\n ] },\n { name: 'Prices', cells: [\n ['Item', 'Unit price'],\n ['Desk', '320'],\n ['Chair', '85'],\n ['Lamp', '40'],\n ] },\n ])\n</script>\n\n<SvSheet workbook={wb} rows={10} columns={6} />\n```\n\nSwitch to the Prices tab, change a price, and switch back: the amounts\nfollowed. `Ctrl+PageUp` and `Ctrl+PageDown` move between sheets from the\nkeyboard, and `Shift+F11` adds one. [Workbooks](../cells/workbooks.md) is\nthe page on sheets that read each other; [Formulas](./formulas.md) is the\npage on what a formula can say.\n\n## A look it opens with\n\nA sheet a person types into can be formatted by hand from the ribbon. A\nsheet you ship should open formatted, or it is a grid of raw numbers\nuntil someone tidies it. `formats` takes cell formats keyed by address\n(`'Orders!F2'` reaches another sheet), and `columnWidths` widths keyed by\nletter:\n\n```svelte {runnable}\n<SvSheet\n data={[{ name: 'Budget', cells: budget }]}\n rows={12}\n columns={6}\n columnWidths={{ A: 130 }}\n formats={{\n A1: { bold: true, fill: '#e2e8f0' }, B1: { bold: true, fill: '#e2e8f0' }, C1: { bold: true, fill: '#e2e8f0' }, D1: { bold: true, fill: '#e2e8f0' }, E1: { bold: true, fill: '#e2e8f0' },\n B2: { numFmt: '#,##0' }, C2: { numFmt: '#,##0' }, D2: { numFmt: '#,##0' }, E2: { numFmt: '#,##0' },\n B3: { numFmt: '#,##0' }, C3: { numFmt: '#,##0' }, D3: { numFmt: '#,##0' }, E3: { numFmt: '#,##0' },\n B4: { numFmt: '#,##0' }, C4: { numFmt: '#,##0' }, D4: { numFmt: '#,##0' }, E4: { numFmt: '#,##0' },\n B5: { numFmt: '#,##0' }, C5: { numFmt: '#,##0' }, D5: { numFmt: '#,##0' }, E5: { numFmt: '#,##0' },\n A6: { bold: true }, B6: { bold: true, numFmt: '#,##0' }, C6: { bold: true, numFmt: '#,##0' }, D6: { bold: true, numFmt: '#,##0' }, E6: { bold: true, numFmt: '#,##0' },\n }}\n/>\n```\n\nOne entry per address gets long by row six, which is what the document\nbelow is for. [Formatting](./formatting.md) covers number formats,\nborders, merged headers and the rules that colour a cell by its value.\n\n## The document\n\nEverything the shell shows beyond the cells lives in a sheet document:\nper sheet its formats, column widths and row heights, hidden lines,\nfrozen panes, comments, protection, page setup, charts, validation and\nconditional formatting rules. `createSheetDocument` builds one around a\nworkbook, `doc.get(name)` is a sheet's part of it, and the `document`\nprop hands the whole thing to the component. `formats.set` formats a\nrectangle at a time, which is how a header row or a money column is one\nline:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const wb = createWorkbook([{ name: 'Budget', cells: budget }])\n const doc = createSheetDocument({ workbook: wb })\n const sheet = doc.get('Budget')\n sheet.formats.set([[0, 0, 0, 4]], { bold: true, fill: '#e2e8f0', color: '#0f172a' }, at)\n sheet.formats.set([[1, 1, 5, 4]], { numFmt: '#,##0' }, at)\n sheet.formats.set([[5, 0, 5, 4]], { bold: true, border: { top: { width: 1 } } }, at)\n sheet.widths.A = 130\n sheet.freeze = { rows: 1, cols: 1 }\n</script>\n\n<SvSheet document={doc} rows={12} columns={6} />\n```\n\nThe header stays put when the sheet scrolls, because `freeze` is part of\nthe document too. So is what the user does next: a format from the\nribbon, a widened column, a new comment all land in `doc`, and\n`doc.getState()` is the whole thing as plain JSON. `onChange` fires once\nper tick with the reasons since the last call, so an autosave is one\nhandler:\n\n```svelte\n<SvSheet document={doc} onChange={(reasons) => save(doc.getState())} />\n```\n\n[Files](./files.md) takes it from there: what the state holds, restoring\nit, `.xlsx` in and out, and the three things a network makes you think\nabout.\n\n## Rows into a sheet\n\nA grid's rows, or an API's, become a sheet with `sheetCellsFromRows`: a\nheader row of labels, a row per record with each value as the text the\nengine reads, and with `totals` a `=SUM` under every column that held a\nnumber. It is how a dataset turns into something the user can add a\nformula to.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const orders = [\n { item: 'Desk', qty: 4, price: 320, shipped: true },\n { item: 'Chair', qty: 12, price: 85, shipped: false },\n { item: 'Lamp', qty: 6, price: 40, shipped: true },\n ]\n const cells = sheetCellsFromRows(orders, [{ field: 'item', label: 'Item' }, 'qty', { field: 'price', label: 'Unit price' }, 'shipped'], { totals: true })\n</script>\n\n<SvSheet data={[{ name: 'Orders', cells }]} rows={10} columns={6} />\n```\n\nA boolean lands as `TRUE` or `FALSE`, a `Date` as `yyyy-mm-dd`, a null as\nnothing.\n\n## The ribbon is Excel's\n\nFile, Home, Insert, Page Layout, Formulas, Data, Review and View, with\nExcel's buttons under them and Excel's shortcuts beside them: `Ctrl+B` and the\nBold button call the same function, and the button lights up because the\ncell is bold, not because it was clicked. Every action reaches an\n`onAction` handler first, so an app can take one over (its own file\npicker on Open, its own dialog on Goal Seek) by returning `true`.\n\n<div data-docs-demo=\"27-spreadsheet-ribbon\" data-height=\"560\"></div>\n\nIn a narrow frame, this page's examples included, a ribbon group that\ndoes not fit folds into one button named for the group, so Home > Cells\n> Format is a click deeper than on a full-width sheet. The `look` prop\npaints the shell with the app's `--sg-*` tokens (`'theme'`, the default)\nor pins Excel's own palette and geometry (`'excel'`). `showRibbon`, `showFormulaBar`, `showTabs` and\n`showStatusBar` hide any part of the chrome, and `height=\"100%\"` fills a\nflex parent.\n\n## See also\n\n- [The spreadsheet shell](../cells/spreadsheet-shell.md) - every prop, every dialog, the file formats and the limits, on one page.\n- [Formulas](./formulas.md) - references, names, LET and LAMBDA, your own functions, auditing.\n- [Formatting](./formatting.md) - number formats, borders, merged headers, conditional formatting.\n- [Files](./files.md) - save, restore, autosave, `.xlsx`, `.ods`, `.xls` and `.csv`.\n- [Excel keyboard shortcuts](../cells/keyboard-shortcuts.md) - the full list, and how to turn them on for a plain grid.\n- [`<sv-sheet>`](../web-components/sv-sheet.md) - the same component as a custom element for React, Vue, Angular or plain HTML.\n"
|
|
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"
|
|
5176
5200
|
},
|
|
5177
5201
|
{
|
|
5178
5202
|
"slug": "help/sheet/validation",
|
|
@@ -5543,7 +5567,7 @@ export const docs = [
|
|
|
5543
5567
|
"path": "docs/help/ui-components/sv-dock-manager.md",
|
|
5544
5568
|
"title": "SvDockManager",
|
|
5545
5569
|
"section": "UI components",
|
|
5546
|
-
"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"
|
|
5547
5571
|
},
|
|
5548
5572
|
{
|
|
5549
5573
|
"slug": "help/ui-components/sv-drawer",
|
|
@@ -5697,7 +5721,7 @@ export const docs = [
|
|
|
5697
5721
|
"path": "docs/help/ui-components/sv-nav-pane.md",
|
|
5698
5722
|
"title": "SvNavPane",
|
|
5699
5723
|
"section": "UI components",
|
|
5700
|
-
"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"
|
|
5701
5725
|
},
|
|
5702
5726
|
{
|
|
5703
5727
|
"slug": "help/ui-components/sv-number-input",
|
|
@@ -7069,8 +7093,8 @@ export const apiReference = {
|
|
|
7069
7093
|
]
|
|
7070
7094
|
};
|
|
7071
7095
|
export const apiSurface = {
|
|
7072
|
-
"gridVersion": "3.0.
|
|
7073
|
-
"enterpriseVersion": "3.1.
|
|
7096
|
+
"gridVersion": "3.0.8",
|
|
7097
|
+
"enterpriseVersion": "3.1.2",
|
|
7074
7098
|
"grid": {
|
|
7075
7099
|
"values": [
|
|
7076
7100
|
"ALL_FILTER_OPERATORS",
|
|
@@ -8441,6 +8465,7 @@ export const apiSurface = {
|
|
|
8441
8465
|
"createSupabaseRealtime",
|
|
8442
8466
|
"createTableRegistry",
|
|
8443
8467
|
"createWorkbook",
|
|
8468
|
+
"createWorkerDataSource",
|
|
8444
8469
|
"criticalPath",
|
|
8445
8470
|
"cronMatches",
|
|
8446
8471
|
"cronMatchesParsed",
|
|
@@ -9099,6 +9124,7 @@ export const apiSurface = {
|
|
|
9099
9124
|
"ImportResult",
|
|
9100
9125
|
"ImportRowError",
|
|
9101
9126
|
"ImportValidator",
|
|
9127
|
+
"InMemoryDataSourceOptions",
|
|
9102
9128
|
"Interval",
|
|
9103
9129
|
"IntrospectSupabaseOptions",
|
|
9104
9130
|
"IntrospectedColumn",
|
|
@@ -9353,6 +9379,9 @@ export const apiSurface = {
|
|
|
9353
9379
|
"Well",
|
|
9354
9380
|
"Workbook",
|
|
9355
9381
|
"WorkbookOptions",
|
|
9382
|
+
"WorkerDataSource",
|
|
9383
|
+
"WorkerDataSourceOptions",
|
|
9384
|
+
"WorkerLike",
|
|
9356
9385
|
"WorkingCalendar",
|
|
9357
9386
|
"WritableDataSource",
|
|
9358
9387
|
"ZoomLevel",
|
|
@@ -9372,7 +9401,8 @@ export const apiSurface = {
|
|
|
9372
9401
|
"@svgrid/enterprise/studio",
|
|
9373
9402
|
"@svgrid/enterprise/wc",
|
|
9374
9403
|
"@svgrid/enterprise/wc/react",
|
|
9375
|
-
"@svgrid/enterprise/wc/vue"
|
|
9404
|
+
"@svgrid/enterprise/wc/vue",
|
|
9405
|
+
"@svgrid/enterprise/worker"
|
|
9376
9406
|
]
|
|
9377
9407
|
},
|
|
9378
9408
|
"props": [
|