Conditional form schema
Declarative when rules drive per-cell visibility and editability. EIN only on nonprofits, SSN only on individuals, rejection reason only when status is rejected.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Editing). See the SvGrid documentation for the full API.
About this example
Schema-driven visibility and editability inside the Svelte 5 data grid. Each column carries declarative when rules evaluated against the row's current values: an EIN cell only appears on nonprofits, an SSN only on individuals, and a rejection reason only when the status is rejected. Cells that fail a visible rule are hidden, cells that fail an editable rule are locked with a contextual reason, all built on SvGrid's per-cell editable function.
Declarative schema-driven field visibility and editability inside a grid. Each column carries a when rule (or array of rules); the runtime evaluates the rules against the row's current values and:
- hides cells whose
visible.whenfails - disables editing on cells whose
editable.whenfails - shows a contextual reason ("locked: status is not 'pending'")
The pattern: instead of imperative checks scattered across columns, define WHEN a field applies; SvGrid's editable: (ctx) => boolean already supports per-cell rules - this demo layers a declarative schema on top so designers can hand-tune workflows without touching rendering code.
Imports, features and API used
Imports: @svgrid/grid
Table features registered: rowSortingFeature, columnFilteringFeature
Columns: id (ID), recordType (Type), status (Status)
Frequently asked questions
How do I make a cell editable only under some condition?
SvGrid accepts editable as a function of the cell context, so you can return false when the row's status is not pending. This demo wraps that in a declarative when rule so the condition lives in the schema rather than in code.
How is a cell hidden for some rows?
The column's cell snippet checks the visible rule against the row and renders nothing when it fails, while the same column shows its value on rows where the rule passes.
Why a declarative schema instead of inline checks?
Rules in data can be edited by people who do not touch the rendering code, combined in arrays, and reused for the reason text the locked cell shows.
Related documentation
Related articles
- A Date-Picker Cell Editor in SvGrid - How to make date columns editable with a real date picker in SvGrid - including display formatting, time zone handling, and validation that preserves user input.
- Conditional Row Styling in SvGrid - Drive row-level background tints, classes, and styles from your data - overdue invoices, failed jobs, VIP records - without touching the DOM directly.
- Conditional Formatting - Color Cells by Their Value - Four rule types, one prop - add heatmaps, data bars, icon sets, and threshold highlights to any SvGrid column without custom cell renderers.
Source code (82-conditional-form-schema.svelte)
<script lang="ts">
/**
* 82. Conditional form schema
* ---------------------------
* Declarative schema-driven field visibility and editability inside a
* grid. Each column carries a `when` rule (or array of rules); the
* runtime evaluates the rules against the row's current values and:
*
* - hides cells whose `visible.when` fails
* - disables editing on cells whose `editable.when` fails
* - shows a contextual reason ("locked: status is not 'pending'")
*
* The pattern: instead of imperative checks scattered across columns,
* define WHEN a field applies; SvGrid's `editable: (ctx) => boolean`
* already supports per-cell rules - this demo layers a declarative
* schema on top so designers can hand-tune workflows without touching
* rendering code.
*/
import {
SvGrid,
tableFeatures,
rowSortingFeature,
columnFilteringFeature,
renderSnippet,
type ColumnDef,
type GridColumns,
type CellContext,
} from '@svgrid/grid'
// ---- Domain -----------------------------------------------------------
type RecordType = 'individual' | 'business' | 'nonprofit'
type Status = 'draft' | 'pending' | 'approved' | 'rejected'
type Application = {
id: string
legalName: string
recordType: RecordType
status: Status
// Business-only
taxId: string
industry: string
annualRevenue: number
// Nonprofit-only
ein: string
missionStatement: string
// Individual-only
ssnLast4: string
dateOfBirth: string
// Universal
contactEmail: string
contactPhone: string
rejectionReason: string
}
let rows = $state<Application[]>([
{ id: 'A001', legalName: 'Atlas Holdings LLC', recordType: 'business', status: 'pending', taxId: '47-1234567', industry: 'Manufacturing', annualRevenue: 4_200_000, ein: '', missionStatement: '', ssnLast4: '', dateOfBirth: '', contactEmail: '[email protected]', contactPhone: '+1 415 555 0112', rejectionReason: '' },
{ id: 'A002', legalName: 'Helios Foundation', recordType: 'nonprofit', status: 'approved', taxId: '', industry: '', annualRevenue: 0, ein: '88-7654321', missionStatement: 'Advance clean-energy literacy in K-12 schools.', ssnLast4: '', dateOfBirth: '', contactEmail: '[email protected]', contactPhone: '+1 503 555 0144', rejectionReason: '' },
{ id: 'A003', legalName: 'Sarah Chen', recordType: 'individual', status: 'draft', taxId: '', industry: '', annualRevenue: 0, ein: '', missionStatement: '', ssnLast4: '4421', dateOfBirth: '1988-04-12', contactEmail: '[email protected]', contactPhone: '+1 212 555 0188', rejectionReason: '' },
{ id: 'A004', legalName: 'Vertex Capital', recordType: 'business', status: 'rejected', taxId: '94-9876543', industry: 'Finance', annualRevenue: 8_900_000, ein: '', missionStatement: '', ssnLast4: '', dateOfBirth: '', contactEmail: '[email protected]', contactPhone: '+1 646 555 0166', rejectionReason: 'Documentation insufficient - missing 2024 audit.' },
{ id: 'A005', legalName: 'Crescent Labs', recordType: 'nonprofit', status: 'pending', taxId: '', industry: '', annualRevenue: 0, ein: '12-3456789', missionStatement: 'Community biotech research mentorship.', ssnLast4: '', dateOfBirth: '', contactEmail: '[email protected]', contactPhone: '+1 312 555 0199', rejectionReason: '' },
{ id: 'A006', legalName: 'James Park', recordType: 'individual', status: 'pending', taxId: '', industry: '', annualRevenue: 0, ein: '', missionStatement: '', ssnLast4: '8821', dateOfBirth: '1992-11-03', contactEmail: '[email protected]', contactPhone: '+1 408 555 0122', rejectionReason: '' },
{ id: 'A007', legalName: 'Pioneer Mining Co.', recordType: 'business', status: 'draft', taxId: '36-2468013', industry: 'Mining', annualRevenue: 1_300_000, ein: '', missionStatement: '', ssnLast4: '', dateOfBirth: '', contactEmail: '[email protected]', contactPhone: '+1 720 555 0133', rejectionReason: '' },
])
const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
// ---- Schema rules ------------------------------------------------------
// A `Rule` is a predicate over the row plus a human-readable reason
// shown in tooltips and locked-cell badges. Rules compose by AND -
// EVERY rule must pass for the field to be visible/editable.
type Rule = {
when: (row: Application) => boolean
reason: string
}
/** The schema declares per-field rules. `visible.when` controls whether
* the cell shows any value at all (otherwise a "-" placeholder); the
* column's editable rule is built from the union of visibility and
* editability rules so the editor never opens on a field that should
* not apply to this row. */
type FieldSchema = {
visible?: Rule
editable?: Rule
}
const schema: Partial<Record<keyof Application, FieldSchema>> = {
// Business-only fields
taxId: { visible: { when: (r) => r.recordType === 'business', reason: 'Tax ID only applies to businesses.' } },
industry: { visible: { when: (r) => r.recordType === 'business', reason: 'Industry only applies to businesses.' } },
annualRevenue: { visible: { when: (r) => r.recordType === 'business', reason: 'Annual revenue only applies to businesses.' } },
// Nonprofit-only fields
ein: { visible: { when: (r) => r.recordType === 'nonprofit', reason: 'EIN only applies to nonprofits.' } },
missionStatement: { visible: { when: (r) => r.recordType === 'nonprofit', reason: 'Mission statement only applies to nonprofits.' } },
// Individual-only fields (PII)
ssnLast4: { visible: { when: (r) => r.recordType === 'individual', reason: 'SSN field only applies to individuals.' } },
dateOfBirth: { visible: { when: (r) => r.recordType === 'individual', reason: 'DOB only applies to individuals.' } },
// Rejection reason - visible only when status is rejected
rejectionReason: {
visible: { when: (r) => r.status === 'rejected', reason: 'Rejection reason only applies to rejected applications.' },
editable: { when: (r) => r.status === 'rejected', reason: 'Only editable while status is rejected.' },
},
// Universal contact - editable only while draft or pending
legalName: { editable: { when: (r) => r.status === 'draft' || r.status === 'pending', reason: 'Locked once the application is approved or rejected.' } },
contactEmail: { editable: { when: (r) => r.status === 'draft' || r.status === 'pending', reason: 'Locked once the application is approved or rejected.' } },
contactPhone: { editable: { when: (r) => r.status === 'draft' || r.status === 'pending', reason: 'Locked once the application is approved or rejected.' } },
// recordType cannot change after submission
recordType: { editable: { when: (r) => r.status === 'draft', reason: 'Record type locks at submission.' } },
}
function isVisible(field: keyof Application, row: Application): boolean {
const s = schema[field]
if (!s?.visible) return true
return s.visible.when(row)
}
function isEditable(field: keyof Application, row: Application): boolean {
const s = schema[field]
// A field is editable if it's visible AND any explicit editable rule passes.
if (!isVisible(field, row)) return false
if (!s?.editable) return true
return s.editable.when(row)
}
function lockReason(field: keyof Application, row: Application): string | null {
const s = schema[field]
if (!s) return null
if (s.visible && !s.visible.when(row)) return s.visible.reason
if (s.editable && !s.editable.when(row)) return s.editable.reason
return null
}
// ---- Build the SvGrid editable callback from the schema ---------------
function ruleEditable<K extends keyof Application>(field: K) {
return (ctx: CellContext<Application>) => isEditable(field, ctx.row.original)
}
// ---- KPI strip --------------------------------------------------------
const draftN = $derived(rows.filter((r) => r.status === 'draft').length)
const pendingN = $derived(rows.filter((r) => r.status === 'pending').length)
const approvedN = $derived(rows.filter((r) => r.status === 'approved').length)
const rejectedN = $derived(rows.filter((r) => r.status === 'rejected').length)
const fmtUSD = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
// ---- Cell snippet helpers ---------------------------------------------
// A snippet that renders the field value when visible, "-" when not,
// with a title tooltip explaining the schema decision.
function renderConditional(field: keyof Application, row: Application, format: (v: unknown) => string = String) {
const visible = isVisible(field, row)
const reason = lockReason(field, row)
const editable = isEditable(field, row)
return { visible, reason, editable, value: visible ? format(row[field]) : '-' }
}
// Reusable column builder so we don't write `cell: (ctx) => renderSnippet(...)`
// ten times in the columns array.
function condColumn<K extends keyof Application>(
field: K,
header: string,
editorType: ColumnDef<typeof features, Application>['editorType'],
extra: Partial<ColumnDef<typeof features, Application>> = {},
): ColumnDef<typeof features, Application> {
return {
field,
header,
editorType,
editable: ruleEditable(field),
cell: (ctx) => renderSnippet(ConditionalCell, { row: ctx.row.original, field }),
...extra,
} as ColumnDef<typeof features, Application>
}
const columns: GridColumns<Application> = [
{ field: 'id', header: 'ID', editorType: 'text', width: 80, editable: false },
condColumn('legalName', 'Legal name', 'text', { width: 200 }),
{
field: 'recordType', header: 'Type',
editorType: 'list',
editorOptions: ['individual', 'business', 'nonprofit'] as unknown as ReadonlyArray<string>,
editable: ruleEditable('recordType'),
width: 130,
},
{
field: 'status', header: 'Status',
editorType: 'list',
editorOptions: ['draft', 'pending', 'approved', 'rejected'] as unknown as ReadonlyArray<string>,
width: 130,
cell: (ctx) => renderSnippet(StatusCell, { row: ctx.row.original }),
},
// Business-only
condColumn('taxId', 'Tax ID', 'text', { width: 130 }),
condColumn('industry', 'Industry', 'text', { width: 150 }),
condColumn('annualRevenue', 'Annual revenue', 'number', { width: 150,
format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } }),
// Nonprofit-only
condColumn('ein', 'EIN', 'text', { width: 130 }),
condColumn('missionStatement', 'Mission', 'text', { width: 280 }),
// Individual-only
condColumn('ssnLast4', 'SSN (last 4)', 'text', { width: 110 }),
condColumn('dateOfBirth', 'DOB', 'date', { width: 130,
format: { type: 'date', pattern: 'y-m-d' } }),
// Universal
condColumn('contactEmail', 'Email', 'text', { width: 200 }),
condColumn('contactPhone', 'Phone', 'text', { width: 160 }),
// Conditional - rejection
condColumn('rejectionReason', 'Reject reason', 'text', { width: 280 }),
]
</script>
{#snippet StatusCell(props: { row: Application })}
<span class={`cf-status cf-status-${props.row.status}`}>{props.row.status}</span>
{/snippet}
{#snippet ConditionalCell(props: { row: Application; field: keyof Application })}
{@const info = renderConditional(props.field, props.row, String)}
<span class="cf-cell" class:cf-na={!info.visible} class:cf-locked={info.visible && !info.editable} title={info.reason ?? ''}>
{info.value}
{#if info.visible && !info.editable}
<span class="cf-lock" aria-hidden="true">đź”’</span>
{/if}
</span>
{/snippet}
<section class="cf-shell flex flex-col flex-1 min-h-0 gap-3">
<!-- KPI strip -->
<div class="cf-kpi-strip shrink-0">
<div class="cf-kpi"><div class="cf-kpi-label">Draft</div><div class="cf-kpi-value">{draftN}</div></div>
<div class="cf-kpi cf-kpi-warn"><div class="cf-kpi-label">Pending review</div><div class="cf-kpi-value">{pendingN}</div></div>
<div class="cf-kpi cf-kpi-good"><div class="cf-kpi-label">Approved</div><div class="cf-kpi-value">{approvedN}</div></div>
<div class="cf-kpi cf-kpi-bad"><div class="cf-kpi-label">Rejected</div><div class="cf-kpi-value">{rejectedN}</div></div>
</div>
<!-- Schema explainer -->
<div class="cf-explain shrink-0">
<strong>Schema-driven editability.</strong>
Cells hide automatically when their field doesn't apply (e.g. <em>EIN</em> only on nonprofits, <em>SSN</em> only on individuals).
Cells lock automatically when their workflow doesn't allow edits (e.g. <em>Email</em> locks once status moves to Approved).
Try editing the <em>Type</em> dropdown on a draft row to watch its conditional columns appear; toggle a row's <em>Status</em> to see the Rejection reason column reveal itself.
</div>
<div class="flex-1 min-h-0">
<SvGrid responsive={true}
columnResize
data={rows}
columns={columns}
features={features}
filterMode="menu"
selectionMode="cell"
enableInlineEditing={true}
enableCellSelection={true}
rowHeight={44}
containerHeight="100%"
fitColumns={false}
/>
</div>
</section>
<style>
/* Status pill colours carry meaning, so they stay off the theme tokens;
only their dark ramp is adjusted here for legibility. */
.cf-shell {
height: 100%;
--cf-warn-bg: #fef3c7; --cf-warn-fg: #92400e;
--cf-good-bg: #dcfce7; --cf-good-fg: #166534;
--cf-bad-bg: #fee2e2; --cf-bad-fg: #991b1b; --cf-bad-ring: #dc2626;
}
:global([data-theme='dark']) .cf-shell {
--cf-warn-bg: rgba(245,158,11,0.20); --cf-warn-fg: #fbbf24;
--cf-good-bg: rgba(34,197,94,0.18); --cf-good-fg: #4ade80;
--cf-bad-bg: rgba(239,68,68,0.22); --cf-bad-fg: #fca5a5; --cf-bad-ring: #ef4444;
}
.cf-kpi-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.cf-kpi {
border: 1px solid var(--sg-border, #e2e8f0);
background: var(--sg-bg, #fff);
border-radius: 10px;
padding: 10px 14px;
display: flex; flex-direction: column; gap: 2px;
}
.cf-kpi-warn { border-left: 3px solid #f59e0b; }
.cf-kpi-good { border-left: 3px solid #10b981; }
.cf-kpi-bad { border-left: 3px solid #ef4444; }
.cf-kpi-label { font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--sg-muted, #64748b); }
.cf-kpi-value { font-size: 22px; font-weight: 700; line-height: 1.1; color: var(--sg-fg, #0f172a); }
.cf-explain {
border: 1px solid var(--sg-border, #e2e8f0);
background: var(--sg-bg, #fff);
border-radius: 10px;
padding: 10px 14px;
font-size: 13px;
color: var(--sg-fg, #0f172a);
line-height: 1.55;
}
.cf-explain em {
background: var(--sg-header-bg, #f1f5f9);
padding: 1px 5px;
border-radius: 3px;
font-style: normal; font-weight: 500;
}
/* Cell rendering */
:global(.cf-cell) {
display: inline-flex; align-items: center; gap: 6px;
}
:global(.cf-na) {
color: var(--sg-muted, #94a3b8);
font-style: italic;
}
:global(.cf-locked) {
color: var(--sg-muted, #64748b);
}
:global(.cf-lock) {
font-size: 11px; opacity: 0.7;
}
/* Status pills */
:global(.cf-status) {
display: inline-block; padding: 3px 10px; border-radius: 999px;
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
}
:global(.cf-status-draft) { background: var(--sg-bg-subtle, var(--sg-header-bg, #f1f5f9)); color: var(--sg-fg, #334155); }
:global(.cf-status-pending) { background: var(--cf-warn-bg); color: var(--cf-warn-fg); }
:global(.cf-status-approved) { background: var(--cf-good-bg); color: var(--cf-good-fg); }
:global(.cf-status-rejected) { background: var(--cf-bad-bg); color: var(--cf-bad-fg); box-shadow: inset 0 0 0 1px var(--cf-bad-ring); }
</style>More Editing examples
- Inline editing - Typed editors (text/number/checkbox/date) with dirty tracking + save.
- Editor types + custom slot - Built-in select / rich-select / textarea editors plus a custom `cellEditor` snippet (a range slider) for cases the built-ins do not cover.
- List + chips editors - Two built-in editors with single & multi-select: dropdown (list) and removable tokens (chips), with options or free-form.
- Async editor options - editorOptions can return a Promise - for the whole column, or per row for a cascade. The dropdown shows Loading… while the request is in flight, results are cached (per column, or per column+row) so reopening never refetches, and api.refreshEditorOptions() invalidates when the server list changes.
- Custom cell editors - Three hand-rolled editors: native colour picker bound to a tag swatch, 5-star rating, emoji feedback mood. All write back through api.setCellValue.