Skip to content

Svelte ​

Svelte needs no adapter: you use the schema package with @formisch/svelte, Formisch's own package for Svelte. Below is a whole renderer to start from, with layouts, steps, conditions and a native control for every built-in type. Copy it into your app and make it yours.

Installation ​

sh
npm install @whatsaaaa/form-builder-schema @whatsaaaa/form-builder-affects @formisch/svelte valibot
sh
pnpm add @whatsaaaa/form-builder-schema @whatsaaaa/form-builder-affects @formisch/svelte valibot
sh
yarn add @whatsaaaa/form-builder-schema @whatsaaaa/form-builder-affects @formisch/svelte valibot
sh
bun add @whatsaaaa/form-builder-schema @whatsaaaa/form-builder-affects @formisch/svelte valibot

The affects package is only needed for conditions. Without it, make the builder with createSchemaBuilder() alone: the renderer then hides nothing, and needs no other change.

How it works ​

It has the parts every renderer has, described in rendering with Formisch:

  • JsonFormView reads the definition once: the schema and initial input for Formisch's form, the layout's steps, and what the conditions hide. It renders the current step, Back and Next while there are steps, and Save on the last one.
  • Layout renders each node of a step by its type: a section as a <fieldset>, a row as a 12-column grid whose cells take their span, a slot with the content you pass, and a field by its role.
  • ArrayField renders Formisch's field array, the layout of each item, and buttons to add and remove items.
  • ValueField connects a field to Formisch at its path, picks the native control by the field's type, and sets what the element gives through coerceInput.

What is particular to Svelte:

  • createForm makes the form. untrack reads the definition once, as Formisch reads its config once.
  • Pass the form and the path to useField and useFieldArray as getters, so a field follows its item when one before it is removed.
  • Each component has a file of its own. Layout renders itself for a section, a row's cell and a group, and builder.ts shares one builder.
  • The content of a slot is a snippet, which Layout renders with {@render}.

The renderer is tested: the integration tests run every shared fixture through it, from a text field to lists, form rules, layouts with steps, and conditions. They type, tick, pick, step and submit like a person, and check the fields on show, the errors and what the form submits.

The renderer ​

svelte
<script lang="ts">
  import {
    createForm,
    focus,
    Form,
    getInput,
    setErrors,
    setInput,
    type SubmitHandler,
    validate,
  } from '@formisch/svelte'
  import {
    fieldPathsOfStep,
    issuesOfStep,
    type JsonForm,
    type BuiltSchema,
    type LayoutStep,
  } from '@whatsaaaa/form-builder-schema'
  import { type Snippet, untrack } from 'svelte'
  import { builder } from './builder'
  import Layout from './Layout.svelte'

  let {
    json,
    onsubmit,
    slots = {},
  }: {
    json: JsonForm
    onsubmit: SubmitHandler<BuiltSchema>
    /** The content of the layout's slots, by name. */
    slots?: Record<string, Snippet>
  } = $props()

  // Formisch reads its config once, so read the definition once too.
  const { steps, conditions, ...config } = untrack(() => ({
    schema: builder.build(json),
    initialInput: builder.getInitialInput(json),
    steps: builder.getLayout(json).steps,
    conditions: builder.getVisibility(json),
  }))
  const form = createForm(config)
  // What the conditions hide for the input now: the same object until that changes.
  const visibility = $derived(conditions.of(getInput(form)))
  // The fields the conditions clear, set blank as soon as they are cleared.
  $effect(() => {
    const { toClear } = visibility
    untrack(() => {
      for (const { path, input } of toClear) setInput(form, { path, input } as never)
    })
  })
  let index = $state(0)
  const step = $derived(steps[index] as LayoutStep)
  // A step whose fields are all hidden is skipped. The current one stays, while the user is on it.
  const shown = $derived(
    steps.flatMap((each, i) => (i === index || !visibility.isStepHidden(each) ? [i] : [])),
  )
  const before = $derived(shown[shown.indexOf(index) - 1])
  const after = $derived(shown[shown.indexOf(index) + 1])

  // Next validates the whole form, and only the issues on this step's fields keep the user here.
  async function next() {
    const found = issuesOfStep(step, (await validate(form)).issues)
    if (found.length) {
      const first = found.find((issue) => issue.field.role === 'value')
      if (first) focus(form, { path: first.path as never })
      return
    }
    if (after === undefined) return
    // That validation set errors on the next step's fields too, which nobody saw.
    for (const path of fieldPathsOfStep(steps[after] as LayoutStep, getInput(form))) {
      setErrors(form, { path, errors: null } as never)
    }
    index = after
  }
</script>

<Form of={form} onsubmit={(output) => onsubmit(output)}>
  <!-- Keyed by the step, so its fields render anew when it changes. -->
  {#key index}
    <Layout {form} {visibility} nodes={step.children} indexes={[]} {slots} />
  {/key}
  {#if before !== undefined}
    <button type="button" data-back="" onclick={() => (index = before as number)}>Back</button>
  {/if}
  {#if after !== undefined}
    <button type="button" data-next="" onclick={next}>Next</button>
  {:else}
    <button type="submit">Save</button>
  {/if}
</Form>
svelte
<script lang="ts">
  import type { FormStore } from '@formisch/svelte'
  import type { LayoutNode, VisibilityState } from '@whatsaaaa/form-builder-schema'
  import type { Snippet } from 'svelte'
  import ArrayField from './ArrayField.svelte'
  import Layout from './Layout.svelte'
  import ValueField from './ValueField.svelte'

  let {
    form,
    visibility,
    nodes,
    indexes,
    slots,
  }: {
    form: FormStore
    /** What the conditions hide now: the nodes it hides are left out. */
    visibility: VisibilityState
    nodes: readonly LayoutNode[]
    /** The index of each array item the nodes are in, outermost first. */
    indexes: readonly number[]
    /** The content of the layout's slots, by name. */
    slots: Record<string, Snippet>
  } = $props()

  const shown = $derived(visibility.shownNodes(nodes, indexes))
</script>

{#each shown as node (node.key)}
  {#if node.type === 'section'}
    <fieldset>
      <legend>{node.section.label}</legend>
      <Layout {form} {visibility} nodes={node.children} {indexes} {slots} />
    </fieldset>
  {:else if node.type === 'row'}
    <!-- One line of a 12-column grid, where each cell takes its span. -->
    <div style="display: grid; grid-template-columns: repeat(12, 1fr)">
      {#each node.children as cell (cell.key)}
        <div data-span={cell.span} style:grid-column="span {cell.span}">
          <Layout {form} {visibility} nodes={[cell]} {indexes} {slots} />
        </div>
      {/each}
    </div>
  {:else if node.type === 'slot'}
    {@render slots[node.name]?.()}
  {:else if node.node.role === 'display'}
    {#if node.node.type === 'heading'}
      <svelte:element this={`h${Number(node.node.field.level ?? 2)}`}>
        {node.node.field.text}
      </svelte:element>
    {:else if node.node.type === 'paragraph'}
      <p>{node.node.field.text}</p>
    {:else}
      <hr />
    {/if}
  {:else if node.node.role === 'group'}
    <fieldset>
      <legend>{node.node.field.label ?? node.node.field.name}</legend>
      <Layout {form} {visibility} nodes={node.children} {indexes} {slots} />
    </fieldset>
  {:else if node.node.role === 'array'}
    <ArrayField {form} {visibility} {node} {indexes} />
  {:else}
    <ValueField {form} node={node.node} {indexes} />
  {/if}
{/each}
svelte
<script lang="ts">
  import { type FormStore, insert, remove, useFieldArray } from '@formisch/svelte'
  import {
    type LayoutField,
    toFieldPath,
    type VisibilityState,
  } from '@whatsaaaa/form-builder-schema'
  import { builder } from './builder'
  import Layout from './Layout.svelte'

  // A list: Formisch's field array, and the layout of each item. A slot never is in one.
  let {
    form,
    visibility,
    node,
    indexes,
  }: {
    form: FormStore
    visibility: VisibilityState
    node: LayoutField
    indexes: readonly number[]
  } = $props()

  // Derived, so the path follows the item when one before it is removed. It comes from the
  // definition at runtime, so Formisch's types can't check it.
  const path = $derived(toFieldPath(node.node.dataPath, indexes))
  const array = useFieldArray(
    () => form,
    () => ({ path: path as never }),
  )
  const add = () =>
    insert(form, {
      path: path as never,
      initialInput: builder.getItemInput(node.node.field) as never,
    })
</script>

<fieldset>
  <legend>{node.node.field.label ?? node.node.field.name}</legend>
  {#each array.items as item, index (item)}
    <div>
      <!-- Each item hides its own fields, by the fields of the item. -->
      <Layout {form} {visibility} nodes={node.children} indexes={[...indexes, index]} slots={{}} />
      <button
        type="button"
        data-remove={`${path.join('.')}.${index}`}
        onclick={() => remove(form, { path: path as never, at: index })}
      >
        Remove
      </button>
    </div>
  {/each}
  <button type="button" data-add={path.join('.')} onclick={add}>Add</button>
  {#if array.errors}
    <small data-error={path.join('.')}>{array.errors[0]}</small>
  {/if}
</fieldset>
svelte
<script lang="ts">
  import { type FormStore, useField } from '@formisch/svelte'
  import {
    coerceInput,
    type FieldNode,
    isRequired,
    toFieldPath,
  } from '@whatsaaaa/form-builder-schema'

  type Option = { value: string | number | boolean; label: string }

  /** The `<input>` type of each field type that has one of its own. Any other type is text. */
  const inputTypes: Record<string, string> = {
    email: 'email',
    password: 'password',
    url: 'url',
    tel: 'tel',
    number: 'number',
    date: 'date',
    time: 'time',
    datetime: 'datetime-local',
  }

  let { form, node, indexes }: { form: FormStore; node: FieldNode; indexes: readonly number[] } =
    $props()

  const path = $derived(toFieldPath(node.dataPath, indexes))
  const field = useField(
    () => form,
    () => ({ path: path as never }),
  )
  // Turns what the element gives into the field's value, e.g. '5' into 5 for a number.
  const set = (input: unknown) => field.onInput(coerceInput(input, node) as never)
  const input = $derived(field.input as unknown)
  const picked = $derived(Array.isArray(input) ? (input as unknown[]) : [])
  const options = $derived((node.field.options ?? []) as Option[])
  const multiple = $derived(node.kind === 'array')

  // A select's value is the option's index, and the option sets its own value.
  const choose = (element: HTMLSelectElement) => {
    const values = [...element.selectedOptions].map((option) => options[Number(option.value)]?.value)
    set(multiple ? values : values[0])
  }
  // A checkbox of a multiselect adds its option's value, or takes it out.
  const toggle = (option: Option, checked: boolean) =>
    set(checked ? [...picked, option.value] : picked.filter((value) => value !== option.value))
</script>

{#snippet label()}
  <span>{node.field.label ?? node.field.name}{isRequired(node.field) ? ' *' : ''}</span>
{/snippet}

{#snippet error()}
  {#if field.errors}
    <small data-error={path.join('.')}>{field.errors[0]}</small>
  {/if}
{/snippet}

<!-- Radio buttons and checkboxes set their option's own value, so 2 stays a number. -->
{#if node.type === 'radio' || (node.type === 'multiselect' && node.field.ui?.widget === 'checkboxes')}
  <fieldset>
    <legend>{@render label()}</legend>
    {#each options as option (option.value)}
      <label>
        {#if node.type === 'radio'}
          <input
            {...field.props}
            type="radio"
            checked={input === option.value}
            oninput={() => set(option.value)}
          />
        {:else}
          <input
            {...field.props}
            type="checkbox"
            checked={picked.includes(option.value)}
            oninput={(e) => toggle(option, e.currentTarget.checked)}
          />
        {/if}
        {option.label}
      </label>
    {/each}
    {@render error()}
  </fieldset>
{:else}
  <label>
    {@render label()}
    {#if node.type === 'checkbox'}
      <input
        {...field.props}
        type="checkbox"
        checked={input === true}
        oninput={(e) => set(e.currentTarget.checked)}
      />
    {:else if options.length}
      <select {...field.props} {multiple} oninput={(e) => choose(e.currentTarget)}>
        {#if !multiple}
          <option value={-1}>Choose…</option>
        {/if}
        {#each options as option, i (option.value)}
          <option
            value={i}
            selected={multiple ? picked.includes(option.value) : input === option.value}
          >
            {option.label}
          </option>
        {/each}
      </select>
    {:else if node.type === 'file'}
      <!-- Never set a file input's value. It gives a FileList, which coerceInput reads. -->
      <input
        {...field.props}
        type="file"
        {multiple}
        accept={node.field.accept as string | undefined}
        oninput={(e) => set(e.currentTarget.files)}
      />
    {:else if node.type === 'textarea'}
      <textarea
        {...field.props}
        value={(input as string | undefined) ?? ''}
        oninput={(e) => set(e.currentTarget.value)}
      ></textarea>
    {:else}
      <input
        {...field.props}
        type={inputTypes[node.type] ?? 'text'}
        value={(input as string | number | undefined) ?? ''}
        placeholder={node.field.placeholder}
        oninput={(e) => set(e.currentTarget.value)}
      />
    {/if}
    {@render error()}
  </label>
{/if}
ts
import { affects } from '@whatsaaaa/form-builder-affects'
import { createSchemaBuilder } from '@whatsaaaa/form-builder-schema'

/**
 * One builder for the schema, the initial input, the layout, what the conditions hide, and new
 * array items. With affects, the fields' conditions hide them: the schema doesn't validate them,
 * and the view leaves them out.
 */
export const builder = createSchemaBuilder({ extensions: [affects()] })

Making it yours ​

  • The data-error, data-add, data-remove, data-next, data-back and data-span attributes only mark elements for the tests. Leave them out.
  • A row's grid has 12 columns at every width here. On a narrow screen, give each cell the whole row with a media query.
  • The Vue and React adapters also restore a step's errors when the user comes back to it, revalidate a step that failed Next as the user fixes it, and treat a submit before the last step as Next. See multi-step forms for the behavior to copy.