Theme
Design your own
On this page (7)

§The graph, live

§Onboarding, drawn and driven both ways

The flow is linkDiagram(form, figure): an Illustrative Diagram built from toDiagram(form) - every possible edge with its choice or rule as the label - redrawn on every move: the steps taken grey out as done, the edges walked turn solid, the current step is the accent node and the edge just walked carries a flow token. It drives the form too: click a step you took to go back to it, or the next step once the current one is answered. A step further on is refused - the form says what to answer first.

§Checking a flow

analyze(form) returns { ok, errors, warnings, nodes, edges }. Errors: an edge to a step that does not exist, a cycle (a questionnaire must end), a dead end (a step that leads nowhere and is no data-end), no end at all or none reachable, a rule or data-depends-on that reads a field no step asks. Warnings: steps no path reaches, and fields a rule reads that a path can arrive without - computed with dominators: a field is only guaranteed at a step when every path to it passes a step where the field is required.

§A broken flow, explained

This flow is wrong on purpose. The list is analyze(form): a jump to a missing step, a loop between 'details' and 'confirm', a dead end, an unreachable step, a rule reading a field only one branch asks, a dependency on a field nobody asks. The form also carries data-flow-invalid.

§Why dominators

A rule on step S that reads the answer f is only safe if every way to S asked f - otherwise some users reach S without it, the condition sees nothing, and the branch quietly goes the other way. A step D dominates S when every path from the start to S passes D; the fields that are required on S's dominators are exactly the fields guaranteed at S. In the broken flow above, coupon is asked only on the B branch, so the rule on details gets a warning. In the onboarding flow the rule on size reads size itself - always safe.

§CSS view file

The graph is computed by the questionnaire's script (analyze, toDiagram, linkDiagram, toMermaid); the drawing is the Illustrative Diagram component (or Mermaid).

/* -- Questionnaire ------------------------------------------------- */
/* One question at a time: a <form> whose steps are <fieldset>s; the  */
/* controller shows one (hidden on the others) - without JavaScript    */
/* every step shows and it is a plain long form.                       */
@layer components {
  .questionnaire {
    display: grid;
    gap: 1.25rem;
    width: 100%;
    color: var(--foreground);
    &[data-busy] {
      cursor: progress;
      & .questionnaire-actions {
        opacity: 0.6;
        pointer-events: none;
      }
    }
  }
  .questionnaire-block {
    display: grid;
    gap: 1rem;
  }
  /* the block's own heading (optional) */
  .questionnaire-block-title {
    margin: 0;
    color: var(--muted-foreground);
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
  }
  /* -- a step ----------------------------------------------------- */
  .questionnaire-step {
    display: grid;
    gap: 0.75rem;
    min-width: 0;
    margin: 0;
    padding: 0;
    border: 0;
    &:not([hidden]) {
      animation: questionnaire-in 220ms ease both;
    }
  }
  .questionnaire-title {
    padding: 0;
    margin-block-end: 0.25rem;
    font-size: 1.375rem;
    font-weight: 600;
    line-height: 1.3;
    letter-spacing: -0.01em;
    text-wrap: balance;
  }
  .questionnaire-description {
    margin: 0;
    color: var(--muted-foreground);
    font-size: 0.9375rem;
    text-wrap: pretty;
  }
  .questionnaire-field {
    display: grid;
    gap: 0.375rem;
  }
  /* -- choices: cards with a key badge (A, B, C …) ------------------ */
  .questionnaire-choices {
    display: grid;
    gap: 0.5rem;
    &[data-columns="2"] {
      grid-template-columns: repeat(auto-fill, minmax(min(100%, 12rem), 1fr));
    }
  }
  .questionnaire-choice {
    position: relative;
    display: flex;
    align-items: center;
    gap: 0.75rem;
    min-height: 2.75rem;
    padding: 0.5rem 0.875rem 0.5rem 0.5rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    background: var(--background);
    cursor: pointer;
    transition: border-color 120ms ease, background-color 120ms ease;
    /* the native control stays (keyboard, form data), the card shows the state */
    & > input[type="radio"],
    & > input[type="checkbox"] {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      margin: 0;
      opacity: 0;
      cursor: pointer;
    }
    &::before {
      content: attr(data-key);
      flex: none;
      display: grid;
      place-items: center;
      min-width: 1.5rem;
      height: 1.5rem;
      padding-inline: 0.25rem;
      border: 1px solid var(--border);
      border-radius: var(--radius-sm);
      background: var(--card);
      color: var(--muted-foreground);
      font-size: 0.75rem;
      font-weight: 600;
      font-variant-numeric: tabular-nums;
    }
    &:not([data-key])::before {
      content: none;
    }
    &:hover {
      border-color: color-mix(in oklch, var(--primary) 45%, var(--border));
      background: color-mix(in oklch, var(--accent) 50%, var(--background));
    }
    &:has(> input:checked) {
      border-color: var(--primary);
      background: color-mix(in oklch, var(--primary) 8%, var(--background));
      &::before {
        border-color: var(--primary);
        background: var(--primary);
        color: var(--primary-foreground);
      }
    }
    &:has(> input:focus-visible) {
      outline: 2px solid var(--ring);
      outline-offset: 2px;
    }
    &:has(> input:disabled) {
      opacity: 0.5;
      cursor: not-allowed;
    }
  }
  .questionnaire-choice-label {
    flex: 1;
    min-width: 0;
  }
  /* secondary text in a choice */
  .questionnaire-choice-hint {
    color: var(--muted-foreground);
    font-size: 0.8125rem;
  }
  /* freeform "Other": typing in it picks its choice; above the card's input */
  .questionnaire-other {
    position: relative;
    z-index: 1;
    flex: 1;
    min-width: 0;
    height: 2rem;
    padding-inline: 0.5rem;
    border: 1px solid var(--input);
    border-radius: var(--radius-sm);
    background: var(--background);
    color: var(--foreground);
    font: inherit;
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 1px;
    }
  }
  /* -- validation ----------------------------------------------------- */
  .questionnaire-error {
    margin: 0;
    color: var(--destructive);
    font-size: 0.875rem;
    font-weight: 500;
  }
  .questionnaire-step[aria-invalid="true"] .questionnaire-choice {
    border-color: color-mix(in oklch, var(--destructive) 55%, var(--border));
  }
  /* -- progress --------------------------------------------------------- */
  .questionnaire-progress {
    display: grid;
    gap: 0.5rem;
  }
  .questionnaire-progress-label {
    color: var(--muted-foreground);
    font-size: 0.8125rem;
    font-variant-numeric: tabular-nums;
  }
  .questionnaire-bar {
    width: 100%;
    height: 0.375rem;
    border: 0;
    border-radius: 999px;
    background: var(--muted);
    accent-color: var(--primary);
    appearance: none;
    overflow: hidden;
    &::-webkit-progress-bar {
      background: var(--muted);
    }
    &::-webkit-progress-value {
      background: var(--primary);
      border-radius: 999px;
      transition: inline-size 300ms ease;
    }
    &::-moz-progress-bar {
      background: var(--primary);
      border-radius: 999px;
    }
  }
  .questionnaire-blocks {
    display: flex;
    flex-wrap: wrap;
    gap: 0.25rem 1rem;
    margin: 0;
    padding: 0;
    list-style: none;
    font-size: 0.8125rem;
  }
  .questionnaire-block-chip {
    display: inline-flex;
    align-items: center;
    gap: 0.375rem;
    color: var(--muted-foreground);
    &::before {
      content: "";
      width: 0.5rem;
      height: 0.5rem;
      border: 1.5px solid currentColor;
      border-radius: 50%;
    }
    &[data-status="done"]::before {
      background: currentColor;
    }
    &[data-status="current"] {
      color: var(--foreground);
      font-weight: 600;
      &::before {
        border-color: var(--primary);
        background: var(--primary);
      }
    }
  }
  /* -- navigation --------------------------------------------------------- */
  .questionnaire-actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
    & [data-questionnaire="next"],
    & [data-questionnaire="submit"] {
      margin-inline-start: auto;
    }
  }
  /* a draft came back / answers were cleared */
  .questionnaire-notice {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem 0.75rem;
    padding: 0.5rem 0.75rem;
    border-radius: var(--radius-md);
    background: var(--muted);
    color: var(--muted-foreground);
    font-size: 0.8125rem;
  }
  .questionnaire-notice-action,
  .questionnaire-edit {
    padding: 0;
    border: 0;
    background: none;
    color: var(--primary);
    font: inherit;
    font-weight: 500;
    text-decoration: underline;
    text-underline-offset: 2px;
    cursor: pointer;
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 2px;
      border-radius: 2px;
    }
  }
  /* -- branch history ------------------------------------------------------- */
  .questionnaire-trail-list {
    display: grid;
    gap: 0.125rem;
    margin: 0;
    padding: 0;
    list-style: none;
    counter-reset: questionnaire-trail;
    & li {
      counter-increment: questionnaire-trail;
    }
    & li[data-status="ahead"] {
      opacity: 0.55;
    }
  }
  .questionnaire-trail-item {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: 0 0.5rem;
    width: 100%;
    padding: 0.375rem 0.5rem;
    border: 0;
    border-radius: var(--radius-sm);
    background: none;
    color: inherit;
    font: inherit;
    text-align: start;
    cursor: pointer;
    &::before {
      content: counter(questionnaire-trail);
      grid-row: span 2;
      display: grid;
      place-items: center;
      width: 1.25rem;
      height: 1.25rem;
      border-radius: 50%;
      background: var(--muted);
      color: var(--muted-foreground);
      font-size: 0.6875rem;
      font-weight: 600;
    }
    &[aria-current="step"] {
      background: var(--accent);
      color: var(--accent-foreground);
      &::before {
        background: var(--primary);
        color: var(--primary-foreground);
      }
    }
    &:hover {
      background: var(--accent);
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: -2px;
    }
  }
  .questionnaire-trail-question {
    font-size: 0.8125rem;
    font-weight: 500;
  }
  .questionnaire-trail-answer {
    overflow: hidden;
    color: var(--muted-foreground);
    font-size: 0.75rem;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  /* -- review --------------------------------------------------------------- */
  .questionnaire-summary-list {
    display: grid;
    margin: 0;
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
  }
  .questionnaire-summary-row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: 0.25rem 1rem;
    padding: 0.625rem 0.875rem;
    font-size: 0.875rem;
    & + & {
      border-top: 1px solid var(--border);
    }
    & dt {
      color: var(--muted-foreground);
    }
    & dd {
      display: flex;
      justify-content: space-between;
      gap: 0.75rem;
      margin: 0;
      font-weight: 500;
    }
  }
  .questionnaire-complete {
    display: grid;
    gap: 0.5rem;
    animation: questionnaire-in 220ms ease both;
  }
  /* the controller hides with [hidden]: it must win over every display above
     (and over a .btn's own display) */
  .questionnaire :is(.questionnaire-block, .questionnaire-step, .questionnaire-actions, .questionnaire-complete, .questionnaire-notice, .questionnaire-error, [data-questionnaire])[hidden] {
    display: none;
  }
  @keyframes questionnaire-in {
    from {
      opacity: 0;
      translate: 0 0.5rem;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  @layer components {
    .questionnaire-step:not([hidden]),
    .questionnaire-complete,
    .questionnaire-choice,
    .questionnaire-bar::-webkit-progress-value {
      animation: none;
      transition: none;
    }
  }
}
@media (prefers-contrast: more) {
  @layer components {
    .questionnaire-choice,
    .questionnaire-summary-list {
      border-color: var(--foreground);
    }
    .questionnaire-description,
    .questionnaire-progress-label,
    .questionnaire-trail-answer {
      color: var(--foreground);
    }
  }
}
@media (forced-colors: active) {
  @layer components {
    .questionnaire-choice {
      border-color: ButtonText;
      &:has(> input:checked) {
        border-color: Highlight;
        outline: 2px solid Highlight;
      }
    }
    .questionnaire-bar {
      border: 1px solid CanvasText;
    }
    .questionnaire-error {
      color: LinkText;
    }
  }
}

§JS view file

// -- Questionnaire --------------------------------------------
// A branching step-flow controller over ONE native <form>: every step is a
// <fieldset>, grouped into blocks; the flow is a graph - an option's
// data-goto, the rules' conditional branches, a step's data-next, else the
// next step in the markup - and the controller walks it: one step at a time,
// validated before it is left (native constraints, choice counts, cross-field
// assertions, your own functions), the path taken kept as a branch history
// (back goes where you came from, not to the previous fieldset), answers that
// a changed answer cuts off the path - or that declare they depend on it -
// invalidated, the whole draft kept in session storage so a reload loses
// nothing. Without JavaScript the form is a plain long form.
//
// The state is the walk (AGENTS.md "State through stores"):
// el.store.value.config = { step, answers, history, index }.
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals, defussQuery, componentState, bindComponent, persisted, viewPersistence } from '../../../shared/state-api.js';
import type { ViewPersistence } from '../../../shared/store.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type
// this file's API docs state - arguments, return values, event details - holds
// against its code: a wrong one is a new type error and fails the build.
/** One answer: a text / select value, a number field, a checkbox (boolean, or the checked values of a group), null when unanswered. */
type QuestionnaireAnswer = string | number | boolean | string[] | null;
/** Every answer so far, by field name. */
type QuestionnaireAnswers = Record<string, QuestionnaireAnswer>;
/** One condition on an answer. */
interface QuestionnaireCondition {
  /** the field it reads */
  field: string;
  /** how it compares (default 'eq': equal, case-insensitive; on a list: includes) */
  op?: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'includes' | 'contains' | 'startsWith' | 'endsWith' | 'answered' | 'empty';
  /** what it compares with: a value, or { field } for another answer */
  value?: QuestionnaireAnswer | QuestionnaireAnswer[] | { field: string };
}
/** When a rule applies: one condition, a list (all must hold), or { any } / { all }. */
type QuestionnaireWhen = QuestionnaireCondition | QuestionnaireCondition[] | { any: QuestionnaireCondition[] } | { all: QuestionnaireCondition[] };
/** A branch out of a step. */
interface QuestionnaireBranch {
  /** when it is taken */
  when: QuestionnaireWhen;
  /** the step it leads to */
  goto: string;
}
/** A cross-field check a step must pass. */
interface QuestionnaireAssert {
  /** the conditions that must hold */
  assert: QuestionnaireWhen;
  /** shown when they do not (default 'Check this answer.') */
  message?: string;
  /** the field the message points at */
  field?: string;
}
/** What configure() takes - merged over the markup's script.questionnaire-rules. */
interface QuestionnaireConfig {
  /** per step id: the branches, tried in order before the step's data-next */
  branches?: Record<string, QuestionnaireBranch[]>;
  /** per step id: checks, or a function of (all answers, the step's answers) returning a message when invalid */
  validate?: Record<string, QuestionnaireAssert[] | ((answers: QuestionnaireAnswers, stepAnswers: QuestionnaireAnswers) => string | null | undefined)>;
  /** called on submit; a rejection keeps the review and shows why */
  onSubmit?: (answers: QuestionnaireAnswers, context: { history: string[] }) => void | Promise<void>;
  /** where the draft is kept (default: session storage under a generated key) */
  persist?: ViewPersistence;
}
/** A step of the flow graph. */
interface QuestionnaireNode {
  /** its data-step id */
  id: string;
  /** its legend */
  title: string;
  /** whether it is an end (data-end) */
  end: boolean;
  /** the id of its block, '' outside one */
  block: string;
}
/** A way between two steps. */
interface QuestionnaireEdge {
  /** the step it leaves */
  from: string;
  /** the step it leads to */
  to: string;
  /** the choice or rule text that takes it, '' for the default way */
  label: string;
  /** what makes it: an option's data-goto, a branch rule, or the default next step */
  kind: 'choice' | 'rule' | 'next';
}
/** What analyze() returns. */
interface QuestionnaireAnalysis {
  /** true when there are no errors */
  ok: boolean;
  /** missing targets, cycles, dead ends, no end step */
  errors: string[];
  /** unreachable steps, fields a rule reads that a path may arrive without */
  warnings: string[];
  /** every step */
  nodes: QuestionnaireNode[];
  /** every way between steps */
  edges: QuestionnaireEdge[];
}
/** An Illustrative Diagram JSON spec - df$.shadcn.diagram.build() renders it. */
type QuestionnaireDiagramSpec = Record<string, unknown>;
/** What questionnaire-invalid carries. */
interface QuestionnaireInvalidDetail {
  /** the step that cannot be left */
  step: string;
  /** the message shown */
  message: string;
}
/** What questionnaire-invalidate carries. */
interface QuestionnaireInvalidateDetail {
  /** the step whose answers changed */
  cause: string;
  /** the fields that changed */
  changed: string[];
  /** the steps whose answers were cleared */
  cleared: string[];
}
/** What questionnaire-step carries. */
interface QuestionnaireStepDetail {
  /** the step now shown */
  step: string;
  /** the step left */
  from: string;
  /** every answer so far */
  answers: QuestionnaireAnswers;
}
/** What questionnaire-submit carries. */
interface QuestionnaireSubmitDetail {
  /** the answers on the path taken */
  answers: QuestionnaireAnswers;
  /** the steps taken, in order */
  history: string[];
}
/** What questionnaire-jump-refused carries. */
interface QuestionnaireJumpRefusedDetail {
  /** the step the diagram click asked for */
  to: string;
  /** the step the form is on */
  step: string;
  /** 'unreached': further than the answers lead; 'invalid': the current step does not validate */
  reason: 'unreached' | 'invalid';
}
const questionnaireStates = ['default', 'answering', 'review', 'submitted'];
/** setState() configs per state - merged into the stored one ({ step } keeps the answers); getState() reports the walk. */
export interface QuestionnaireStateConfigs {
  /** At the start step. */
  default: {
    /** replace the answers (the fields are filled from them) */
    answers?: QuestionnaireAnswers;
    /** reported by getState(): the step shown */
    step?: string;
    /** reported by getState(): the steps taken, in order */
    history?: string[];
    /** reported by getState(): the current step's position in history */
    index?: number;
    /** reported by getState(): the optional steps skipped */
    skipped?: string[];
  };
  /** On a later step. */
  answering: {
    /** the step to show (one the answers reach) */
    step?: string;
    /** replace the answers */
    answers?: QuestionnaireAnswers;
    /** reported by getState(): the steps taken, in order */
    history?: string[];
    /** reported by getState(): the current step's position in history */
    index?: number;
    /** reported by getState(): the optional steps skipped */
    skipped?: string[];
  };
  /** On an end step - the summary and Send. */
  review: {
    /** the end step to show (default: the first end) */
    step?: string;
    /** replace the answers */
    answers?: QuestionnaireAnswers;
    /** reported by getState(): the steps taken, in order */
    history?: string[];
    /** reported by getState(): the current step's position in history */
    index?: number;
    /** reported by getState(): the optional steps skipped */
    skipped?: string[];
  };
  /** Sent - the steps give way to the .questionnaire-complete message. */
  submitted: {
    /** reported by getState(): the answers sent */
    answers?: QuestionnaireAnswers;
    /** reported by getState(): the end step */
    step?: string;
    /** reported by getState(): the steps taken, in order */
    history?: string[];
    /** reported by getState(): the end step's position in history */
    index?: number;
    /** reported by getState(): the optional steps skipped */
    skipped?: string[];
  };
}
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
// -- the flow: steps, rules, graph -----------------------------------------------
const stepsOf = (root) => dfDollar(root).find('.questionnaire-step[data-step]').toArray();
const stepById = (root, id) => root._flow?.byId.get(id) ?? null;
const titleOf = (step) => (dfDollar(step).find('.questionnaire-title').get(0)?.textContent ?? step.dataset.step).trim();
/** A step's answer controls - inputs, selects and textareas share type, name, value and validation. */
type FormControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
const controlsOf = (step) => dfDollar(step).find<FormControl>('input[name], select[name], textarea[name]').toArray().filter((c) => c.type !== 'hidden' || c.dataset.answer !== undefined);
const isEnd = (step) => !!step && step.hasAttribute('data-end');
/** the author's rules: <script type="application/json" class="questionnaire-rules"> + configure() */
function rulesOf(root) {
  let authored: QuestionnaireConfig = {};
  const script = dfDollar(root).find('script.questionnaire-rules').get(0);
  if (script) {
    try {
      authored = JSON.parse(script.textContent || '{}');
    } catch (error) {
      console.warn(`questionnaire #${root.id}: the rules are not JSON -`, error);
    }
  }
  const config = root._config || {};
  return {
    branches: { ...authored.branches, ...config.branches },
    validate: { ...authored.validate, ...config.validate },
  };
}
/** read the flow from the markup (once; configure() reads it again) */
function readFlow(root) {
  const steps = stepsOf(root);
  const byId = new Map(steps.map((s) => [s.dataset.step, s]));
  const blocks = [];
  for (const step of steps) {
    const block = step.closest<HTMLElement>('.questionnaire-block');
    const id = block?.dataset.block || '';
    if (!blocks.some((b) => b.id === id)) blocks.push({ id, title: block?.dataset.title || '', el: block });
  }
  root._flow = { steps, byId, blocks, rules: rulesOf(root), start: root.dataset.start || steps[0]?.dataset.step };
}
/** a value an assertion compares with: a literal, or { field } - another answer */
const operand = (value, answers) => (value && typeof value === 'object' && !Array.isArray(value) && 'field' in value ? answers[value.field] : value);
const lower = (v) => String(v ?? '').toLowerCase();
/** order two answers: numbers (and numeric strings) by value, the rest as
 *  strings - ISO dates ("2026-10-04") compare correctly that way */
function compare(a, b) {
  const na = typeof a === 'number' ? a : a === '' || a == null ? NaN : Number(a);
  const nb = typeof b === 'number' ? b : b === '' || b == null ? NaN : Number(b);
  if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
  const sa = String(a);
  const sb = String(b);
  return sa < sb ? -1 : sa > sb ? 1 : 0;
}
const isEmpty = (v) => v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
/** one condition: { field, op, value } - dataview's operators, plus answered / empty / includes */
function holds(cond, answers) {
  const v = answers[cond.field];
  const target = operand(cond.value, answers);
  switch (cond.op) {
    case 'answered': return !isEmpty(v);
    case 'empty': return isEmpty(v);
    case 'neq': return Array.isArray(v) ? !v.includes(target) : lower(v) !== lower(target);
    case 'gt': return !isEmpty(v) && !isEmpty(target) && compare(v, target) > 0;
    case 'gte': return !isEmpty(v) && !isEmpty(target) && compare(v, target) >= 0;
    case 'lt': return !isEmpty(v) && !isEmpty(target) && compare(v, target) < 0;
    case 'lte': return !isEmpty(v) && !isEmpty(target) && compare(v, target) <= 0;
    case 'in': return (Array.isArray(target) ? target : [target]).some((t) => (Array.isArray(v) ? v.includes(t) : lower(v) === lower(t)));
    case 'includes': return Array.isArray(v) ? v.includes(target) : lower(v).includes(lower(target));
    case 'contains': return lower(v).includes(lower(target));
    case 'startsWith': return lower(v).startsWith(lower(target));
    case 'endsWith': return lower(v).endsWith(lower(target));
    default: return Array.isArray(v) ? v.includes(target) : lower(v) === lower(target);
  }
}
/** a rule's `when`: a list (all must hold) or { any: [...] } */
function when(rule, answers) {
  const w = rule.when ?? [];
  if (Array.isArray(w)) return w.every((c) => holds(c, answers));
  if (Array.isArray(w.any)) return w.any.some((c) => holds(c, answers));
  if (Array.isArray(w.all)) return w.all.every((c) => holds(c, answers));
  return holds(w, answers);
}
/** the option a step's answer picked, with its data-goto (radio, select, checkbox) */
function gotoOf(step, answers) {
  for (const control of controlsOf(step)) {
    if ((control.type === 'radio' || control.type === 'checkbox') && control.dataset.goto) {
      const v = answers[control.name];
      if (Array.isArray(v) ? v.includes(control.value) : v === control.value || (control.type === 'checkbox' && v === true)) return control.dataset.goto;
    }
    if (control.tagName === 'SELECT') {
      const option = [...(control as HTMLSelectElement).options].find((o) => o.value === answers[control.name] && o.dataset.goto);
      if (option) return option.dataset.goto;
    }
  }
  return null;
}
/** where a step leads with these answers: option goto → rule → data-next → the next step */
function nextOf(root, stepId, answers) {
  const step = stepById(root, stepId);
  if (!step || isEnd(step)) return null;
  const picked = gotoOf(step, answers);
  if (picked) return picked;
  for (const rule of root._flow.rules.branches[stepId] ?? []) if (when(rule, answers)) return rule.goto;
  return defaultNext(root, step);
}
function defaultNext(root, step) {
  if (step.dataset.next) return step.dataset.next;
  const steps = root._flow.steps;
  return steps[steps.indexOf(step) + 1]?.dataset.step ?? null;
}
/** every edge a step may take (the graph, for analysis and progress) */
function edgesOf(root, step) {
  const out: Omit<QuestionnaireEdge, 'from'>[] = [];
  const seen = new Set();
  const add = (to, label, kind) => {
    if (!to || seen.has(to)) return;
    seen.add(to);
    out.push({ to, label, kind });
  };
  if (isEnd(step)) return out;
  let exhaustive = false;
  const radios = controlsOf(step).filter((c) => c.type === 'radio');
  for (const c of controlsOf(step)) {
    if ((c.type === 'radio' || c.type === 'checkbox') && c.dataset.goto) add(c.dataset.goto, choiceText(c), 'choice');
    if (c.tagName === 'SELECT') for (const o of (c as HTMLSelectElement).options) if (o.dataset.goto) add(o.dataset.goto, o.textContent.trim(), 'choice');
  }
  // a required single choice whose every option jumps never takes the default way
  if (radios.length && radios.every((r) => r.dataset.goto) && radios.some((r) => r.required)) exhaustive = true;
  for (const rule of root._flow.rules.branches[step.dataset.step] ?? []) add(rule.goto, ruleText(rule), 'rule');
  if (!exhaustive) add(defaultNext(root, step), '', 'next');
  return out;
}
const ruleText = (rule) => {
  const w = rule.when ?? [];
  const list = Array.isArray(w) ? w : w.any ?? w.all ?? [w];
  return list.map((c) => `${c.field} ${c.op ?? 'eq'}${c.value === undefined ? '' : ' ' + (typeof c.value === 'object' && c.value && 'field' in c.value ? c.value.field : JSON.stringify(c.value))}`).join(Array.isArray(w) || w.all ? ' and ' : ' or ');
};
/** the label text of a choice control (its own text, without key hints or a freeform input) */
function choiceText(control) {
  const label = control.closest('label') || (control.id && dfDollar(`label[for="${CSS.escape(control.id)}"]`).get(0));
  if (!label) return control.value;
  const own = dfDollar(label).find('.questionnaire-choice-label').get(0);
  return (own ?? label).textContent.trim() || control.getAttribute('aria-label') || control.value;
}
/**
 * Every step the answers can still reach: an answered (or skipped) step
 * leads where its answer leads; a step still open may take any of its
 * edges. An answered step outside this set is an orphan - the walk left its
 * branch.
 */
function reachable(root, answers, skipped) {
  const seen = new Set();
  const stack = [root._flow.start];
  while (stack.length) {
    const id = stack.pop();
    if (!id || seen.has(id)) continue;
    seen.add(id);
    const step = stepById(root, id);
    if (!step || isEnd(step)) continue;
    if (skipped.includes(id)) stack.push(defaultNext(root, step));
    else if (answeredStep(step, answers)) stack.push(nextOf(root, id, answers));
    else for (const e of edgesOf(root, step)) stack.push(e.to);
  }
  return seen;
}
const answeredStep = (step, answers) => {
  const names = [...new Set(controlsOf(step).map((c) => c.name))];
  return names.length === 0 || names.some((n) => !isEmpty(answers[n]));
};
// -- answers <-> controls -------------------------------------------------------------
/** a step's answers, by control name */
function readStep(step) {
  const out = {};
  const groups = new Map();
  for (const c of controlsOf(step)) {
    if (!groups.has(c.name)) groups.set(c.name, []);
    groups.get(c.name).push(c);
  }
  for (const [name, list] of groups) {
    const first = list[0];
    let value;
    if (first.type === 'radio') value = list.find((c) => c.checked)?.value ?? null;
    else if (first.type === 'checkbox') value = list.length > 1 || first.dataset.multiple !== undefined ? list.filter((c) => c.checked).map((c) => c.value) : first.checked;
    else if (first.tagName === 'SELECT' && first.multiple) value = [...first.selectedOptions].map((o) => o.value);
    else if (first.type === 'number' || first.type === 'range') value = first.value === '' ? null : Number(first.value);
    else value = first.value;
    out[name] = value;
  }
  return out;
}
/** put answers back into a step's controls (a restored draft, a reset) */
function writeStep(step, answers) {
  for (const c of controlsOf(step)) {
    const v = answers[c.name];
    if (c.type === 'radio') (c as HTMLInputElement).checked = v === c.value;
    else if (c.type === 'checkbox') (c as HTMLInputElement).checked = Array.isArray(v) ? v.includes(c.value) : v === true;
    else if (c.tagName === 'SELECT' && (c as HTMLSelectElement).multiple) for (const o of (c as HTMLSelectElement).options) o.selected = Array.isArray(v) && v.includes(o.value);
    else c.value = v == null ? '' : String(v);
  }
}
/** merge a step's answers in; empty values are no answer */
function mergeAnswers(answers, stepAnswers) {
  const next = { ...answers };
  for (const [k, v] of Object.entries(stepAnswers)) {
    if (isEmpty(v) || v === false) delete next[k];
    else next[k] = v;
  }
  return next;
}
/** forget a step's answers - in the data and in the controls */
function clearStep(step, answers) {
  const next = { ...answers };
  for (const c of controlsOf(step)) delete next[c.name];
  writeStep(step, next);
  return next;
}
// -- validation -----------------------------------------------------------------------
/**
 * Why a step cannot be left (null = it can): the native constraints of its
 * controls (required, min, pattern, type=email …), data-min / data-max on a
 * choice group, the rules' cross-field assertions (they may read any earlier
 * answer), then configure()'s validate functions.
 */
function invalidOf(root, step, answers) {
  for (const c of controlsOf(step)) {
    if (!c.checkValidity()) return { control: c, message: c.validationMessage };
  }
  const boxes = controlsOf(step).filter((c) => c.type === 'checkbox');
  const min = Number(step.dataset.min || 0);
  const max = Number(step.dataset.max || Infinity);
  if (boxes.length && (min || Number.isFinite(max))) {
    const n = boxes.filter((c) => (c as HTMLInputElement).checked).length;
    if (n < min) return { control: boxes[0], message: min === 1 ? 'Choose at least one.' : `Choose at least ${min}.` };
    if (n > max) return { control: boxes[0], message: `Choose at most ${max}.` };
  }
  const id = step.dataset.step;
  const rules = root._flow.rules.validate[id];
  if (Array.isArray(rules)) {
    for (const rule of rules) {
      const asserts = rule.assert ?? [];
      const ok = Array.isArray(asserts) ? asserts.every((c) => holds(c, answers)) : when({ when: asserts }, answers);
      if (!ok) return { control: rule.field ? controlsOf(step).find((c) => c.name === rule.field) : null, message: rule.message || 'Check this answer.' };
    }
  } else if (typeof rules === 'function') {
    const message = rules(answers, readStep(step));
    if (message) return { control: null, message: String(message) };
  }
  return null;
}
function errorEl(step) {
  let el = dfDollar(step).find('.questionnaire-error').get(0);
  if (!el) {
    el = document.createElement('p');
    el.className = 'questionnaire-error';
    el.id = `${step.closest('.questionnaire').id || 'questionnaire'}-${step.dataset.step}-error`;
    el.setAttribute('role', 'alert');
    el.hidden = true;
    dfDollar(step).append(el);
  }
  return el;
}
function showInvalid(step, problem) {
  const el = errorEl(step);
  el.textContent = problem.message;
  el.hidden = false;
  step.setAttribute('aria-invalid', 'true');
  step.setAttribute('aria-describedby', el.id);
  const target = problem.control || controlsOf(step)[0];
  if (target) {
    target.setAttribute('aria-invalid', 'true');
    target.focus({ preventScroll: false });
  }
}
function clearInvalid(step) {
  const el = dfDollar(step).find('.questionnaire-error').get(0);
  if (el) {
    el.hidden = true;
    el.textContent = '';
  }
  step.removeAttribute('aria-invalid');
  for (const c of controlsOf(step)) c.removeAttribute('aria-invalid');
}
// -- the walk ---------------------------------------------------------------------------
const cfgOf = (root) => root._walk;
/** where the walk is, as a state: start → default, an end → review */
function landed(root, walk) {
  if (walk.submitted) return 'submitted';
  if (isEnd(stepById(root, walk.step))) return 'review';
  return walk.index === 0 && walk.step === root._flow.start ? 'default' : 'answering';
}
/**
 * The markup of a state, for render() AND the live element: which step
 * shows (config.step - the start by default; nothing once submitted), the
 * nav buttons a step offers, the completion note, data-state.
 */
function applyMarkup(root, state) {
  const config = state.config || {};
  const steps = stepsOf(root);
  const start = root.dataset.start || steps[0]?.dataset.step;
  // the step a state shows: default = the start, review = an end step (the
  // configured one, else the first), answering = config.step
  let current = config.step || start;
  if (state.name === 'default') current = start;
  if (state.name === 'review' && !isEnd(steps.find((s) => s.dataset.step === current))) current = steps.find((s) => s.hasAttribute('data-end'))?.dataset.step ?? current;
  const submitted = state.name === 'submitted';
  const step = steps.find((s) => s.dataset.step === current);
  for (const s of steps) dfDollar(s).attr('hidden', !submitted && s === step ? null : '');
  for (const block of dfDollar(root).find('.questionnaire-block').toArray()) {
    dfDollar(block).attr('hidden', !submitted && step && block.contains(step) ? null : '');
  }
  const button = (name) => dfDollar(root).find(`[data-questionnaire="${name}"]`).toArray();
  const end = isEnd(step);
  for (const b of button('back')) dfDollar(b).attr('hidden', submitted || state.name === 'default' ? '' : null);
  for (const b of button('next')) dfDollar(b).attr('hidden', submitted || end ? '' : null);
  for (const b of button('submit')) dfDollar(b).attr('hidden', submitted || !end ? '' : null);
  for (const b of button('skip')) dfDollar(b).attr('hidden', submitted || !step?.hasAttribute('data-optional') ? '' : null);
  for (const n of dfDollar(root).find('.questionnaire-actions').toArray()) dfDollar(n).attr('hidden', submitted ? '' : null);
  for (const c of dfDollar(root).find('.questionnaire-complete').toArray()) dfDollar(c).attr('hidden', submitted ? null : '');
  dfDollar(root).attr('data-state', state.name);
}
/** the steps still ahead (shortest way to an end over every edge) - for progress */
function remainingFrom(root, id) {
  const queue = [[id, 0]];
  const seen = new Set([id]);
  while (queue.length) {
    const [at, d] = queue.shift();
    const step = stepById(root, at);
    if (!step || isEnd(step)) return d;
    for (const e of edgesOf(root, step)) {
      if (!seen.has(e.to)) {
        seen.add(e.to);
        queue.push([e.to, d + 1]);
      }
    }
  }
  return 0;
}
/** does any step ahead still branch? (then the count is an estimate) */
function remainingBranches(root, id) {
  const seen = new Set();
  const stack = [id];
  while (stack.length) {
    const at = stack.pop();
    if (seen.has(at)) continue;
    seen.add(at);
    const step = stepById(root, at);
    if (!step) continue;
    const out = edgesOf(root, step);
    if (out.length > 1) return true;
    for (const e of out) stack.push(e.to);
  }
  return false;
}
/** the progress bar, the "step n of about m" line and the block chips */
function renderProgress(root) {
  const host = dfDollar(root).find('.questionnaire-progress').get(0);
  if (!host) return;
  const walk = cfgOf(root);
  if (!host._built) {
    host._built = true;
    const bar = document.createElement('progress');
    bar.className = 'questionnaire-bar';
    bar.max = 100;
    const label = document.createElement('span');
    label.className = 'questionnaire-progress-label';
    const blocks = document.createElement('ol');
    blocks.className = 'questionnaire-blocks';
    for (const b of root._flow.blocks) {
      if (!b.title) continue;
      const li = document.createElement('li');
      li.className = 'questionnaire-block-chip';
      li.dataset.block = b.id;
      li.textContent = b.title;
      blocks.append(li);
    }
    host.append(label, bar);
    if (blocks.children.length) host.append(blocks);
  }
  const done = walk.submitted ? walk.history.length : walk.index;
  const remaining = walk.submitted ? 0 : remainingFrom(root, walk.step);
  // the current question is number done + 1; the shortest way to an end
  // passes `remaining` questions (the current one included) - the end is no question
  const endNow = isEnd(stepById(root, walk.step));
  const total = Math.max(1, done + remaining);
  const percent = walk.submitted || endNow ? 100 : Math.round((done / total) * 100);
  const bar = dfDollar(host).find<HTMLProgressElement>('.questionnaire-bar').get(0);
  bar.value = percent;
  bar.setAttribute('aria-label', `${percent}% done`);
  const label = dfDollar(host).find('.questionnaire-progress-label').get(0);
  // 'about': a branch ahead may take a longer way
  const branchy = remainingBranches(root, walk.step);
  label.textContent = walk.submitted ? 'Done' : endNow ? 'Review your answers' : `Question ${done + 1} of ${branchy ? 'about ' : ''}${total}`;
  const currentBlock = stepById(root, walk.step)?.closest('.questionnaire-block')?.dataset.block;
  const visitedBlocks = new Set(walk.history.slice(0, walk.index).map((id) => stepById(root, id)?.closest('.questionnaire-block')?.dataset.block));
  for (const chip of dfDollar(host).find('.questionnaire-block-chip').toArray()) {
    const id = chip.dataset.block;
    const status = walk.submitted ? 'done' : id === currentBlock ? 'current' : visitedBlocks.has(id) ? 'done' : 'upcoming';
    chip.dataset.status = status;
    if (status === 'current') chip.setAttribute('aria-current', 'step');
    else chip.removeAttribute('aria-current');
  }
}
/** the branch history: the steps taken, each one a way back */
function renderTrail(root) {
  // inside the form, or anywhere: <nav class="questionnaire-trail" data-for="form-id">
  const host = dfDollar(root).find('.questionnaire-trail').get(0) || (root.id && dfDollar(`.questionnaire-trail[data-for="${CSS.escape(root.id)}"]`).get(0));
  if (!host) return;
  if (!root.contains(host) && !host._wired) {
    host._wired = true;
    host.addEventListener('click', (e) => {
      const go = (e.target as HTMLElement).closest?.<HTMLElement>('[data-questionnaire-go]')?.dataset.questionnaireGo;
      if (go) goTo(root, go);
    });
  }
  const walk = cfgOf(root);
  host.textContent = '';
  const list = document.createElement('ol');
  list.className = 'questionnaire-trail-list';
  walk.history.forEach((id, i) => {
    const step = stepById(root, id);
    if (!step || isEnd(step)) return;
    const li = document.createElement('li');
    li.dataset.status = i < walk.index ? 'done' : i === walk.index ? 'current' : 'ahead';
    const button = document.createElement('button');
    button.type = 'button';
    button.className = 'questionnaire-trail-item';
    button.dataset.questionnaireGo = id;
    if (i === walk.index) button.setAttribute('aria-current', 'step');
    const q = document.createElement('span');
    q.className = 'questionnaire-trail-question';
    q.textContent = titleOf(step);
    const a = document.createElement('span');
    a.className = 'questionnaire-trail-answer';
    a.textContent = answerText(root, step, walk.answers) || (walk.skipped.includes(id) ? 'Skipped' : '—');
    button.append(q, a);
    li.append(button);
    list.append(li);
  });
  host.append(list);
}
/** an answer as words: choice labels, joined lists, the raw text */
function answerText(root, step, answers) {
  const parts = [];
  const names = [...new Set(controlsOf(step).map((c) => c.name))];
  for (const name of names) {
    const v = answers[name];
    if (isEmpty(v)) continue;
    const controls = controlsOf(step).filter((c) => c.name === name);
    const first = controls[0];
    if (first.type === 'radio' || first.type === 'checkbox') {
      const picked = controls.filter((c) => (Array.isArray(v) ? v.includes(c.value) : v === c.value || v === true));
      parts.push(picked.map(choiceText).join(', '));
    } else if (first.tagName === 'SELECT') {
      const values = Array.isArray(v) ? v : [v];
      parts.push([...(first as HTMLSelectElement).options].filter((o) => values.includes(o.value)).map((o) => o.textContent.trim()).join(', '));
    } else parts.push(String(v));
  }
  return parts.filter(Boolean).join(' · ');
}
/** the review: every answered step on the path, each with Edit */
function renderSummary(root) {
  const walk = cfgOf(root);
  const step = stepById(root, walk.step);
  const host = step && dfDollar(step).find('.questionnaire-summary').get(0);
  if (!host) return;
  host.textContent = '';
  const list = document.createElement('dl');
  list.className = 'questionnaire-summary-list';
  for (const id of walk.history.slice(0, walk.index)) {
    const s = stepById(root, id);
    if (!s || isEnd(s) || !controlsOf(s).length) continue;
    const row = document.createElement('div');
    row.className = 'questionnaire-summary-row';
    const dt = document.createElement('dt');
    dt.textContent = titleOf(s);
    const dd = document.createElement('dd');
    const text = document.createElement('span');
    text.textContent = answerText(root, s, walk.answers) || 'Skipped';
    const edit = document.createElement('button');
    edit.type = 'button';
    edit.className = 'questionnaire-edit';
    edit.dataset.questionnaireGo = id;
    edit.textContent = 'Edit';
    edit.setAttribute('aria-label', `Edit: ${titleOf(s)}`);
    dd.append(text, edit);
    row.append(dt, dd);
    list.append(row);
  }
  host.append(list);
}
/** a short note above the steps: a restored draft, cleared answers */
function notify(root, text, action?) {
  let host = dfDollar(root).find('.questionnaire-notice').get(0);
  if (!host) {
    host = document.createElement('div');
    host.className = 'questionnaire-notice';
    host.setAttribute('role', 'status');
    const first = dfDollar(root).find('.questionnaire-block, .questionnaire-step').get(0);
    if (first) dfDollar(first).before(host);
    else dfDollar(root).append(host);
  }
  host.textContent = '';
  if (!text) {
    host.hidden = true;
    return;
  }
  host.hidden = false;
  const span = document.createElement('span');
  span.textContent = text;
  host.append(span);
  if (action) {
    const b = document.createElement('button');
    b.type = 'button';
    b.className = 'questionnaire-notice-action';
    b.dataset.questionnaire = action.name;
    b.textContent = action.label;
    host.append(b);
  }
}
/** choice key hints (A, B, C … or data-key) - typed to pick */
function keyHints(root) {
  if (root.dataset.shortcuts === 'none') return;
  for (const step of root._flow.steps) {
    const choices = dfDollar(step).find('.questionnaire-choice').toArray();
    choices.forEach((choice, i) => {
      if (!choice.dataset.key) choice.dataset.key = root.dataset.shortcuts === 'digits' ? String(i + 1) : LETTERS[i] ?? '';
    });
  }
}
/** everything the walk shows besides the step itself */
function paint(root) {
  renderProgress(root);
  renderTrail(root);
  renderSummary(root);
}
/** keep the walk: the store (subscribers) and the draft */
function record(root, name?) {
  const walk = cfgOf(root);
  const config = { step: walk.step, answers: walk.answers, history: walk.history, index: walk.index, skipped: walk.skipped };
  if (root.store) questionnaireApi.commit(root, name ?? landed(root, walk), config);
  if (root._draft) root._draft.set(walk.submitted ? null : config);
}
/** show a step (no validation): the DOM side of every move */
function show(root, id, focus = true) {
  const walk = cfgOf(root);
  const step = stepById(root, id);
  if (!step) return;
  walk.step = id;
  // what the step held when it was entered: leaving it compares against this
  // (the live draft merges every keystroke, so the answers alone cannot tell)
  walk.entered = readStep(step);
  const name = landed(root, walk);
  applyMarkup(root, { name, config: { step: id } });
  root.dataset.stateName = name;
  clearInvalid(step);
  paint(root);
  if (focus) {
    const target = controlsOf(step).find((c) => c.type !== 'radio' || (c as HTMLInputElement).checked) || controlsOf(step)[0] || dfDollar(root).find('[data-questionnaire="submit"]').get(0);
    target?.focus({ preventScroll: true });
    step.scrollIntoView?.({ block: 'nearest' });
  }
}
/**
 * Leave the current step forward: validate, merge its answers, find where
 * it leads, and invalidate what the change cut off - answers on steps the
 * new path no longer reaches, and answers that declare data-depends-on a
 * changed field. A step whose answers did not change keeps the history
 * ahead (forward again goes where it went before).
 */
function advance(root, { skip = false } = {}) {
  const walk = cfgOf(root);
  const step = stepById(root, walk.step);
  if (!step || isEnd(step)) return false;
  const before = walk.answers;
  const mine = readStep(step);
  let answers = skip ? clearStep(step, before) : mergeAnswers(before, mine);
  if (!skip) {
    const problem = invalidOf(root, step, answers);
    if (problem) {
      showInvalid(step, problem);
      // Fires when a step cannot be left - the step and the message shown.
      root.dispatchEvent(new CustomEvent<QuestionnaireInvalidDetail>('questionnaire-invalid', { bubbles: true, detail: { step: walk.step, message: problem.message } }));
      return false;
    }
  }
  clearInvalid(step);
  const skipped = skip ? [...new Set([...walk.skipped, walk.step])] : walk.skipped.filter((s) => s !== walk.step);
  const was = mergeAnswers({}, walk.entered || {});
  const now = mergeAnswers({}, skip ? {} : mine);
  const changed = Object.keys({ ...was, ...now }).filter((k) => JSON.stringify(was[k]) !== JSON.stringify(now[k]));
  const to = skip ? defaultNext(root, step) : nextOf(root, walk.step, answers);
  if (!to || !stepById(root, to)) {
    console.warn(`questionnaire #${root.id}: step "${walk.step}" leads nowhere (${to ?? 'no next step'})`);
    return false;
  }
  const ahead = walk.history[walk.index + 1];
  let history = walk.history;
  if (ahead !== to || changed.length) history = [...walk.history.slice(0, walk.index + 1), to];
  // dependent answers: declared dependencies on a changed field …
  const cleared = [];
  if (changed.length) {
    for (const s of root._flow.steps) {
      const deps = (s.dataset.dependsOn || '').split(',').map((x) => x.trim()).filter(Boolean);
      if (s !== step && deps.some((d) => changed.includes(d)) && answeredStep(s, answers) && controlsOf(s).length) {
        answers = clearStep(s, answers);
        cleared.push(s.dataset.step);
      }
    }
    // … and every answered step the answers can no longer reach
    const path = reachable(root, answers, skipped);
    for (const s of root._flow.steps) {
      const id = s.dataset.step;
      if (path.has(id) || !controlsOf(s).length || !answeredStep(s, answers) || cleared.includes(id)) continue;
      answers = clearStep(s, answers);
      cleared.push(id);
    }
  }
  walk.answers = answers;
  walk.skipped = skipped.filter((s) => !cleared.includes(s));
  walk.history = history;
  walk.index += 1;
  if (cleared.length) {
    notify(root, `${cleared.length === 1 ? '1 later answer was' : cleared.length + ' later answers were'} cleared - they depended on "${titleOf(step)}".`);
    // Fires when a changed answer clears later answers - the step that changed, the fields that changed, the steps cleared.
    root.dispatchEvent(new CustomEvent<QuestionnaireInvalidateDetail>('questionnaire-invalidate', { bubbles: true, detail: { cause: walk.step, changed, cleared } }));
  } else notify(root, '');
  show(root, to);
  record(root);
  // Fires on every move forward - the new step, the one left, the answers.
  root.dispatchEvent(new CustomEvent<QuestionnaireStepDetail>('questionnaire-step', { bubbles: true, detail: { step: to, from: history[walk.index - 1], answers } }));
  return true;
}
/** back along the branch history (the answers stay) */
function back(root) {
  const walk = cfgOf(root);
  if (walk.index <= 0) return false;
  // keep what was typed on the step being left (it is not validated yet)
  const step = stepById(root, walk.step);
  if (step && !isEnd(step)) walk.answers = mergeAnswers(walk.answers, readStep(step));
  walk.index -= 1;
  show(root, walk.history[walk.index]);
  record(root);
  return true;
}
/** jump to a step of the history (the trail, the review's Edit) */
function goTo(root, id) {
  const walk = cfgOf(root);
  const at = walk.history.indexOf(id);
  if (at < 0) return false;
  walk.index = at;
  show(root, id);
  record(root);
  return true;
}
/** start over: no answers, no history, the draft gone */
function restart(root) {
  const walk = cfgOf(root);
  for (const s of root._flow.steps) writeStep(s, {});
  walk.answers = {};
  walk.history = [root._flow.start];
  walk.index = 0;
  walk.skipped = [];
  walk.submitted = false;
  notify(root, '');
  show(root, root._flow.start);
  record(root, 'default');
}
/** the end step's submit: every answer on the path, to onSubmit / the event */
async function submit(root) {
  const walk = cfgOf(root);
  if (!isEnd(stepById(root, walk.step))) return advance(root);
  const path = new Set(walk.history.slice(0, walk.index + 1));
  const names = new Set(root._flow.steps.filter((s) => path.has(s.dataset.step)).flatMap((s) => controlsOf(s).map((c) => c.name)));
  const answers = Object.fromEntries(Object.entries(walk.answers).filter(([k]) => names.has(k))) as QuestionnaireAnswers;
  const onSubmit = root._config?.onSubmit;
  root.toggleAttribute('data-busy', true);
  try {
    if (onSubmit) await onSubmit(answers, { history: walk.history.slice(0, walk.index + 1) });
  } catch (error) {
    root.removeAttribute('data-busy');
    notify(root, `Could not send: ${error?.message || error}`);
    return false;
  }
  root.removeAttribute('data-busy');
  walk.submitted = true;
  applyMarkup(root, { name: 'submitted', config: { step: walk.step } });
  root.dataset.stateName = 'submitted';
  notify(root, '');
  paint(root);
  record(root, 'submitted');
  // Fires when sent - the answers on the path and the steps taken.
  root.dispatchEvent(new CustomEvent<QuestionnaireSubmitDetail>('questionnaire-submit', { bubbles: true, detail: { answers, history: walk.history.slice(0, walk.index + 1) } }));
  return true;
}
// -- State API ----------------------------------------------------------------------------
/**
 * UI side of setState: 'default' goes to the start step; 'answering' to
 * config.step (a step of the history, or any step - agents may jump);
 * 'review' to the end step; 'submitted' sends. The answers are kept unless
 * config.answers replaces them. It lands where the walk lands.
 */
function triggerStateChange(root, state, incoming) {
  const walk = cfgOf(root);
  if (!walk) return;
  if (incoming.answers && typeof incoming.answers === 'object') {
    walk.answers = { ...incoming.answers };
    for (const s of root._flow.steps) writeStep(s, walk.answers);
  }
  walk.submitted = false;
  const go = (id) => {
    if (!stepById(root, id)) return;
    const at = walk.history.indexOf(id);
    if (at >= 0) walk.index = at;
    else {
      walk.history = [...walk.history.slice(0, walk.index + 1), id];
      walk.index = walk.history.length - 1;
    }
    walk.step = id;
  };
  switch (state.name) {
    case 'default':
      walk.index = 0;
      walk.history = walk.history.length ? walk.history : [root._flow.start];
      walk.step = walk.history[0];
      break;
    case 'answering':
      if (incoming.step) go(incoming.step);
      // no step named, still at the start: one step on, the way the answers (or the default) lead
      else if (walk.index === 0) go(nextOf(root, walk.step, walk.answers) || defaultNext(root, stepById(root, walk.step)));
      break;
    case 'review': {
      const end = incoming.step && isEnd(stepById(root, incoming.step)) ? incoming.step : root._flow.steps.find(isEnd)?.dataset.step;
      if (end) go(end);
      break;
    }
    case 'submitted':
      walk.submitted = true;
      break;
  }
  const name = landed(root, walk);
  applyMarkup(root, { name, config: { step: walk.step } });
  root.dataset.stateName = name;
  paint(root);
  if (root._draft) root._draft.set(walk.submitted ? null : { step: walk.step, answers: walk.answers, history: walk.history, index: walk.index, skipped: walk.skipped });
}
export const questionnaireApi = componentState({
  component: 'questionnaire',
  states: questionnaireStates,
  // the config merges: { step } keeps the answers
  mergeConfig: true,
  apply: (root, state, _previous, incoming) => triggerStateChange(root, state, incoming),
  // the walk the element shows (answers typed since the last move included)
  read: (root, state) => {
    const walk = cfgOf(root);
    if (!walk) return state;
    return { name: landed(root, walk), config: { ...state.config, step: walk.step, answers: walk.answers, history: walk.history, index: walk.index, skipped: walk.skipped } };
  },
  markup: (el, state) => applyMarkup(el, state),
});
df$.questionnaireApi = questionnaireApi;
df$.questionnaireStates = questionnaireStates;
// -- the flow graph: analysis + a Mermaid picture ---------------------------------------
/**
 * Check the flow as a graph: edges to steps that do not exist, cycles,
 * dead ends (a step that leads nowhere and is no end), no end reachable,
 * steps no path reaches, and fields a rule or a dependency reads that a
 * path may arrive without (a field is only guaranteed when every path to
 * the step passes a step that requires it - its dominators).
 */
function analyze(root) {
  const errors = [];
  const warnings = [];
  const steps = root._flow.steps;
  const ids = new Set(steps.map((s) => s.dataset.step));
  const edges = new Map<string, Omit<QuestionnaireEdge, 'from'>[]>(steps.map((s) => [s.dataset.step, edgesOf(root, s)]));
  const start = root._flow.start;
  if (!ids.has(start)) errors.push(`the start step "${start}" does not exist`);
  for (const [from, list] of edges) for (const e of list) if (!ids.has(e.to)) errors.push(`"${from}" leads to "${e.to}", which does not exist`);
  for (const s of steps) if (!isEnd(s) && !(edges.get(s.dataset.step) || []).length) errors.push(`"${s.dataset.step}" is a dead end - give it a next step or data-end`);
  if (!steps.some(isEnd)) errors.push('no step is an end (data-end)');
  // cycles (DFS colors)
  const color = new Map();
  const stack = [];
  const visit = (id) => {
    color.set(id, 1);
    stack.push(id);
    for (const e of edges.get(id) || []) {
      if (!ids.has(e.to)) continue;
      if (color.get(e.to) === 1) errors.push(`a cycle: ${[...stack.slice(stack.indexOf(e.to)), e.to].join(' → ')}`);
      else if (!color.get(e.to)) visit(e.to);
    }
    stack.pop();
    color.set(id, 2);
  };
  if (ids.has(start)) visit(start);
  for (const s of steps) if (!color.get(s.dataset.step)) warnings.push(`"${s.dataset.step}" is unreachable from "${start}"`);
  // reachable end
  if (ids.has(start) && ![...color.keys()].some((id) => isEnd(stepById(root, id)))) errors.push(`no end can be reached from "${start}"`);
  const fieldOwner = new Map();
  for (const s of steps) for (const c of controlsOf(s)) fieldOwner.set(c.name, s.dataset.step);
  const fieldsIn = (w) => (Array.isArray(w) ? w : w?.any ?? w?.all ?? (w ? [w] : [])).flatMap((c) => [c.field, c.value && typeof c.value === 'object' && 'field' in c.value ? c.value.field : null]).filter(Boolean);
  for (const [id, rules] of Object.entries(root._flow.rules.branches as Record<string, QuestionnaireBranch[]>)) {
    if (!ids.has(id)) errors.push(`branches for "${id}", which does not exist`);
    for (const rule of rules) for (const f of fieldsIn(rule.when)) if (!fieldOwner.has(f)) errors.push(`a branch on "${id}" reads "${f}", which no step asks`);
  }
  for (const [id, rules] of Object.entries(root._flow.rules.validate)) {
    if (!ids.has(id)) errors.push(`validation for "${id}", which does not exist`);
    if (Array.isArray(rules)) for (const rule of rules) for (const f of fieldsIn(rule.assert)) if (!fieldOwner.has(f)) errors.push(`an assertion on "${id}" reads "${f}", which no step asks`);
  }
  for (const s of steps) for (const d of (s.dataset.dependsOn || '').split(',').map((x) => x.trim()).filter(Boolean)) {
    if (!fieldOwner.has(d)) errors.push(`"${s.dataset.step}" depends on "${d}", which no step asks`);
  }
  // dominators (the iterative data-flow form - cycles included): a field a
  // rule reads is only guaranteed when every path to the step passes a step
  // that requires it
  {
    const reach = [...color.keys()];
    const preds = new Map(reach.map((id) => [id, []]));
    for (const id of reach) for (const e of edges.get(id) || []) if (preds.has(e.to)) preds.get(e.to).push(id);
    const all = new Set(reach);
    const dom = new Map(reach.map((id) => [id, id === start ? new Set([start]) : new Set(all)]));
    for (let changed = true; changed;) {
      changed = false;
      for (const id of reach) {
        if (id === start) continue;
        const ps = preds.get(id);
        // a copy - with one predecessor reduce() returns that predecessor's own set
        const inter = new Set(ps.length ? ps.map((p) => dom.get(p)).reduce((a, b) => new Set([...a].filter((x) => b.has(x)))) : []);
        inter.add(id);
        if (inter.size !== dom.get(id).size) {
          dom.set(id, inter);
          changed = true;
        }
      }
    }
    const required = (id) => controlsOf(stepById(root, id)).filter((c) => c.required).map((c) => c.name);
    const guaranteed = (id) => new Set([...(dom.get(id) ?? [])].filter((d) => d !== id).flatMap(required));
    const reads = (id, field, what) => {
      if (fieldOwner.has(field) && fieldOwner.get(field) !== id && dom.has(id) && !guaranteed(id).has(field)) warnings.push(`${what} on "${id}" reads "${field}" - a path can reach "${id}" without it`);
    };
    for (const [id, rules] of Object.entries(root._flow.rules.branches as Record<string, QuestionnaireBranch[]>)) for (const rule of rules) for (const f of fieldsIn(rule.when)) reads(id, f, 'a branch');
    for (const [id, rules] of Object.entries(root._flow.rules.validate)) if (Array.isArray(rules)) for (const rule of rules) for (const f of fieldsIn(rule.assert)) reads(id, f, 'an assertion');
  }
  const nodes = steps.map((s) => ({ id: s.dataset.step, title: titleOf(s), end: isEnd(s), block: s.closest('.questionnaire-block')?.dataset.block || '' }));
  const list = [...edges].flatMap(([from, l]) => l.map((e) => ({ from, ...e })));
  return { ok: errors.length === 0, errors: [...new Set(errors)], warnings: [...new Set(warnings)], nodes, edges: list };
}
/** the flow as a Mermaid flowchart - the walked path highlighted */
function toMermaid(root) {
  const { nodes, edges } = analyze(root);
  const walk = cfgOf(root);
  const visited = new Set(walk ? walk.history.slice(0, walk.index + 1) : []);
  const safe = (id) => 'q_' + id.replace(/[^A-Za-z0-9_]/g, '_');
  const text = (t) => t.replace(/["\n]/g, ' ').slice(0, 48);
  const lines = ['flowchart TD'];
  for (const n of nodes) lines.push(`  ${safe(n.id)}${n.end ? `(["${text(n.title)}"])` : `["${text(n.title)}"]`}`);
  const styled = [];
  edges.forEach((e, i) => {
    lines.push(`  ${safe(e.from)} -->${e.label ? `|"${text(e.label)}"|` : ''} ${safe(e.to)}`);
    const a = walk?.history.indexOf(e.from) ?? -1;
    if (a >= 0 && a < (walk?.index ?? 0) && walk.history[a + 1] === e.to) styled.push(i);
  });
  lines.push('  classDef visited stroke-width:2px;');
  lines.push('  classDef current stroke-width:3px,stroke-dasharray:4 2;');
  const done = [...visited].filter((id) => id !== walk?.step);
  if (done.length) lines.push(`  class ${done.map(safe).join(',')} visited;`);
  if (walk) lines.push(`  class ${safe(walk.step)} current;`);
  if (styled.length) lines.push(`  linkStyle ${styled.join(',')} stroke-width:3px;`);
  return lines.join('\n');
}
/**
 * The flow as an Illustrative Diagram spec (df$.shadcn.diagram.build): steps
 * ranked top-down by their longest path from the start, the steps taken
 * marked (done 'muted', the current one 'accent'), the walked edges solid accent and the rest
 * dashed, the edge just walked carrying a flow token. An edge that skips a
 * rank in the same column is routed around the stack, never under a box.
 */
function toDiagram(root, { title = '' } = {}) {
  const { nodes, edges } = analyze(root);
  const walk = cfgOf(root);
  const start = root._flow.start;
  // rank = the longest path from the start (a valid questionnaire is acyclic; the pass cap guards a broken one)
  const rank = new Map([[start, 0]]);
  for (let pass = 0; pass <= nodes.length; pass++) {
    let moved = false;
    for (const e of edges) {
      if (!rank.has(e.from)) continue;
      const r = rank.get(e.from) + 1;
      if ((rank.get(e.to) ?? -1) < r) { rank.set(e.to, r); moved = true; }
    }
    if (!moved) break;
  }
  let bottom = Math.max(0, ...rank.values());
  for (const n of nodes) if (!rank.has(n.id)) rank.set(n.id, ++bottom); // unreachable steps sit below
  const rows = new Map();
  for (const n of nodes) {
    const r = rank.get(n.id);
    if (!rows.has(r)) rows.set(r, []);
    rows.get(r).push(n.id);
  }
  const cols = Math.max(1, ...[...rows.values()].map((ids) => ids.length));
  const col = new Map();
  for (const ids of rows.values()) ids.forEach((id, i) => col.set(id, Math.floor((cols - ids.length) / 2) + i + 1));
  const taken = walk ? walk.history.slice(0, walk.index + 1) : [];
  const walked = new Set(taken.slice(1).map((to, i) => `${taken[i]}->${to}`));
  const last = taken.length > 1 ? `${taken[taken.length - 2]}->${taken[taken.length - 1]}` : '';
  return {
    type: 'flow',
    ...(title ? { title } : {}),
    cols,
    interactive: true,
    nodes: nodes.map((n) => ({
      id: n.id,
      name: n.title || n.id,
      ...(n.id === start ? { eyebrow: 'Start' } : n.end ? { eyebrow: 'End' } : {}),
      col: col.get(n.id),
      row: rank.get(n.id) + 1,
      ...(n.end ? { shape: 'pill' } : {}),
      ...(walk && n.id === walk.step ? { tone: 'accent' } : taken.includes(n.id) ? { tone: 'muted' } : {}),
    })),
    edges: edges.map((e) => {
      const ref = `${e.from}->${e.to}`;
      return {
        from: e.from,
        to: e.to,
        ...(e.label ? { label: e.label } : {}),
        ...(rank.get(e.to) - rank.get(e.from) > 1 && col.get(e.to) === col.get(e.from) ? { curve: 'around' } : {}),
        ...(walked.has(ref) ? { tone: 'accent' } : { line: 'dashed' }),
        ...(ref === last ? { flow: true } : {}),
      };
    }),
  };
}
/**
 * A diagram figure drawn from the form and driving it back: every move
 * redraws it (the walk marked, the current step the accent node), and a click on a
 * step moves the form - back to any step taken, forward only to the step the
 * current answers lead to (validated, like Continue). A step further on is
 * refused: the figure stays on the walk, the form says what to answer first
 * and questionnaire-jump-refused fires. Returns the unlink function.
 */
function linkDiagram(root, figure) {
  const diagram = df$.diagram as { build(target: HTMLElement, spec: unknown): unknown } | undefined;
  if (!diagram?.build) throw new Error('questionnaire.linkDiagram: the diagram component is not loaded (df$.shadcn.diagram)');
  figure._questionnaireUnlink?.();
  let syncing = false;
  let drawn = '';
  // at rest the figure shows the whole walk - no activation dimming the path
  // taken; the current step is the accent node (toDiagram's tones)
  const focus = () => {
    syncing = true;
    try {
      if (figure.api?.getState().name === 'active') figure.api.setState('default');
    } finally {
      syncing = false;
    }
  };
  const draw = () => {
    const walk = cfgOf(root);
    if (!walk) return;
    const key = `${walk.step}|${walk.history.join(',')}|${walk.index}`;
    if (key === drawn) return;
    drawn = key;
    diagram.build(figure, toDiagram(root, { title: figure.getAttribute('aria-label') || '' }));
    focus();
  };
  const refuse = (to, reason) => {
    const walk = cfgOf(root);
    focus();
    const step = stepById(root, walk.step);
    const target = stepById(root, to);
    if (reason === 'unreached') notify(root, `"${target ? titleOf(target) : to}" is not reachable yet - answer "${step ? titleOf(step) : walk.step}" first.`);
    // Fires when a click on the linked diagram asks for a step the walk cannot reach yet - the step asked for, the current step and why: 'unreached' (further on) or 'invalid' (the current step does not validate).
    root.dispatchEvent(new CustomEvent<QuestionnaireJumpRefusedDetail>('questionnaire-jump-refused', { bubbles: true, detail: { to, step: walk.step, reason } }));
  };
  const onActivate = (e) => {
    if (syncing) return;
    const walk = cfgOf(root);
    const to = e.detail?.kind === 'node' ? e.detail.ref : null;
    if (!walk || !to || to === walk.step) return void focus();
    if (walk.history.includes(to)) return void goTo(root, to);
    const step = stepById(root, walk.step);
    const answers = step && !isEnd(step) ? mergeAnswers(walk.answers, readStep(step)) : walk.answers;
    if (to === nextOf(root, walk.step, answers)) {
      if (!advance(root)) refuse(to, 'invalid');
      return;
    }
    refuse(to, 'unreached');
  };
  dfDollar(figure).on('diagram-activate', onActivate);
  const off = root.store.subscribe(draw);
  draw();
  const unlink = () => {
    off?.();
    dfDollar(figure).off('diagram-activate', onActivate);
    delete figure._questionnaireUnlink;
  };
  figure._questionnaireUnlink = unlink;
  return unlink;
}
// -- df$.shadcn.questionnaire: the imperative surface --------------------------------------
const resolve = (target) => (typeof target === 'string' ? dfDollar(target).get(0) : target);
df$.questionnaire = {
  /**
   * Behavior in code: branches ({ stepId: [{ when, goto }] }), validate
   * ({ stepId: [{ assert, message, field }] | (answers, stepAnswers) =>
   * message | null }), onSubmit(answers, { history }) (may return a
   * promise; a rejection keeps the review and says why), persist
   * ({ area, prefix, key } - where the draft is kept).
   * @param target - the .questionnaire element or its selector
   * @param config - branches, checks, the submit handler and the draft's place (merged into the current config)
   */
  configure(target: string | HTMLElement, config: QuestionnaireConfig = {}): void {
    const root = resolve(target);
    root._config = { ...root._config, ...config };
    if (root._flow) root._flow.rules = rulesOf(root);
    if (config.persist && root._walk) attachDraft(root, config.persist);
  },
  /**
   * Leave the current step forward - validated; false when it cannot be left.
   * @param target - the .questionnaire element or its selector
   * @returns true when it moved on; false when the step does not validate (or is an end)
   */
  next: (target: string | HTMLElement): boolean => advance(resolve(target)),
  /**
   * Back one step along the branch history.
   * @param target - the .questionnaire element or its selector
   * @returns false on the first step
   */
  back: (target: string | HTMLElement): boolean => back(resolve(target)),
  /**
   * Skip an optional step - its answers dropped, the default way taken.
   * @param target - the .questionnaire element or its selector
   * @returns true when it moved on
   */
  skip: (target: string | HTMLElement): boolean => advance(resolve(target), { skip: true }),
  /**
   * Jump to a step of the history (what the trail and Edit do).
   * @param target - the .questionnaire element or its selector
   * @param id - the step id
   * @returns false when the step is not in the history
   */
  goTo: (target: string | HTMLElement, id: string): boolean => goTo(resolve(target), id),
  /**
   * Start over: no answers, no history, no draft.
   * @param target - the .questionnaire element or its selector
   */
  restart: (target: string | HTMLElement): void => restart(resolve(target)),
  /**
   * Send from the end step (onSubmit, then questionnaire-submit).
   * @param target - the .questionnaire element or its selector
   * @returns true when sent; false when onSubmit rejected or (before the end) the step did not validate
   */
  submit: (target: string | HTMLElement): Promise<boolean> => submit(resolve(target)),
  /**
   * The answers so far.
   * @param target - the .questionnaire element or its selector
   * @returns a copy of every answer, by field name
   */
  answers: (target: string | HTMLElement): QuestionnaireAnswers => ({ ...cfgOf(resolve(target))?.answers }),
  /**
   * The steps taken up to the current one.
   * @param target - the .questionnaire element or its selector
   * @returns the step ids, in order
   */
  history: (target: string | HTMLElement): string[] => {
    const walk = cfgOf(resolve(target));
    return walk ? walk.history.slice(0, walk.index + 1) : [];
  },
  /**
   * Where the answers lead from a step (the graph, evaluated).
   * @param target - the .questionnaire element or its selector
   * @param stepId - the step to leave
   * @param answers - the answers to evaluate (default: the current ones)
   * @returns the next step's id, null from an end
   */
  nextOf: (target: string | HTMLElement, stepId: string, answers?: QuestionnaireAnswers): string | null => nextOf(resolve(target), stepId, answers ?? cfgOf(resolve(target)).answers),
  /**
   * Check the flow graph - { ok, errors, warnings, nodes, edges }.
   * @param target - the .questionnaire element or its selector
   * @returns the errors, the warnings and the graph
   */
  analyze: (target: string | HTMLElement): QuestionnaireAnalysis => analyze(resolve(target)),
  /**
   * The flow as a Mermaid flowchart, the walked path marked.
   * @param target - the .questionnaire element or its selector
   * @returns the flowchart source
   */
  toMermaid: (target: string | HTMLElement): string => toMermaid(resolve(target)),
  /** The flow as an Illustrative Diagram spec for df$.shadcn.diagram.build - steps ranked top-down, the walked path marked, the edge just walked flowing; { title } names it.
   * @param target - the .questionnaire element or its selector
   * @param options - title: the figure's title
   * @returns the spec, for df$.shadcn.diagram.build()
   */
  toDiagram: (target: string | HTMLElement, options?: { title?: string }): QuestionnaireDiagramSpec => toDiagram(resolve(target), options),
  /** Link a .diagram figure both ways: it redraws on every move with the current step active, and a click moves the form - back to a step taken, forward only to the step the answers lead to; further on is refused (questionnaire-jump-refused).
   * @param target - the .questionnaire element or its selector
   * @param figure - the .diagram figure or its selector
   * @returns the unlink function: call it to stop the two following each other
   */
  linkDiagram: (target: string | HTMLElement, figure: string | HTMLElement): (() => void) => linkDiagram(resolve(target), resolve(figure)),
};
/** where the draft is kept (viewPersistence: data-persist / -prefix / -key, or the config) */
function attachDraft(root, config) {
  root._draft?.destroy();
  const where = viewPersistence(root, 'questionnaire', String(dfDollar('.questionnaire').toArray().indexOf(root)), config || {});
  root._draft = where ? persisted(where.key, null, { area: where.area, validate: (v): v is Record<string, unknown> | null => v === null || (typeof v === 'object' && !Array.isArray(v)) }) : null;
  return root._draft?.value ?? null;
}
// -- keyboard: one listener for the page ----------------------------------------------------
/** the questionnaire last focused or clicked */
let lastActive = null;
const TYPING = 'input[type="text"], input[type="email"], input[type="number"], input[type="search"], input[type="url"], input[type="tel"], input[type="password"], input[type="date"], input:not([type]), textarea, select, [contenteditable]';
/**
 * Typeform keys, wherever the focus is: a letter (or digit) picks a choice,
 * a digit picks the nth radio of a step without choice cards (a star
 * rating), Enter continues. Inside a questionnaire that one takes the keys;
 * with the focus on the page itself (nothing focused) the one last worked
 * in - or the only one showing. Never while typing, never from another widget.
 */
function onKey(e) {
  if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return;
  const t = e.target;
  let root = t.closest?.('.questionnaire');
  const onPage = !root && (t === document.body || t === document.documentElement || t === document);
  if (!root && !onPage) return;
  if (!root) {
    const live = dfDollar('.questionnaire[data-init]').toArray().filter((q) => q._walk && !q._walk.submitted && q.checkVisibility());
    root = live.includes(lastActive) ? lastActive : live.length === 1 ? live[0] : null;
  }
  if (!root?._walk || root._walk.submitted || t.matches?.(TYPING)) return;
  const step = stepById(root, root._walk.step);
  if (!step) return;
  // Enter on the page (a control's own Enter is the form's submit)
  if (e.key === 'Enter' && onPage) {
    e.preventDefault();
    if (isEnd(step)) submit(root);
    else advance(root);
    return;
  }
  if (root.dataset.shortcuts === 'none' || e.key.length !== 1) return;
  const key = e.key.toLowerCase();
  let input = null;
  const choice = dfDollar(step).find('.questionnaire-choice').toArray().find((c) => c.dataset.key?.toLowerCase() === key);
  if (choice) input = dfDollar(choice).find('input[type="radio"], input[type="checkbox"]').get(0);
  else if (/^[1-9]$/.test(key) && !dfDollar(step).find('.questionnaire-choice').get(0)) {
    input = dfDollar(step).find('input[type="radio"]').toArray()[Number(key) - 1] ?? null;
  }
  if (!input || input.disabled) return;
  e.preventDefault();
  lastActive = root;
  input.click();
  input.focus({ preventScroll: true });
}
if (!document.__questionnaireKeys) {
  document.__questionnaireKeys = true;
  document.addEventListener('keydown', onKey);
}
// -- init --------------------------------------------------------------------------------------
function init() {
  dfDollar('.questionnaire:not([data-init])').toArray().forEach((root) => {
    root.dataset.init = '';
    readFlow(root);
    if (!root._flow.steps.length) return;
    root.setAttribute('novalidate', ''); // the controller validates per step, with its own messages
    keyHints(root);
    root._walk = { step: root._flow.start, answers: {}, history: [root._flow.start], index: 0, skipped: [], submitted: false };
    // the draft: a walk kept in session storage (by default) comes back
    const draft = attachDraft(root, root._config?.persist);
    let restored = false;
    if (draft && draft.history?.length && stepById(root, draft.step)) {
      const walk = root._walk;
      walk.answers = draft.answers || {};
      walk.history = draft.history.filter((id) => stepById(root, id));
      walk.index = Math.min(Math.max(0, draft.index ?? 0), walk.history.length - 1);
      walk.step = walk.history[walk.index];
      walk.skipped = draft.skipped || [];
      for (const s of root._flow.steps) writeStep(s, walk.answers);
      restored = walk.index > 0 || Object.keys(walk.answers).length > 0;
    }
    const analysis = analyze(root);
    if (!analysis.ok) console.warn(`questionnaire #${root.id || '?'}: the flow has problems -`, analysis.errors);
    root.toggleAttribute('data-flow-invalid', !analysis.ok);
    // a submit button (Continue / Send) or Enter in a field: one way forward
    root.addEventListener('submit', (e) => {
      e.preventDefault();
      const action = e.submitter?.dataset.questionnaire;
      if (action === 'skip') advance(root, { skip: true });
      else if (action === 'back') back(root);
      else if (isEnd(stepById(root, cfgOf(root).step))) submit(root);
      else advance(root);
    });
    root.addEventListener('click', (e) => {
      const action = (e.target as HTMLElement).closest?.<HTMLElement>('[data-questionnaire]')?.dataset.questionnaire;
      const go = (e.target as HTMLElement).closest?.<HTMLElement>('[data-questionnaire-go]')?.dataset.questionnaireGo;
      if (go) return goTo(root, go);
      // a submit button goes through the form's submit event (no double step)
      if ((e.target as HTMLElement).closest?.('button[type="submit"], input[type="submit"]')) return;
      if (action === 'back') back(root);
      else if (action === 'next') advance(root);
      else if (action === 'skip') advance(root, { skip: true });
      else if (action === 'restart') restart(root);
      else if (action === 'dismiss') notify(root, '');
    });
    // typing keeps the draft (a reload in the middle of a step loses nothing)
    const keep = (e) => {
      const step = e.target.closest?.('.questionnaire-step');
      if (!step || step.dataset.step !== cfgOf(root).step) return;
      // freeform "Other": typing picks its choice
      if (e.target.classList?.contains('questionnaire-other') && e.target.value) {
        const holder = e.target.closest('.questionnaire-choice');
        const choice = holder && dfDollar(holder).find<HTMLInputElement>('input[type="radio"], input[type="checkbox"]').get(0);
        if (choice) choice.checked = true;
      }
      cfgOf(root).answers = mergeAnswers(cfgOf(root).answers, readStep(step));
      clearInvalid(step);
      record(root);
      // a single choice with data-auto-advance moves on once picked
      if (e.type === 'change' && e.target.type === 'radio' && root.hasAttribute('data-auto-advance') && controlsOf(step).every((c) => c.type === 'radio')) {
        clearTimeout(root._auto);
        root._auto = setTimeout(() => advance(root), 280);
      }
    };
    root.addEventListener('input', keep);
    root.addEventListener('change', keep);
    // the questionnaire being worked in takes the keys (see the document listener)
    root.addEventListener('focusin', () => { lastActive = root; });
    root.addEventListener('pointerdown', () => { lastActive = root; });
    bindComponent(root, questionnaireApi, { name: 'default', config: { step: root._flow.start, answers: {}, history: [root._flow.start], index: 0, skipped: [] } });
    show(root, root._walk.step, false);
    record(root);
    if (restored) notify(root, 'Your answers from earlier are back.', { name: 'restart', label: 'Start over' });
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub