ValidationMOL
A step is validated before it is left - never the whole form at the end. Four layers, in order: the browser's own constraints on each control, how many choices a group needs, assertions that compare answers (also with answers from earlier steps), and functions you pass in code. The first problem stops the step: its message appears under the question (role="alert"), the step and the field get aria-invalid, and the field gets the focus.
On this page (8)
§Native constraints and choice counts
Everything the browser already checks works per step: required, min / max, minlength, pattern, type="email", type="url". The form gets novalidate so the controller can speak one step at a time with the browser's own messages. A checkbox group has no native "required" - data-min / data-max on the step counts its checks.
§Sign-up
Try Continue with nothing, with a short username (pattern), with a bad email, with four interests (data-max='3').
§Assertions across answers
Some answers are only valid together: an end after a start, a maximum above a minimum, a guest count within the room size chosen two steps earlier. validate[step] in the rules is a list of { assert, message, field }: assert holds conditions (the same as a branch's when) that must all hold - a value of { "field": "x" } reads another answer, from this step or any earlier one. field names the control that gets the focus.
§Event booking
The end date may not be before the start; the guests may not exceed the venue's capacity - chosen one step earlier (a field reference across steps). The rules are in the form's JSON script.
§Validators in code
Anything the rules cannot say goes into a function: configure(form, { validate: { step: (answers, stepAnswers) => message | null } }). It sees every answer so far and returns the message to show, or nothing.
§Password
Two functions: the password needs a digit and a capital letter; the confirmation has to match. Both read the answers object.
Whatever fails, questionnaire-invalid fires on the form with { step, message } - for analytics, or a toast. Fixing the field clears the message as soon as it changes.
§CSS view file
The error styles are the questionnaire's: .questionnaire-error and the aria-invalid choice borders.
/* -- 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