Dependent AnswersMOL
Going back and changing an answer can make later answers wrong. The questionnaire handles two kinds: answers on a branch the new answer no longer leads to (a beach preference after switching to a city break), and answers that were chosen from the changed one (a state picked for the previous country). Both are cleared when the changed step is left - the user is told, questionnaire-invalidate lists them, and they are never submitted.
On this page (7)
§Abandoned branches
After a changed answer the controller asks the graph which answered steps 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. Every answered step outside that set is an orphan and loses its answers. Answers further along that are still reachable stay.
§Plan picker
Pick 'Team', answer its two questions, reach the review - then Edit the first answer to 'Personal'. Leaving that step clears the team answers (the log lists them); the device question, on both ways, keeps its answer.
§Declared dependencies
Some answers stay on the path but stop making sense: a state chosen for a country, a size chosen for a product. data-depends-on="country" (a list: "a, b") on a step clears its answers whenever one of those fields changes - even when the walk still passes it. The step asks again with its options for the new answer.
§Shipping address
Choose a country, then a region - its options are built from the country. Go back, change the country, continue: the region is empty again (data-depends-on='country'), because a German state is no answer for Canada.
§What is sent
Sending passes only the answers of the steps on the path to the end - an answer that survived on a hidden step (typed, then the user went another way before leaving the step) is still not sent. questionnaire-submit carries { answers, history }; configure(form, { onSubmit }) receives the same.
§CSS view file
Invalidation is behavior - the stylesheet is the questionnaire's (the notice: .questionnaire-notice).
/* -- 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