SvForm

A schema-driven form that renders the SvGrid UI-kit controls from a FormField[], with labels, required and custom validation, and a submit handler.

SvForm turns a declarative field list into a laid-out form - text, email, tel, textarea, number, password, select, checkbox, switch, date, color, and rating fields all map to the kit's editors. It validates on blur and on submit, emits onSubmit(values) only when valid, and onChange(values) on every edit. Colors come from the grid's --sg-* tokens, so it matches the rest of the kit.

Related: SvField · SvFileUpload · Layout & composite overview

Installation

Add it with the CLI - this drops a ready-to-edit SvForm starter into your app:

Prefer to see it first? npx @svgrid/ui try form opens it in a throwaway sandbox - no project needed.

Or install the package and import it directly. SvForm ships free in @svgrid/grid (dependency-free):

import { SvForm } from '@svgrid/grid'

Example

Open the live example: Form (Layout)

<script lang="ts">
  import { SvForm, type FormField } from '@svgrid/grid'
  const fields: FormField[] = [
    { name: 'name', label: 'Full name', required: true },
    { name: 'email', label: 'Email', type: 'email', required: true },
    { name: 'plan', label: 'Plan', type: 'select',
      options: [{ value: 'free', label: 'Free' }, { value: 'pro', label: 'Pro' }] },
    { name: 'agree', label: 'I accept the terms', type: 'checkbox' },
  ]
</script>

<SvForm {fields} columns={2} onSubmit={(values) => save(values)} />

Props

Prop Type Default Description
fields ReadonlyArray<FormField | FormSection> - The form schema - flat fields and/or titled sections. See FormField.
initial Record<string, any> {} Initial values, seeded once on mount.
onSubmit (values) => void | Promise<void> - Fired with the (visible-field) values when valid. If it returns a promise, the submit button shows a loading state until it settles.
onChange (values: Record<string, any>) => void - Fired on every field edit.
onCancel () => void - When set, renders a secondary Cancel button.
submitLabel string Submit Label for the submit button.
cancelLabel string Cancel Label for the Cancel button.
showReset boolean false Render a Reset button that restores the initial values.
resetLabel string Reset Label for the Reset button.
stepper boolean false Render titled sections as a validated multi-step wizard (Back / Next, per-step gating).
tabs boolean false Render titled sections as tabs (one panel at a time; a failed submit jumps to the erroring tab). Ignored with stepper.
columns number 1 Columns in the responsive field grid (a section can override its own).
disabled boolean false Disable every field and the submit button.
serverErrors Record<string, string> - Inject server-side errors ({ field: message }), applied reactively.
formError string - A form-level message shown in a banner above the fields.
errorSummary boolean false Show a summary of field errors (with focus links) above the form after a failed submit.
reinitialize off | always | ifPristine off Re-seed when the initial identity changes (e.g. editing a different record). ifPristine re-seeds only when there are no unsaved edits.
dir ltr | rtl | auto - Text direction; rtl mirrors the form layout.
messages Partial<FormMessages> - Override the built-in generated strings (required(label), minItems, maxItems, checking).

FormField

type FormFieldType =
  | 'text' | 'email' | 'tel' | 'textarea' | 'number' | 'password'
  | 'select' | 'multiselect' | 'combobox' | 'checkbox' | 'switch' | 'radio'
  | 'date' | 'datetime' | 'color' | 'rating' | 'slider' | 'tags'
  | 'phone' | 'country' | 'mask' | 'file'
  | 'array'   // a repeatable group - see itemFields

type FormField = {
  name: string
  label: string
  type?: FormFieldType                 // defaults to 'text'
  required?: boolean
  readonly?: boolean                   // shown but not editable (still submitted, unlike hidden)
  help?: string                        // helper text under the control (distinct from an error)
  placeholder?: string
  options?: Array<{ value; label }> | ((values) => Array<{ value; label }>)  // a function CASCADES (list derived from other values)
  dependsOn?: string | string[]        // clear this field when a parent changes (cascading selects)
  mask?: string                        // for type: 'mask' (#=digit, A=letter, *=alnum)
  min?: number; max?: number; step?: number; precision?: number  // number / slider
  prefix?: string; suffix?: string     // number affixes ($ / %)
  loadOptions?: (query: string) => Promise<Array<{ value; label }>>  // remote combobox
  accept?: string; multiple?: boolean  // for type: 'file'
  computed?: (values) => any           // derive a read-only value from other fields (included in the payload)
  rules?: ReadonlyArray<Validator>     // declarative rules (email/pattern/min/compare...)
  validate?: (value: any, values: Record<string, any>) => string | null | undefined
  asyncValidate?: (value, values) => Promise<string | null | undefined>  // debounced, stale-guarded
  asyncDebounce?: number               // ms before asyncValidate runs on edit (default 300)
  full?: boolean                       // span the full width in the grid
  span?: number                        // columns to span (generalizes full)
  visible?: boolean | ((values) => boolean)   // show only when true; hidden = not validated, not submitted
  disabled?: boolean | ((values) => boolean)  // disable, statically or derived from other values
  // For type: 'array' (a repeatable group):
  itemFields?: ReadonlyArray<FormField>        // the fields of each row
  addLabel?: string                            // "add" button label (default "+ Add")
  minItems?: number                            // min / max row count
  maxItems?: number
}

Beyond the basics, SvForm maps each rich type to the matching kit editor: radio, slider, tags, phone, country, mask, datetime, combobox (with remote loadOptions) and file. See the rich field types demo.

Cascading fields: give a child a function options (derived from the current values) and a dependsOn naming its parent(s). When the parent changes, the child's list re-derives and its value is cleared so a stale selection never lingers - for example Country -> State -> City:

{ name: 'country', label: 'Country', type: 'select', options: countries }
{ name: 'state', label: 'State', type: 'select', dependsOn: 'country',
  options: (v) => statesByCountry[v.country] ?? [] }

Computed fields: computed: (values) => ... derives a read-only value from the other fields; it recomputes reactively, is never user-validated, and its value is included in the submitted payload - a live total, for instance:

{ name: 'qty', label: 'Qty', type: 'number' }
{ name: 'price', label: 'Unit price', type: 'number' }
{ name: 'total', label: 'Total', computed: (v) => (v.qty ?? 0) * (v.price ?? 0) }

FormSection

Group fields under a heading. A section renders as a titled fieldset, and with <SvForm stepper> each section becomes one validated step of a wizard. The schema may freely mix flat fields and sections.

type FormSection = {
  section: string                    // heading (and step label)
  description?: string
  fields: ReadonlyArray<FormField>
  columns?: number                   // this section's grid columns (defaults to the form's)
}

Examples

Conditional (dynamic) fields

visible and disabled take a value or a (values) => boolean, so a field can appear, hide or disable based on the rest of the form. A hidden field is skipped in validation and left out of the submitted payload - so a conditionally required field never blocks submit while it's hidden.

Open the live example: Dynamic form (Layout)

const fields: FormField[] = [
  { name: 'contact', label: 'Preferred contact', type: 'select', required: true, options: [
    { value: 'email', label: 'Email' }, { value: 'phone', label: 'Phone' }, { value: 'none', label: 'Do not contact me' },
  ] },
  { name: 'email', label: 'Email address', type: 'email', required: true,
    rules: [rules.email()], visible: (v) => v.contact === 'email' },
  { name: 'notes', label: 'Notes', type: 'textarea',
    disabled: (v) => v.contact === 'none' },
]

Sections and wizard steps

Group fields into FormSections and they render as titled fieldsets. Add stepper and each section becomes a validated step - Next only advances when the current step is valid, and the last step submits. Or add tabs to render the sections as tabs instead (one panel at a time; a failed submit jumps to the first tab that has an error, which is also marked):

Open the live example: Wizard form (Layout)

const schema: FormEntry[] = [
  { section: 'Account', fields: [
    { name: 'email', label: 'Email', type: 'email', required: true, rules: [rules.email()] },
  ] },
  { section: 'Profile', columns: 2, fields: [
    { name: 'first', label: 'First name', required: true },
    { name: 'last', label: 'Last name', required: true },
  ] },
]
<SvForm fields={schema} stepper columns={2} onSubmit={save} />

Field arrays (repeatable groups)

A type: 'array' field with itemFields renders add / remove rows (and up / down reorder buttons once there is more than one row). Its value is an array of item objects; each row validates against itself (so within-row rules work), and required / minItems / maxItems gate the row count:

Open the live example: Field array form (Layout)

const schema: FormEntry[] = [
  { name: 'items', label: 'Line items', type: 'array', required: true, minItems: 1, itemFields: [
    { name: 'desc', label: 'Description', required: true },
    { name: 'qty', label: 'Qty', type: 'number', required: true, rules: [rules.min(1)] },
  ] },
]
// value: { items: [{ desc: 'Widget', qty: 3 }, ...] }

Driving createForm directly, arrays expose arrayItems(name), itemValue, itemError, addItem(name, item?), removeItem(name, i), moveItem, setItemValue, and handleItemBlur.

Async validation

asyncValidate runs after the sync checks pass - debounced (asyncDebounce, default 300ms) and stale-guarded so only the latest response wins. While it runs the field shows a checking indicator (form.isValidating(name)), and submit waits for every async validator before it fires:

{ name: 'username', label: 'Username', required: true,
  asyncValidate: async (v) => (await isTaken(v)) ? 'That username is taken' : null }

Async submit, reset, and server errors

onSubmit may return a promise - the submit button shows a loading state until it settles. Set showReset for a Reset button that restores the initial values. For server-side validation, drive the form from the headless createForm core and call setErrors after the request:

<script lang="ts">
  import { createForm } from '@svgrid/grid'
  const form = createForm({ fields: () => fields, initial, onSubmit: save })
  async function submit() {
    if (!(await form.submit())) return
    const res = await api.save(form.values)
    if (!res.ok) form.setErrors(res.fieldErrors)   // { email: 'Already taken' }
  }
</script>

createForm also exposes submitting, isDirty, isFieldDirty(name), isVisible(name), isDisabled(name), and reset(next?).

Two-column layout with full-width rows

Set columns={2} and mark wide fields full so they span both columns:

const fields: FormField[] = [
  { name: 'first', label: 'First name', required: true },
  { name: 'last', label: 'Last name', required: true },
  { name: 'bio', label: 'Bio', type: 'textarea', full: true },
]

Cross-field validation

validate receives the whole values object, so one field can check another - for example confirming a password:

{ name: 'confirm', label: 'Confirm password', type: 'password', required: true,
  validate: (v, values) => (v === values.password ? null : 'Passwords do not match') }

Declarative rules

Use rules for reusable checks (email, pattern, min, compare) instead of hand writing validate. Required, rules, then validate run in that order, and the first failure wins.

Sign-up form with declarative rules

Compose rules per field rather than hand-writing validate. compare reads another field for a cross-field check, so it confirms the password inline:

<script lang="ts">
  import { SvForm, rules, type FormField } from '@svgrid/grid'
  const fields: FormField[] = [
    { name: 'email', label: 'Email', type: 'email', required: true, rules: [rules.email()] },
    { name: 'password', label: 'Password', type: 'password', required: true,
      rules: [rules.minLength(8)] },
    { name: 'confirm', label: 'Confirm password', type: 'password', required: true,
      rules: [rules.compare('password', '===', { message: 'Passwords do not match' })] },
    { name: 'age', label: 'Age', type: 'number', rules: [rules.min(18)] },
  ]
</script>

<SvForm {fields} columns={2} submitLabel="Create account"
  onSubmit={(values) => register(values)} />

Tip: every rule builder except required skips empty values, so rules.min(18) only fires once the optional age field is filled - add required when the field must also be present.

Accessibility

More examples

Form: rich field types

SvForm reaching the whole input suite from one schema - phone, country, mask, combobox, radio, slider, tags, datetime and file - plus per-field help text, a readonly field, column span, and a promise toast on submit.

Open the live example: Form: rich field types (Layout)

Form: cascading fields

SvForm dependent selects - a child list derives from the parent value via a function options, and dependsOn clears the child when the parent changes so a stale selection never lingers. Country -> State -> City.

Open the live example: Form: cascading fields (Layout)

Checkout form

A real payment form built entirely from SvGrid UI: SvMaskedInput (card number + expiry + CVC), SvCountryInput (billing country), SvNumberInput (amount), SvSwitchButton (save card) and SvButton - with live card-brand detection and validation. Copy the file and ship it.

Open the live example: Checkout form (Recipes)

Appointment booking

A scheduling form from SvGrid UI: SvComboBox (service), SvCalendar (date), SvTimePicker (slot), SvButtonGroup (duration), SvNumberInput (guests) and a live summary. The same components SvGrid uses to edit cells, composed into a page.

Open the live example: Appointment booking (Recipes)

See also

Live examples

  • Form - SvForm: a schema-driven signup form wiring the whole kit (text, email, password, select, date, switch, rating) with required + cross-field custom validation in a two-column grid.
  • Dynamic form - SvForm with conditional fields: visible / disabled derived from other values (hidden fields skip validation and drop from the payload), async field validation, an async onSubmit with a loading button, and a Reset that restores the initial values.
  • Wizard form - SvForm as a multi-step wizard: titled FormSection groups become validated steps with Back / Next (gated on each step) and a final async Submit. Drop the stepper prop and the same schema renders as stacked fieldsets.
  • Field array form - SvForm field array (type: array): a repeatable group with add / remove rows, per-row validation (each item validates against itself), required / minItems gating, and an array value in the submitted payload - an invoice line-items form.
  • Form: rich field types - SvForm reaching the whole input suite from one schema - phone, country, mask, combobox, radio, slider, tags, datetime and file - plus per-field help text, a readonly field, column span, and a promise toast on submit.
  • Form: cascading fields - SvForm dependent selects - a child list derives from the parent value via a function `options`, and `dependsOn` clears the child when the parent changes so a stale selection never lingers. Country -> State -> City.
  • Checkout form - A real payment form built entirely from SvGrid UI: SvMaskedInput (card number + expiry + CVC), SvCountryInput (billing country), SvNumberInput (amount), SvSwitchButton (save card) and SvButton - with live card-brand detection and validation. Copy the file and ship it.
  • Appointment booking - A scheduling form from SvGrid UI: SvComboBox (service), SvCalendar (date), SvTimePicker (slot), SvButtonGroup (duration), SvNumberInput (guests) and a live summary. The same components SvGrid uses to edit cells, composed into a page.

Related articles