form-graph

Job posting wizard

One graph, zero wizard awareness: the steps are key lists declared next to the UI (see STEPS in the source), and each "Next" is store.validate(step.keys) — it judges only that step's fields, so step 3's requireds can't scold on step 1, and going back shows everything remembered. Step 2 branches on employment type; its key list names both arms — the inactive one is vacuously valid. Step 3's gate is a whole list(). The store runs revalidate: 'touched': after a failed "Next" surfaces an error (and focusFirstError jumps to it), fixing the field clears it as you type.

view source — job-form.ts (90 lines)
import { z } from 'zod';
import { boolOf, branch, defineGraph, enumOf, list, slider, textOf } from '$lib/core/index.js';

/**
 * The wizard demo — one graph, zero wizard awareness.
 *
 * Nothing here knows about steps: no step registry, no per-step schemas, no
 * "page 2 fields". The WIZARD is a UI concern — each step is a key list
 * declared next to its component, and a step's "Next" is
 * `store.validate(STEP_KEYS)`: it judges only those fields, so a later
 * step's requireds can't scold early, and a step whose fields left the
 * active branch gates nothing (list every arm's keys; inactive ones are
 * vacuously valid).
 *
 * The same graph would serve the whole form on one page unchanged — that is
 * the point.
 */

const fullTime = defineGraph()
  .field('salary', slider({ min: 40_000, max: 400_000, step: 5_000, default: 120_000 }))
  .field('equity', boolOf({ default: false }));

const contract = defineGraph()
  .field('hourlyRate', slider({ min: 20, max: 400, step: 5, default: 95 }))
  .field('maxHours', slider({ min: 5, max: 40, step: 5, default: 20 }));

const question = defineGraph()
  .field('prompt', {
    input: z.string().optional(),
    output: z.string().min(1, 'Question text is required'),
    default: '',
  })
  .field(
    'kind',
    enumOf({
      options: [
        { value: 'text', label: 'Free text' },
        { value: 'yesno', label: 'Yes / no' },
      ],
      default: 'text',
    })
  );

export const jobForm = defineGraph()
  // step 1 — basics
  .field('title', {
    input: z.string().optional(),
    output: z.string().min(1, 'Job title is required'),
    default: '',
  })
  .field('company', {
    input: z.string().optional(),
    output: z.string().min(1, 'Company is required'),
    default: '',
  })
  .field('remote', boolOf({ default: true }))
  // step 2 — compensation branches on the employment type
  .field(
    'employmentType',
    enumOf({
      options: [
        { value: 'fullTime', label: 'Full-time' },
        { value: 'contract', label: 'Contract' },
      ],
      default: 'fullTime',
    })
  )
  .use(
    branch('employmentType', [
      [['fullTime'], fullTime],
      [['contract'], contract],
    ] as const)
  )
  // step 3 — screening questions: the step gate is a whole LIST
  .use(list('questions', question, { min: 1, max: 5 }));

/**
 * The steps, OUTSIDE the graph — a key list per step, beside the UI that
 * renders it. Step 2 lists BOTH arms' keys: whichever arm is inactive is
 * vacuously valid, so one list serves every branch.
 */
export const STEPS = [
  { label: 'Basics', keys: ['title', 'company', 'remote'] },
  {
    label: 'Compensation',
    keys: ['employmentType', 'salary', 'equity', 'hourlyRate', 'maxHours'],
  },
  { label: 'Screening', keys: ['questions'] },
  { label: 'Review', keys: [] },
] as const;