Data validation (dropdowns)

Excel Data Validation on a plain <SvGrid>: Status / Priority / Owner / Sprint columns are list-constrained (double-click for a dropdown), and Estimate must be a whole number 0-40. Four cells arrive invalid and light up red with the reason as a tooltip; fix one and it clears live. Dropdowns are editorType:list + editorOptions; the flag is the declarative validate() hook. (requires @svgrid/enterprise)

A live, editable Svelte 5 data grid example from the SvGrid gallery (Spreadsheet). See the SvGrid documentation for the full API.

About this example

Excel-style data validation on a plain Svelte 5 SvGrid. Status, Priority, Owner and Sprint are list-constrained with editorType 'list' and editorOptions, so a double-click opens a dropdown, and Estimate must be a whole number from 0 to 40. Four cells arrive invalid and light up red with the reason as a tooltip through the declarative validate hook, and fixing a value clears the flag live.

Excel "Data Validation" on a plain <SvGrid responsive={true}>. Some letter columns are list-constrained via editorType:'list' + editorOptions (double-click for a dropdown); Estimate enforces a numeric rule. The red flag is the grid's own declarative per-column validate() hook - it fires for bad data already in the sheet on load and re-checks live as you edit.

Imports, features and API used

Imports: @svgrid/grid

Frequently asked questions

How do I restrict a column to a list?

Set editorType: 'list' and editorOptions on the column. The editor is a dropdown of those values, and a validate function can flag any existing value that is not in the list.

How is the numeric rule enforced?

The Estimate column's validate returns a message unless the value is an integer between 0 and 40; the cell turns red and the message becomes the tooltip until the value passes.

Does validation run on load?

Yes. The hook runs for every rendered cell, which is why the four seeded bad values are flagged before anyone edits.

Related documentation

Related articles

  • A Fill Handle (Drag to Fill) in SvGrid - Build a working spreadsheet-style fill handle on top of SvGrid's cell selection and editing - pointer tracking, range highlighting, series fill, and undo/redo integration all covered.
  • Inside SvGrid: The Inline Editing Engine - How SvGrid handles inline cell editing - typed editors, an event-based commit model, undo/redo, and why the grid never touches your data directly.
  • Inline Editing with Validation in SvGrid - How to wire up typed cell editors, reject bad input before it reaches your data, and keep per-cell error state without a form library.

Source code (209-data-validation.svelte)

<script lang="ts">
  /**
   * 209. Data validation (dropdowns + rules)
   * -----------------------------------------
   * Excel "Data Validation" on a plain <SvGrid responsive={true}>. Some letter columns are
   * list-constrained via editorType:'list' + editorOptions (double-click for a
   * dropdown); Estimate enforces a numeric rule. The red flag is the grid's own
   * declarative per-column validate() hook - it fires for bad data already in
   * the sheet on load and re-checks live as you edit.
   */
  import {
    SvGrid,
    tableFeatures,
    renderSnippet,
    rowResize,
    type ColumnDef,
  } from '@svgrid/grid'

  function colLetters(n: number): string[] {
    return Array.from({ length: n }, (_, i) => String.fromCharCode(65 + i))
  }
  const letters = colLetters(12) // A=Task, B=Status, C=Priority, D=Owner, E=Estimate, F=Sprint, G..L empty
  const MIN_ROWS = 60
  type Row = Record<string, unknown>

  const STATUS = ['Backlog', 'In progress', 'Blocked', 'In review', 'Done']
  const PRIORITY = ['Low', 'Medium', 'High', 'Critical']
  const OWNERS = ['Ava', 'Marco', 'Priya', 'Sam', 'Lena', 'Diego']
  const SPRINTS = ['S-41', 'S-42', 'S-43', 'S-44']
  const HEADERS = ['Task', 'Status', 'Priority', 'Owner', 'Estimate (pts)', 'Sprint']

  type Task = [string, string, string, string, number | string, string]
  const DATA: Task[] = [
    ['Auth: refresh tokens',     'In progress', 'High',     'Ava',   5,  'S-42'],
    ['Billing webhook retries',  'Blocked',     'Critical', 'Marco', 8,  'S-42'],
    ['Grid: freeze panes',       'In review',   'Medium',   'Priya', 13, 'S-43'],
    ['Onboarding empty states',  'Backlog',     'Low',      'Sam',   3,  'S-44'],
    ['CSV import mapping',       'Todo',        'High',     'Lena',  5,  'S-43'], // * bad Status
    ['Dark theme polish',        'Done',        'Low',      'Diego', 2,  'S-41'],
    ['Perf: virtualize 1M rows', 'In progress', 'Urgent',   'Ava',   21, 'S-44'], // * bad Priority
    ['Docs: MCP quickstart',     'Backlog',     'Medium',   'Nadia', 8,  'S-43'], // * bad Owner
    ['A11y audit (WCAG 2.2)',    'In review',   'High',     'Marco', 90, 'S-42'], // * Estimate > 40
    ['Search relevance tuning',  'Backlog',     'Medium',   'Priya', 13, 'S-44'],
  ]

  const empty = (): Row => Object.fromEntries(letters.map((c) => [c, ''])) as Row
  const rows: Row[] = [Object.fromEntries(letters.map((c, i) => [c, HEADERS[i] ?? ''])) as Row]
  DATA.forEach((t) => { const r = empty(); t.forEach((v, ci) => (r[letters[ci]!] = v)); rows.push(r) })
  while (rows.length < MIN_ROWS) rows.push(empty()) // empty grid below the data, Excel-style

  let raw = $state<Row[]>(rows)
  const features = tableFeatures({})

  function commitCell(rowIndex: number, columnId: string, value: unknown) {
    const next = raw.slice()
    next[rowIndex] = { ...next[rowIndex]!, [columnId]: value }
    raw = next
  }

  const inList = (list: string[]) => ({ value, rowIndex }: { value: unknown; rowIndex: number }) =>
    rowIndex === 0 || value === '' || list.includes(String(value)) ? (true as const) : `Must be one of: ${list.join(', ')}`

  type Col = ColumnDef<typeof features, Row>
  const listCol = (letter: string, list: string[]): Partial<Col> => ({ editorType: 'list', editorOptions: list, validate: inList(list) })

  const perCol: Record<string, Partial<Col>> = {
    B: listCol('B', STATUS),
    C: listCol('C', PRIORITY),
    D: listCol('D', OWNERS),
    E: {
      editorType: 'number',
      align: 'right',
      validate: ({ value, rowIndex }: { value: unknown; rowIndex: number }) => {
        if (rowIndex === 0 || value === '') return true as const
        const n = Number(value)
        if (!Number.isFinite(n) || !Number.isInteger(n)) return 'Estimate must be a whole number'
        if (n < 0 || n > 40) return 'Estimate must be between 0 and 40 points'
        return true as const
      },
    },
    F: listCol('F', SPRINTS),
  }

  function pillClass(rowIndex: number, letter: string): string {
    if (rowIndex === 0) return 'sheet-head'
    const v = String(raw[rowIndex]?.[letter] ?? '')
    if (letter === 'B') return 'st-' + v.toLowerCase().replace(/[^a-z]+/g, '-')
    if (letter === 'C') return 'pr-' + v.toLowerCase()
    return ''
  }

  const columns: Col[] = letters.map((letter) => ({
    field: letter,
    header: letter,
    width: letter === 'A' ? 220 : letter === 'E' ? 120 : ['B', 'C', 'D', 'F'].includes(letter) ? 124 : 92,
    editorType: 'text',
    cellClass: (ctx: { row: { index: number } }) => pillClass(ctx.row.index, letter),
    ...perCol[letter],
  }))

  let heights = $state<Record<number, number>>({})
  const rowHeight = (i: number) => heights[i] ?? 26
  const onRowResize = (i: number, h: number) => (heights = { ...heights, [i]: h })
</script>

<section class="sheet-demo">
  <header class="bs-head">
    <h2 class="bs-title">Data validation</h2>
    <p class="bs-sub">
      <strong>Status</strong>, <strong>Priority</strong>, <strong>Owner</strong> and
      <strong>Sprint</strong> are list-constrained - double-click for a dropdown.
      <strong>Estimate</strong> must be a whole number 0-40. Four cells arrived
      <span class="bs-red">invalid</span>; fix one and the flag clears live.
    </p>
  </header>

  <!-- svelte-ignore a11y_no_static_element_interactions -->
  <div class="sheet" use:rowResize={{ onResize: onRowResize, min: 20, max: 320 }}>
    <SvGrid responsive={true}
      columnResize
      data={raw}
      columns={columns}
      features={features}
      sortable={false}
      filterable={false}
      selectionMode="cell"
      showRowNumbers={true}
      rowNumberWidth={46}
      showColumnFilters={false}
      showRowSelection={false}
      enableInlineEditing={true}
      enableRowHover={false}
      enableCellSelection={true}
      contextMenu={['copy', 'cut', 'paste', 'clear']}
      rowHeight={rowHeight}
      containerHeight="100%"
      fitColumns={false}
      onCellValueChange={(e) => { if (e.columnId !== '__rownum__') commitCell(e.rowIndex, e.columnId, e.newValue) }}
    />
  </div>
</section>

<!-- Shared Excel chrome is `.sheet-demo` in src/index.css; only the
     status/priority pill colors live here. -->
<style>
  /* Status / priority hues chosen to read on both light and dark themes;
     neutral tones use the theme's muted token. */
  .bs-red { color: var(--sg-danger, #ef4444); font-weight: 700; }
  .sheet :global(.sv-grid-cell.st-backlog)     { color: var(--sg-muted, #64748b); font-weight: 600; }
  .sheet :global(.sv-grid-cell.st-in-progress) { color: #3b82f6; font-weight: 600; }
  .sheet :global(.sv-grid-cell.st-blocked)     { color: #ef4444; font-weight: 600; }
  .sheet :global(.sv-grid-cell.st-in-review)   { color: #f59e0b; font-weight: 600; }
  .sheet :global(.sv-grid-cell.st-done)        { color: #22c55e; font-weight: 600; }
  .sheet :global(.sv-grid-cell.pr-critical) { color: #ef4444; font-weight: 600; }
  .sheet :global(.sv-grid-cell.pr-high)     { color: #f97316; font-weight: 600; }
  .sheet :global(.sv-grid-cell.pr-medium)   { color: #eab308; font-weight: 600; }
  .sheet :global(.sv-grid-cell.pr-low)      { color: var(--sg-muted, #64748b); font-weight: 600; }
</style>

View this example on GitHub

More Spreadsheet examples

  • Spreadsheet + Ribbon bar - The whole Excel surface as one component, <SvSheet workbook={wb} />: a six-tab ribbon (Home, Insert, Formulas, Data, Review, View), the Name Box and fx bar, sheet tabs and the Sum / Average / Count status bar. Format cells, merge them, comment on them, validate what goes in, colour them by rule, filter the region, protect the sheet; every button is the same call as its shortcut, so Ctrl+B and the Bold button cannot drift. A two-sheet P&L with the formats travelling in the document.
  • Spreadsheet + formulas - Real formula engine inside the grid: cell refs (A1), ranges (A1:A10), SUM / AVG / IF / COUNTIF / ROUND, arithmetic, string concat, cycle detection.
  • Per-cell custom borders (KPI) - Editable KPI scorecard. spreadsheetLayout paints spreadsheet-style per-edge custom borders via an absolute-positioned overlay (no border-collapse conflicts). Edit any quarter or target - the borders re-derive: green double = beat target, blue solid = hit, amber dotted = near miss, red dashed = bad miss; row champion gets a colored full frame.
  • Cell merging (spreadsheet shell) - A real invoice rendered on an Excel-style shell: A / B / C / D / E column letters across the top, row numbers down the left. Brand band, bill-from / bill-to address blocks, meta block, line items, totals, notes, signatures - all assembled from MergeSpec + CellBorderSpec. Editable Qty / Rate / addresses / notes; totals recompute live.
  • HyperFormula integration - Full HyperFormula engine wired into the grid as a peer-optional dep. Editable spreadsheet with A1-style cell refs, dozens of formulas across math (SUM / SUMIF), lookup (VLOOKUP / INDEX-MATCH), text (CONCAT / UPPER), date (TODAY / DATEDIF), logical (IF nests), financial (PMT / IRR / NPV), statistical (AVERAGE / MAX / RANK).