# Submit-time validation

Bulk-leads import: edit freely, click Submit, the row-level validator highlights invalid cells and lists every error in an aria-live panel.

Uses `@svgrid/grid` (MIT). Category: Editing. Live demo: https://svgrid.com/demos/71-submit-validation/

Install: `npm i @svgrid/grid` (Svelte 5).

## About this example

Submit-time validation for a bulk leads import in the Svelte 5 data grid. Users edit freely, then click Submit and a row-level validator runs over the in-memory rows; invalid cells are highlighted through cellClass, every error is listed in an aria-live panel, and the payload only goes to the server when the error list is empty.

Different from per-keystroke validation (demo 24). Here the user edits freely and clicks "Submit" - the grid then runs a row-level validator. Invalid rows are highlighted and listed in an errors panel; the form blocks submission until every row passes.

Pattern: keep a `pending` map of unresolved edits; on Submit, run the validator against the in-memory rows, derive `errors`, and only push the payload to "the server" when `errors.length === 0`.

## Imports, features and API used

- Imports: `@svgrid/grid`
- Table features registered: `rowSortingFeature`
- Columns: `id` (Lead), `company` (Company), `email` (Email), `phone` (Phone), `revenue` (Revenue), `status` (Status)

## Frequently asked questions

### How is this different from validating on each edit?

Nothing runs while the user types. Validation happens once on Submit against all rows, which suits imports where a row may be temporarily incomplete while it is being filled in.

### How are invalid cells highlighted?

The column's cellClass function reads the derived errors map and returns an error class for cells that failed, so the styling is data-driven and clears when the row passes.

### Why an aria-live panel for the errors?

So screen readers announce the error list when it changes. The panel lists each row and field with its message, and submission stays blocked until the list is empty.

## Related documentation

- [Submit-time validation with error summary](https://svgrid.com/docs/recipes/submit-time-validation.md)

## Related articles

- [Inside SvGrid: The Inline Editing Engine](https://svgrid.com/blog/building-the-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](https://svgrid.com/blog/inline-editing-with-validation/) - 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.
- [Throttling Live Grid Updates to Animation Frames](https://svgrid.com/blog/throttle-live-updates-animation-frames/) - High-frequency WebSocket feeds will wreck grid performance if you apply every message immediately. One rAF buffer and a Map coalescer cuts thousands of renders down to 60 per second.

## Source code (71-submit-validation.svelte)

```svelte
<script lang="ts">
  /**
   * 71. Submit-time validation
   * --------------------------
   * Different from per-keystroke validation (demo 24). Here the user
   * edits freely and clicks "Submit" - the grid then runs a row-level
   * validator. Invalid rows are highlighted and listed in an errors
   * panel; the form blocks submission until every row passes.
   *
   * Pattern: keep a `pending` map of unresolved edits; on Submit, run
   * the validator against the in-memory rows, derive `errors`, and only
   * push the payload to "the server" when `errors.length === 0`.
   */
  import {
    SvGrid,
    tableFeatures,
    rowSortingFeature,
    type GridColumns,
  } from '@svgrid/grid'

  type Lead = {
    id: string
    company: string
    email: string
    phone: string
    revenue: number
    status: 'new' | 'contacted' | 'qualified'
  }

  let rows = $state<Lead[]>([
    { id: 'L-001', company: 'Acme Corp',  email: 'sales@acme.io',     phone: '+1 555 0101', revenue: 250_000,  status: 'new' },
    { id: 'L-002', company: '',            email: 'tim@globex',         phone: '5551111',     revenue: 0,        status: 'new' },
    { id: 'L-003', company: 'Initech',    email: 'peter@initech.com',  phone: '+1 555 4488', revenue: -100,     status: 'contacted' },
    { id: 'L-004', company: 'Umbrella',   email: 'jill',               phone: '+44 20 7000', revenue: 1_400_000,status: 'qualified' },
    { id: 'L-005', company: 'Stark Inc.', email: 'tony@stark.com',     phone: '+1 555 7777', revenue: 9_900_000,status: 'qualified' },
  ])

  type Issue = { rowId: string; rowIndex: number; field: keyof Lead; message: string }
  let errors = $state<Issue[]>([])
  let submitted = $state<string | null>(null)

  /** Row-level rules. Return one Issue per failed field. */
  function validate(row: Lead, ix: number): Issue[] {
    const out: Issue[] = []
    if (!row.company.trim())
      out.push({ rowId: row.id, rowIndex: ix, field: 'company', message: 'Company is required' })
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(row.email))
      out.push({ rowId: row.id, rowIndex: ix, field: 'email',   message: 'Email is malformed' })
    if (row.phone.replace(/\D/g, '').length < 7)
      out.push({ rowId: row.id, rowIndex: ix, field: 'phone',   message: 'Phone needs at least 7 digits' })
    if (!Number.isFinite(row.revenue) || row.revenue < 0)
      out.push({ rowId: row.id, rowIndex: ix, field: 'revenue', message: 'Revenue cannot be negative' })
    return out
  }

  function submit() {
    submitted = null
    const all: Issue[] = []
    rows.forEach((r, i) => all.push(...validate(r, i)))
    errors = all
    if (all.length === 0) {
      submitted = `Submitted ${rows.length} leads successfully at ${new Date().toLocaleTimeString()}`
    }
  }
  function reset() { errors = []; submitted = null }

  const errorsByRowId = $derived(() => {
    const m = new Map<string, Set<string>>()
    for (const e of errors) {
      if (!m.has(e.rowId)) m.set(e.rowId, new Set())
      m.get(e.rowId)!.add(e.field as string)
    }
    return m
  })

  const features = tableFeatures({ rowSortingFeature })

  const columns: GridColumns<Lead> = [
    { field: 'id',      header: 'Lead',    editorType: 'text', width: 90 },
    {
      field: 'company', header: 'Company', editorType: 'text', width: 180,
      cellClass: (ctx) =>
        errorsByRowId().get(ctx.row.original.id)?.has('company') ? 'cell-invalid' : '',
    },
    {
      field: 'email',   header: 'Email',   editorType: 'text', width: 200,
      cellClass: (ctx) =>
        errorsByRowId().get(ctx.row.original.id)?.has('email')   ? 'cell-invalid' : '',
    },
    {
      field: 'phone',   header: 'Phone',   editorType: 'text', width: 150,
      cellClass: (ctx) =>
        errorsByRowId().get(ctx.row.original.id)?.has('phone')   ? 'cell-invalid' : '',
    },
    {
      field: 'revenue', header: 'Revenue', editorType: 'number', width: 160,
      format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },
      cellClass: (ctx) =>
        errorsByRowId().get(ctx.row.original.id)?.has('revenue') ? 'cell-invalid' : '',
    },
    { field: 'status', header: 'Status', editorType: 'list', width: 140,
      editorOptions: ['new', 'contacted', 'qualified'] },
  ]
</script>

<section class="flex flex-col flex-1 min-h-0 gap-3">
  <div class="flex flex-wrap items-center gap-2 text-sm shrink-0">
    <button type="button" onclick={submit}
      class="sv-btn-primary rounded-md px-3 py-1.5 font-medium"
      >Submit all leads</button>
    <button type="button" onclick={reset}
      class="sv-btn rounded-md px-3 py-1.5"
      >Clear errors</button>
    {#if submitted}
      <span class="text-emerald-600 dark:text-emerald-400 text-xs">✓ {submitted}</span>
    {/if}
  </div>

  {#if errors.length > 0}
    <div role="alert" aria-live="polite"
      class="rounded-md border border-rose-300 bg-rose-50 dark:border-rose-800 dark:bg-rose-950/30 px-3 py-2 text-sm shrink-0">
      <div class="font-medium text-rose-700 dark:text-rose-300 mb-1">
        Fix {errors.length} issue{errors.length === 1 ? '' : 's'} before submitting:
      </div>
      <ul class="list-disc list-inside text-rose-700/90 dark:text-rose-300/90 max-h-28 overflow-auto">
        {#each errors as e (`${e.rowId}-${e.field}`)}
          <li>
            <code>{e.rowId}</code>
            <span class="sv-sep">·</span>
            <strong>{e.field}</strong>: {e.message}
          </li>
        {/each}
      </ul>
    </div>
  {/if}

  <div class="flex-1 min-h-0">
    <SvGrid responsive={true}
      columnResize
      data={rows}
      columns={columns}
      features={features}
      filterMode="none"
      selectionMode="cell"
      showRowNumbers={false}
      enableInlineEditing={true}
      enableCellSelection={true}
      rowHeight={36}
      containerHeight="100%"
      fitColumns={true}
    />
  </div>
</section>

<style>
  /* Toolbar chrome tracks the grid theme; the rose invalid-cell tint below
     is a validation signal, so it stays fixed. */
  .sv-btn-primary {
    border: 1px solid var(--sg-accent, #4f46e5);
    background: var(--sg-accent, #4f46e5);
    color: var(--sg-on-accent, #fff);
  }
  .sv-btn-primary:hover { filter: brightness(1.08); }
  .sv-btn {
    border: 1px solid var(--sg-border, #cbd5e1);
    background: var(--sg-bg, #fff);
    color: var(--sg-fg, #0f172a);
  }
  .sv-btn:hover { background: var(--sg-row-hover-bg, #f8fafc); }
  .sv-sep { color: var(--sg-muted, #64748b); }

  :global(td.cell-invalid) {
    background: rgba(244, 63, 94, 0.12);
    box-shadow: inset 0 0 0 1px #f43f5e;
  }
</style>
```

[View this example on GitHub](https://github.com/sv-grid/sv-grid/blob/main/examples/src/demos/71-submit-validation.svelte)
