@questify/core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/index.ts"],"sourcesContent":["import type {\n Question,\n QuestionnaireConfig,\n QuestionnaireState,\n ShowIfCondition,\n ShowIfRule,\n} from \"./types\";\n\nexport * from \"./types\";\n\n// ─── Conditional visibility ───────────────────────────────────────────────────\n\nfunction evaluateSimple(\n condition: ShowIfCondition,\n responses: Record<string, unknown>\n): boolean {\n const response = responses[condition.questionId];\n if (response === undefined || response === null) return false;\n\n const op = condition.operator ?? \"eq\";\n const val = condition.value;\n\n switch (op) {\n case \"eq\": return response === val;\n case \"neq\": return response !== val;\n case \"gt\":\n return typeof response === \"number\" && typeof val === \"number\" && response > val;\n case \"lt\":\n return typeof response === \"number\" && typeof val === \"number\" && response < val;\n case \"gte\":\n return typeof response === \"number\" && typeof val === \"number\" && response >= val;\n case \"lte\":\n return typeof response === \"number\" && typeof val === \"number\" && response <= val;\n case \"includes\":\n return Array.isArray(response)\n ? response.includes(val)\n : String(response).includes(String(val));\n default: return response === val;\n }\n}\n\n// Maximum nesting depth for showIf condition trees.\n// Guards against stack overflows from accidentally deep or circular configs.\nconst MAX_CONDITION_DEPTH = 20;\n\nfunction evaluateRule(\n rule: ShowIfRule,\n responses: Record<string, unknown>,\n depth = 0\n): boolean {\n if (depth > MAX_CONDITION_DEPTH) {\n // Tree too deep — default to visible so questions don't silently disappear.\n console.warn(\"[questify] showIf condition tree exceeds maximum depth. Defaulting to visible.\");\n return true;\n }\n if (\"and\" in rule && rule.and) {\n if (rule.and.length === 0) return true;\n return rule.and.every((r) => evaluateRule(r, responses, depth + 1));\n }\n if (\"or\" in rule && rule.or) {\n if (rule.or.length === 0) return false;\n return rule.or.some((r) => evaluateRule(r, responses, depth + 1));\n }\n return evaluateSimple(rule as ShowIfCondition, responses);\n}\n\nfunction getVisibleQuestions(\n questions: Question[],\n responses: Record<string, unknown>\n): Question[] {\n return questions.filter((q) => (q.showIf ? evaluateRule(q.showIf, responses) : true));\n}\n\n// ─── Validation ───────────────────────────────────────────────────────────────\n\nfunction isEmpty(value: unknown): boolean {\n if (value === undefined || value === null) return true;\n if (typeof value === \"string\" && value.trim() === \"\") return true;\n if (Array.isArray(value) && value.length === 0) return true;\n return false;\n}\n\nexport function validateAnswer(question: Question, value: unknown): string | null {\n // Required check — uses isEmpty which trims whitespace\n if (question.required && isEmpty(value)) {\n return question.validation?.message ?? \"This field is required.\";\n }\n\n // No further validation on empty optional fields\n if (isEmpty(value)) return null;\n\n const v = question.validation;\n\n // Number / rating\n if (question.type === \"number\" || question.type === \"rating\") {\n const num = Number(value);\n if (isNaN(num) || !isFinite(num)) return \"Must be a valid number.\";\n if (v?.min !== undefined && num < v.min) return v.message ?? `Minimum value is ${v.min}.`;\n if (v?.max !== undefined && num > v.max) return v.message ?? `Maximum value is ${v.max}.`;\n }\n\n // Text (length + regex)\n if (question.type === \"text\") {\n const str = String(value).trim();\n if (v?.minLength !== undefined && str.length < v.minLength)\n return v.message ?? `Minimum ${v.minLength} characters required.`;\n if (v?.maxLength !== undefined && str.length > v.maxLength)\n return v.message ?? `Maximum ${v.maxLength} characters allowed.`;\n if (v?.regex) {\n try {\n if (!new RegExp(v.regex).test(str)) return v.message ?? \"Invalid format.\";\n } catch {\n // Malformed regex in config — skip silently in production\n }\n }\n }\n\n // Email — format check always runs, regardless of whether a validation object exists\n if (question.type === \"email\") {\n const str = String(value).trim();\n if (v?.minLength !== undefined && str.length < v.minLength)\n return v.message ?? `Minimum ${v.minLength} characters required.`;\n if (v?.maxLength !== undefined && str.length > v.maxLength)\n return v.message ?? `Maximum ${v.maxLength} characters allowed.`;\n if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(str))\n return \"Please enter a valid email address.\";\n }\n\n // Date — always validate YYYY-MM-DD format to avoid timezone surprises\n if (question.type === \"date\") {\n const str = String(value);\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(str)) return \"Please enter a valid date (YYYY-MM-DD).\";\n const d = new Date(str + \"T00:00:00\");\n if (isNaN(d.getTime())) return \"Please enter a valid date.\";\n }\n\n return null;\n}\n\n// ─── Progress + completion ────────────────────────────────────────────────────\n\nfunction computeProgress(\n visibleQuestions: Question[],\n responses: Record<string, unknown>\n): number {\n if (visibleQuestions.length === 0) return 0;\n const answered = visibleQuestions.filter((q) => !isEmpty(responses[q.id])).length;\n return answered / visibleQuestions.length;\n}\n\nfunction checkComplete(\n visibleQuestions: Question[],\n responses: Record<string, unknown>\n): boolean {\n return visibleQuestions\n .filter((q) => q.required)\n .every((q) => !isEmpty(responses[q.id]) && validateAnswer(q, responses[q.id]) === null);\n}\n\n// ─── State builder ────────────────────────────────────────────────────────────\n\nfunction buildState(\n config: QuestionnaireConfig,\n responses: Record<string, unknown>,\n questionIndex: number,\n errors: Record<string, string>\n): QuestionnaireState {\n const visibleQuestions = getVisibleQuestions(config.questions, responses);\n const safeIndex = Math.min(questionIndex, Math.max(0, visibleQuestions.length - 1));\n const question = visibleQuestions[safeIndex] ?? null;\n\n // [H-1] Filter stale errors for hidden questions\n const visibleIds = new Set(visibleQuestions.map((q) => q.id));\n const visibleErrors = Object.fromEntries(\n Object.entries(errors).filter(([id]) => visibleIds.has(id))\n );\n\n return {\n question,\n visibleQuestions,\n questionIndex: safeIndex,\n totalQuestions: visibleQuestions.length,\n progress: computeProgress(visibleQuestions, responses),\n // [H-2] Defensive copy — prevents external mutation of internal state\n responses: { ...responses },\n isComplete: checkComplete(visibleQuestions, responses),\n errors: visibleErrors,\n canGoBack: safeIndex > 0,\n canGoNext: safeIndex < visibleQuestions.length - 1,\n };\n}\n\n// ─── Questionnaire class ──────────────────────────────────────────────────────\n\n// Keys that must never become property names on a plain object.\n// Guards against prototype pollution via developer-supplied question IDs.\nconst UNSAFE_KEYS = new Set([\"__proto__\", \"constructor\", \"prototype\"]);\n\ntype Subscriber = (state: QuestionnaireState) => void;\n\nexport class Questionnaire {\n private config: QuestionnaireConfig;\n private responses: Record<string, unknown>;\n private questionIndex: number;\n private errors: Record<string, string>;\n private subscribers: Subscriber[] = [];\n private cachedState: QuestionnaireState;\n\n constructor(config: QuestionnaireConfig) {\n // [M-1] Warn on duplicate IDs — always a config bug, regardless of environment\n const ids = config.questions.map((q) => q.id);\n const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);\n if (dupes.length > 0) {\n console.warn(\n `[questify] Duplicate question IDs detected: ${[...new Set(dupes)].join(\", \")}. ` +\n `Each question must have a unique id.`\n );\n }\n\n this.config = { mode: \"step\", ...config };\n this.responses = { ...(config.initialResponses ?? {}) };\n this.questionIndex = 0;\n this.errors = {};\n this.cachedState = buildState(this.config, this.responses, 0, {});\n }\n\n private recompute(): void {\n this.cachedState = buildState(\n this.config,\n this.responses,\n this.questionIndex,\n this.errors\n );\n }\n\n private notify(): void {\n // [M-5] Snapshot before iterating — safe against subscribe/unsubscribe during emit\n const snapshot = this.subscribers.slice();\n snapshot.forEach((s) => s(this.cachedState));\n }\n\n // ── Read ──────────────────────────────────────────────────────────────────\n\n getState(): QuestionnaireState {\n return this.cachedState;\n }\n\n /**\n * Returns only responses for currently visible questions.\n * Use this for form submission — avoids sending hidden-branch data to the server.\n *\n * `state.responses` retains all answers for back-navigation; this method prunes them.\n */\n getSubmittableResponses(): Record<string, unknown> {\n const { visibleQuestions } = this.cachedState;\n const visibleIds = new Set(visibleQuestions.map((q) => q.id));\n return Object.fromEntries(\n Object.entries(this.responses).filter(([id]) => visibleIds.has(id))\n );\n }\n\n subscribe(callback: Subscriber): () => void {\n this.subscribers.push(callback);\n callback(this.cachedState);\n return () => {\n this.subscribers = this.subscribers.filter((s) => s !== callback);\n };\n }\n\n // ── Write ─────────────────────────────────────────────────────────────────\n\n /**\n * Answer the current question (step mode).\n * In `mode: \"all\"` prefer `answerById()`.\n */\n answer(value: unknown): void {\n const { visibleQuestions, questionIndex } = this.cachedState;\n const q = visibleQuestions[questionIndex];\n if (!q) return;\n this._setAnswer(q, value);\n }\n\n /**\n * Answer any visible question by its id — the right API for `mode: \"all\"`.\n * Safe to call in step mode too (allows answering any step by id).\n */\n answerById(questionId: string, value: unknown): void {\n const q = this.cachedState.visibleQuestions.find((q) => q.id === questionId);\n if (!q) return;\n this._setAnswer(q, value);\n }\n\n private _setAnswer(question: Question, value: unknown): void {\n // Guard against prototype pollution — unsafe IDs are silently ignored.\n if (UNSAFE_KEYS.has(question.id)) return;\n this.responses = { ...this.responses, [question.id]: value };\n\n const error = validateAnswer(question, value);\n if (error) {\n this.errors = { ...this.errors, [question.id]: error };\n } else {\n const { [question.id]: _dropped, ...rest } = this.errors;\n this.errors = rest;\n }\n\n this.recompute();\n this.notify();\n }\n\n /**\n * Advance to the next step (step mode).\n * Validates the current required question first; populates `errors` if invalid.\n */\n next(): void {\n const { visibleQuestions, questionIndex } = this.cachedState;\n const q = visibleQuestions[questionIndex];\n\n if (q) {\n const error = validateAnswer(q, this.responses[q.id]);\n if (error) {\n this.errors = { ...this.errors, [q.id]: error };\n this.recompute();\n this.notify();\n return;\n }\n }\n\n if (questionIndex < visibleQuestions.length - 1) {\n this.questionIndex = questionIndex + 1;\n this.recompute();\n this.notify();\n }\n }\n\n /** Go to the previous step. No-op on step 0. */\n back(): void {\n if (this.questionIndex > 0) {\n this.questionIndex -= 1;\n this.recompute();\n this.notify();\n }\n }\n\n /**\n * Jump to a specific step index (0-based, within visible questions).\n * Out-of-range indices are silently ignored.\n */\n jumpTo(index: number): void {\n const { visibleQuestions } = this.cachedState;\n if (index >= 0 && index < visibleQuestions.length) {\n this.questionIndex = index;\n this.recompute();\n this.notify();\n }\n }\n\n /**\n * Validate all visible required questions and return a map of errors.\n * Useful for \"submit\" gates in `mode: \"all\"`.\n */\n validate(): Record<string, string> {\n const newErrors: Record<string, string> = {};\n for (const q of this.cachedState.visibleQuestions) {\n const error = validateAnswer(q, this.responses[q.id]);\n if (error) newErrors[q.id] = error;\n }\n if (Object.keys(newErrors).length > 0) {\n this.errors = { ...this.errors, ...newErrors };\n this.recompute();\n this.notify();\n }\n return newErrors;\n }\n\n /** Reset all responses and errors, restart from step 0. */\n reset(): void {\n this.responses = { ...(this.config.initialResponses ?? {}) };\n this.questionIndex = 0;\n this.errors = {};\n this.recompute();\n this.notify();\n }\n}\n"],"mappings":";AAYA,SAAS,eACP,WACA,WACS;AAfX;AAgBE,QAAM,WAAW,UAAU,UAAU,UAAU;AAC/C,MAAI,aAAa,UAAa,aAAa,KAAM,QAAO;AAExD,QAAM,MAAK,eAAU,aAAV,YAAsB;AACjC,QAAM,MAAM,UAAU;AAEtB,UAAQ,IAAI;AAAA,IACV,KAAK;AAAO,aAAO,aAAa;AAAA,IAChC,KAAK;AAAO,aAAO,aAAa;AAAA,IAChC,KAAK;AACH,aAAO,OAAO,aAAa,YAAY,OAAO,QAAQ,YAAY,WAAW;AAAA,IAC/E,KAAK;AACH,aAAO,OAAO,aAAa,YAAY,OAAO,QAAQ,YAAY,WAAW;AAAA,IAC/E,KAAK;AACH,aAAO,OAAO,aAAa,YAAY,OAAO,QAAQ,YAAY,YAAY;AAAA,IAChF,KAAK;AACH,aAAO,OAAO,aAAa,YAAY,OAAO,QAAQ,YAAY,YAAY;AAAA,IAChF,KAAK;AACH,aAAO,MAAM,QAAQ,QAAQ,IACzB,SAAS,SAAS,GAAG,IACrB,OAAO,QAAQ,EAAE,SAAS,OAAO,GAAG,CAAC;AAAA,IAC3C;AAAS,aAAO,aAAa;AAAA,EAC/B;AACF;AAIA,IAAM,sBAAsB;AAE5B,SAAS,aACP,MACA,WACA,QAAQ,GACC;AACT,MAAI,QAAQ,qBAAqB;AAE/B,YAAQ,KAAK,gFAAgF;AAC7F,WAAO;AAAA,EACT;AACA,MAAI,SAAS,QAAQ,KAAK,KAAK;AAC7B,QAAI,KAAK,IAAI,WAAW,EAAG,QAAO;AAClC,WAAO,KAAK,IAAI,MAAM,CAAC,MAAM,aAAa,GAAG,WAAW,QAAQ,CAAC,CAAC;AAAA,EACpE;AACA,MAAI,QAAQ,QAAQ,KAAK,IAAI;AAC3B,QAAI,KAAK,GAAG,WAAW,EAAG,QAAO;AACjC,WAAO,KAAK,GAAG,KAAK,CAAC,MAAM,aAAa,GAAG,WAAW,QAAQ,CAAC,CAAC;AAAA,EAClE;AACA,SAAO,eAAe,MAAyB,SAAS;AAC1D;AAEA,SAAS,oBACP,WACA,WACY;AACZ,SAAO,UAAU,OAAO,CAAC,MAAO,EAAE,SAAS,aAAa,EAAE,QAAQ,SAAS,IAAI,IAAK;AACtF;AAIA,SAAS,QAAQ,OAAyB;AACxC,MAAI,UAAU,UAAa,UAAU,KAAM,QAAO;AAClD,MAAI,OAAO,UAAU,YAAY,MAAM,KAAK,MAAM,GAAI,QAAO;AAC7D,MAAI,MAAM,QAAQ,KAAK,KAAK,MAAM,WAAW,EAAG,QAAO;AACvD,SAAO;AACT;AAEO,SAAS,eAAe,UAAoB,OAA+B;AAlFlF;AAoFE,MAAI,SAAS,YAAY,QAAQ,KAAK,GAAG;AACvC,YAAO,oBAAS,eAAT,mBAAqB,YAArB,YAAgC;AAAA,EACzC;AAGA,MAAI,QAAQ,KAAK,EAAG,QAAO;AAE3B,QAAM,IAAI,SAAS;AAGnB,MAAI,SAAS,SAAS,YAAY,SAAS,SAAS,UAAU;AAC5D,UAAM,MAAM,OAAO,KAAK;AACxB,QAAI,MAAM,GAAG,KAAK,CAAC,SAAS,GAAG,EAAG,QAAO;AACzC,SAAI,uBAAG,SAAQ,UAAa,MAAM,EAAE,IAAK,SAAO,OAAE,YAAF,YAAa,oBAAoB,EAAE,GAAG;AACtF,SAAI,uBAAG,SAAQ,UAAa,MAAM,EAAE,IAAK,SAAO,OAAE,YAAF,YAAa,oBAAoB,EAAE,GAAG;AAAA,EACxF;AAGA,MAAI,SAAS,SAAS,QAAQ;AAC5B,UAAM,MAAM,OAAO,KAAK,EAAE,KAAK;AAC/B,SAAI,uBAAG,eAAc,UAAa,IAAI,SAAS,EAAE;AAC/C,cAAO,OAAE,YAAF,YAAa,WAAW,EAAE,SAAS;AAC5C,SAAI,uBAAG,eAAc,UAAa,IAAI,SAAS,EAAE;AAC/C,cAAO,OAAE,YAAF,YAAa,WAAW,EAAE,SAAS;AAC5C,QAAI,uBAAG,OAAO;AACZ,UAAI;AACF,YAAI,CAAC,IAAI,OAAO,EAAE,KAAK,EAAE,KAAK,GAAG,EAAG,SAAO,OAAE,YAAF,YAAa;AAAA,MAC1D,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF;AAGA,MAAI,SAAS,SAAS,SAAS;AAC7B,UAAM,MAAM,OAAO,KAAK,EAAE,KAAK;AAC/B,SAAI,uBAAG,eAAc,UAAa,IAAI,SAAS,EAAE;AAC/C,cAAO,OAAE,YAAF,YAAa,WAAW,EAAE,SAAS;AAC5C,SAAI,uBAAG,eAAc,UAAa,IAAI,SAAS,EAAE;AAC/C,cAAO,OAAE,YAAF,YAAa,WAAW,EAAE,SAAS;AAC5C,QAAI,CAAC,6BAA6B,KAAK,GAAG;AACxC,aAAO;AAAA,EACX;AAGA,MAAI,SAAS,SAAS,QAAQ;AAC5B,UAAM,MAAM,OAAO,KAAK;AACxB,QAAI,CAAC,sBAAsB,KAAK,GAAG,EAAG,QAAO;AAC7C,UAAM,IAAI,oBAAI,KAAK,MAAM,WAAW;AACpC,QAAI,MAAM,EAAE,QAAQ,CAAC,EAAG,QAAO;AAAA,EACjC;AAEA,SAAO;AACT;AAIA,SAAS,gBACP,kBACA,WACQ;AACR,MAAI,iBAAiB,WAAW,EAAG,QAAO;AAC1C,QAAM,WAAW,iBAAiB,OAAO,CAAC,MAAM,CAAC,QAAQ,UAAU,EAAE,EAAE,CAAC,CAAC,EAAE;AAC3E,SAAO,WAAW,iBAAiB;AACrC;AAEA,SAAS,cACP,kBACA,WACS;AACT,SAAO,iBACJ,OAAO,CAAC,MAAM,EAAE,QAAQ,EACxB,MAAM,CAAC,MAAM,CAAC,QAAQ,UAAU,EAAE,EAAE,CAAC,KAAK,eAAe,GAAG,UAAU,EAAE,EAAE,CAAC,MAAM,IAAI;AAC1F;AAIA,SAAS,WACP,QACA,WACA,eACA,QACoB;AAtKtB;AAuKE,QAAM,mBAAmB,oBAAoB,OAAO,WAAW,SAAS;AACxE,QAAM,YAAY,KAAK,IAAI,eAAe,KAAK,IAAI,GAAG,iBAAiB,SAAS,CAAC,CAAC;AAClF,QAAM,YAAW,sBAAiB,SAAS,MAA1B,YAA+B;AAGhD,QAAM,aAAa,IAAI,IAAI,iBAAiB,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;AAC5D,QAAM,gBAAgB,OAAO;AAAA,IAC3B,OAAO,QAAQ,MAAM,EAAE,OAAO,CAAC,CAAC,EAAE,MAAM,WAAW,IAAI,EAAE,CAAC;AAAA,EAC5D;AAEA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,eAAe;AAAA,IACf,gBAAgB,iBAAiB;AAAA,IACjC,UAAU,gBAAgB,kBAAkB,SAAS;AAAA;AAAA,IAErD,WAAW,EAAE,GAAG,UAAU;AAAA,IAC1B,YAAY,cAAc,kBAAkB,SAAS;AAAA,IACrD,QAAQ;AAAA,IACR,WAAW,YAAY;AAAA,IACvB,WAAW,YAAY,iBAAiB,SAAS;AAAA,EACnD;AACF;AAMA,IAAM,cAAc,oBAAI,IAAI,CAAC,aAAa,eAAe,WAAW,CAAC;AAI9D,IAAM,gBAAN,MAAoB;AAAA,EAQzB,YAAY,QAA6B;AAHzC,SAAQ,cAA4B,CAAC;AA7MvC;AAkNI,UAAM,MAAM,OAAO,UAAU,IAAI,CAAC,MAAM,EAAE,EAAE;AAC5C,UAAM,QAAQ,IAAI,OAAO,CAAC,IAAI,MAAM,IAAI,QAAQ,EAAE,MAAM,CAAC;AACzD,QAAI,MAAM,SAAS,GAAG;AACpB,cAAQ;AAAA,QACN,+CAA+C,CAAC,GAAG,IAAI,IAAI,KAAK,CAAC,EAAE,KAAK,IAAI,CAAC;AAAA,MAE/E;AAAA,IACF;AAEA,SAAK,SAAS,EAAE,MAAM,QAAQ,GAAG,OAAO;AACxC,SAAK,YAAY,EAAE,IAAI,YAAO,qBAAP,YAA2B,CAAC,EAAG;AACtD,SAAK,gBAAgB;AACrB,SAAK,SAAS,CAAC;AACf,SAAK,cAAc,WAAW,KAAK,QAAQ,KAAK,WAAW,GAAG,CAAC,CAAC;AAAA,EAClE;AAAA,EAEQ,YAAkB;AACxB,SAAK,cAAc;AAAA,MACjB,KAAK;AAAA,MACL,KAAK;AAAA,MACL,KAAK;AAAA,MACL,KAAK;AAAA,IACP;AAAA,EACF;AAAA,EAEQ,SAAe;AAErB,UAAM,WAAW,KAAK,YAAY,MAAM;AACxC,aAAS,QAAQ,CAAC,MAAM,EAAE,KAAK,WAAW,CAAC;AAAA,EAC7C;AAAA;AAAA,EAIA,WAA+B;AAC7B,WAAO,KAAK;AAAA,EACd;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,0BAAmD;AACjD,UAAM,EAAE,iBAAiB,IAAI,KAAK;AAClC,UAAM,aAAa,IAAI,IAAI,iBAAiB,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;AAC5D,WAAO,OAAO;AAAA,MACZ,OAAO,QAAQ,KAAK,SAAS,EAAE,OAAO,CAAC,CAAC,EAAE,MAAM,WAAW,IAAI,EAAE,CAAC;AAAA,IACpE;AAAA,EACF;AAAA,EAEA,UAAU,UAAkC;AAC1C,SAAK,YAAY,KAAK,QAAQ;AAC9B,aAAS,KAAK,WAAW;AACzB,WAAO,MAAM;AACX,WAAK,cAAc,KAAK,YAAY,OAAO,CAAC,MAAM,MAAM,QAAQ;AAAA,IAClE;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,OAAO,OAAsB;AAC3B,UAAM,EAAE,kBAAkB,cAAc,IAAI,KAAK;AACjD,UAAM,IAAI,iBAAiB,aAAa;AACxC,QAAI,CAAC,EAAG;AACR,SAAK,WAAW,GAAG,KAAK;AAAA,EAC1B;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,WAAW,YAAoB,OAAsB;AACnD,UAAM,IAAI,KAAK,YAAY,iBAAiB,KAAK,CAACA,OAAMA,GAAE,OAAO,UAAU;AAC3E,QAAI,CAAC,EAAG;AACR,SAAK,WAAW,GAAG,KAAK;AAAA,EAC1B;AAAA,EAEQ,WAAW,UAAoB,OAAsB;AAE3D,QAAI,YAAY,IAAI,SAAS,EAAE,EAAG;AAClC,SAAK,YAAY,EAAE,GAAG,KAAK,WAAW,CAAC,SAAS,EAAE,GAAG,MAAM;AAE3D,UAAM,QAAQ,eAAe,UAAU,KAAK;AAC5C,QAAI,OAAO;AACT,WAAK,SAAS,EAAE,GAAG,KAAK,QAAQ,CAAC,SAAS,EAAE,GAAG,MAAM;AAAA,IACvD,OAAO;AACL,YAAM,EAAE,CAAC,SAAS,EAAE,GAAG,UAAU,GAAG,KAAK,IAAI,KAAK;AAClD,WAAK,SAAS;AAAA,IAChB;AAEA,SAAK,UAAU;AACf,SAAK,OAAO;AAAA,EACd;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,OAAa;AACX,UAAM,EAAE,kBAAkB,cAAc,IAAI,KAAK;AACjD,UAAM,IAAI,iBAAiB,aAAa;AAExC,QAAI,GAAG;AACL,YAAM,QAAQ,eAAe,GAAG,KAAK,UAAU,EAAE,EAAE,CAAC;AACpD,UAAI,OAAO;AACT,aAAK,SAAS,EAAE,GAAG,KAAK,QAAQ,CAAC,EAAE,EAAE,GAAG,MAAM;AAC9C,aAAK,UAAU;AACf,aAAK,OAAO;AACZ;AAAA,MACF;AAAA,IACF;AAEA,QAAI,gBAAgB,iBAAiB,SAAS,GAAG;AAC/C,WAAK,gBAAgB,gBAAgB;AACrC,WAAK,UAAU;AACf,WAAK,OAAO;AAAA,IACd;AAAA,EACF;AAAA;AAAA,EAGA,OAAa;AACX,QAAI,KAAK,gBAAgB,GAAG;AAC1B,WAAK,iBAAiB;AACtB,WAAK,UAAU;AACf,WAAK,OAAO;AAAA,IACd;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,OAAO,OAAqB;AAC1B,UAAM,EAAE,iBAAiB,IAAI,KAAK;AAClC,QAAI,SAAS,KAAK,QAAQ,iBAAiB,QAAQ;AACjD,WAAK,gBAAgB;AACrB,WAAK,UAAU;AACf,WAAK,OAAO;AAAA,IACd;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,WAAmC;AACjC,UAAM,YAAoC,CAAC;AAC3C,eAAW,KAAK,KAAK,YAAY,kBAAkB;AACjD,YAAM,QAAQ,eAAe,GAAG,KAAK,UAAU,EAAE,EAAE,CAAC;AACpD,UAAI,MAAO,WAAU,EAAE,EAAE,IAAI;AAAA,IAC/B;AACA,QAAI,OAAO,KAAK,SAAS,EAAE,SAAS,GAAG;AACrC,WAAK,SAAS,EAAE,GAAG,KAAK,QAAQ,GAAG,UAAU;AAC7C,WAAK,UAAU;AACf,WAAK,OAAO;AAAA,IACd;AACA,WAAO;AAAA,EACT;AAAA;AAAA,EAGA,QAAc;AAvXhB;AAwXI,SAAK,YAAY,EAAE,IAAI,UAAK,OAAO,qBAAZ,YAAgC,CAAC,EAAG;AAC3D,SAAK,gBAAgB;AACrB,SAAK,SAAS,CAAC;AACf,SAAK,UAAU;AACf,SAAK,OAAO;AAAA,EACd;AACF;","names":["q"]}
@@ -0,0 +1,116 @@
1
+ type QuestionType = "text" | "number" | "boolean" | "single" | "multi" | "date" | "rating" | "email";
2
+ type ConditionOperator = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "includes";
3
+ /** A single condition against one question's response. */
4
+ interface ShowIfCondition {
5
+ questionId: string;
6
+ value: unknown;
7
+ operator?: ConditionOperator;
8
+ }
9
+ /**
10
+ * Compound condition — nest `and`/`or` arrays for full branching logic.
11
+ *
12
+ * @example
13
+ * // Show only when user has diabetes AND is on insulin
14
+ * showIf: {
15
+ * and: [
16
+ * { questionId: 'conditions', value: 'diabetes', operator: 'includes' },
17
+ * { questionId: 'on_insulin', value: true },
18
+ * ]
19
+ * }
20
+ */
21
+ interface ShowIfCompound {
22
+ and?: ShowIfRule[];
23
+ or?: ShowIfRule[];
24
+ }
25
+ /** A single condition OR a compound AND/OR tree — fully nestable. */
26
+ type ShowIfRule = ShowIfCondition | ShowIfCompound;
27
+ interface QuestionValidation {
28
+ min?: number;
29
+ max?: number;
30
+ minLength?: number;
31
+ maxLength?: number;
32
+ regex?: string;
33
+ message?: string;
34
+ }
35
+ interface QuestionOption {
36
+ label: string;
37
+ value: string | number;
38
+ }
39
+ interface Question {
40
+ id: string;
41
+ text: string;
42
+ type: QuestionType;
43
+ description?: string;
44
+ required?: boolean;
45
+ placeholder?: string;
46
+ options?: QuestionOption[];
47
+ showIf?: ShowIfRule;
48
+ validation?: QuestionValidation;
49
+ }
50
+ type DisplayMode = "step" | "all";
51
+ interface QuestionnaireConfig {
52
+ questions: Question[];
53
+ mode?: DisplayMode;
54
+ initialResponses?: Record<string, unknown>;
55
+ }
56
+ interface QuestionnaireState {
57
+ /** Current question (step mode). In 'all' mode, always the first visible question. */
58
+ question: Question | null;
59
+ /** All currently visible questions (after applying showIf conditions). */
60
+ visibleQuestions: Question[];
61
+ /** Zero-based index of the current question in the visible questions array. */
62
+ questionIndex: number;
63
+ /** Total number of currently visible questions. */
64
+ totalQuestions: number;
65
+ /** Progress from 0 to 1 based on how many questions have valid answers. */
66
+ progress: number;
67
+ /** All responses keyed by question id. */
68
+ responses: Record<string, unknown>;
69
+ /** True when all required visible questions have valid responses. */
70
+ isComplete: boolean;
71
+ /** Validation errors keyed by question id. */
72
+ errors: Record<string, string>;
73
+ /** Whether the user can navigate to the previous question. */
74
+ canGoBack: boolean;
75
+ /** Whether the user can navigate to the next question. */
76
+ canGoNext: boolean;
77
+ }
78
+
79
+ type UseQuestionnaireReturn = QuestionnaireState & {
80
+ /**
81
+ * Answer the current step question (step mode).
82
+ * In `mode: "all"` use `answerById` instead.
83
+ */
84
+ answer: (value: unknown) => void;
85
+ /**
86
+ * Answer any visible question by its id — use this in `mode: "all"`.
87
+ * Safe to call in step mode too.
88
+ */
89
+ answerById: (questionId: string, value: unknown) => void;
90
+ /** Advance to the next step. Validates the current required question first. */
91
+ next: () => void;
92
+ /** Go back one step. No-op on step 0. */
93
+ back: () => void;
94
+ /** Jump directly to a step by index (0-based within visible questions). */
95
+ jumpTo: (index: number) => void;
96
+ /**
97
+ * Validate all visible required questions.
98
+ * Useful for a "Submit" gate in `mode: "all"`.
99
+ * Returns a map of questionId → error message for any invalid fields.
100
+ */
101
+ validate: () => Record<string, string>;
102
+ /** Reset all responses and restart from step 0. */
103
+ reset: () => void;
104
+ /**
105
+ * Returns only responses for currently visible questions.
106
+ * Use for form submission — avoids submitting hidden-branch data.
107
+ *
108
+ * NOTE: if you pass `config` with different `questions` after the initial
109
+ * render, the hook will NOT reinitialize. Use a `key` prop on the parent
110
+ * component to force remount when the question set changes.
111
+ */
112
+ getSubmittableResponses: () => Record<string, unknown>;
113
+ };
114
+ declare function useQuestionnaire(config: QuestionnaireConfig): UseQuestionnaireReturn;
115
+
116
+ export { type UseQuestionnaireReturn, useQuestionnaire };
@@ -0,0 +1,116 @@
1
+ type QuestionType = "text" | "number" | "boolean" | "single" | "multi" | "date" | "rating" | "email";
2
+ type ConditionOperator = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "includes";
3
+ /** A single condition against one question's response. */
4
+ interface ShowIfCondition {
5
+ questionId: string;
6
+ value: unknown;
7
+ operator?: ConditionOperator;
8
+ }
9
+ /**
10
+ * Compound condition — nest `and`/`or` arrays for full branching logic.
11
+ *
12
+ * @example
13
+ * // Show only when user has diabetes AND is on insulin
14
+ * showIf: {
15
+ * and: [
16
+ * { questionId: 'conditions', value: 'diabetes', operator: 'includes' },
17
+ * { questionId: 'on_insulin', value: true },
18
+ * ]
19
+ * }
20
+ */
21
+ interface ShowIfCompound {
22
+ and?: ShowIfRule[];
23
+ or?: ShowIfRule[];
24
+ }
25
+ /** A single condition OR a compound AND/OR tree — fully nestable. */
26
+ type ShowIfRule = ShowIfCondition | ShowIfCompound;
27
+ interface QuestionValidation {
28
+ min?: number;
29
+ max?: number;
30
+ minLength?: number;
31
+ maxLength?: number;
32
+ regex?: string;
33
+ message?: string;
34
+ }
35
+ interface QuestionOption {
36
+ label: string;
37
+ value: string | number;
38
+ }
39
+ interface Question {
40
+ id: string;
41
+ text: string;
42
+ type: QuestionType;
43
+ description?: string;
44
+ required?: boolean;
45
+ placeholder?: string;
46
+ options?: QuestionOption[];
47
+ showIf?: ShowIfRule;
48
+ validation?: QuestionValidation;
49
+ }
50
+ type DisplayMode = "step" | "all";
51
+ interface QuestionnaireConfig {
52
+ questions: Question[];
53
+ mode?: DisplayMode;
54
+ initialResponses?: Record<string, unknown>;
55
+ }
56
+ interface QuestionnaireState {
57
+ /** Current question (step mode). In 'all' mode, always the first visible question. */
58
+ question: Question | null;
59
+ /** All currently visible questions (after applying showIf conditions). */
60
+ visibleQuestions: Question[];
61
+ /** Zero-based index of the current question in the visible questions array. */
62
+ questionIndex: number;
63
+ /** Total number of currently visible questions. */
64
+ totalQuestions: number;
65
+ /** Progress from 0 to 1 based on how many questions have valid answers. */
66
+ progress: number;
67
+ /** All responses keyed by question id. */
68
+ responses: Record<string, unknown>;
69
+ /** True when all required visible questions have valid responses. */
70
+ isComplete: boolean;
71
+ /** Validation errors keyed by question id. */
72
+ errors: Record<string, string>;
73
+ /** Whether the user can navigate to the previous question. */
74
+ canGoBack: boolean;
75
+ /** Whether the user can navigate to the next question. */
76
+ canGoNext: boolean;
77
+ }
78
+
79
+ type UseQuestionnaireReturn = QuestionnaireState & {
80
+ /**
81
+ * Answer the current step question (step mode).
82
+ * In `mode: "all"` use `answerById` instead.
83
+ */
84
+ answer: (value: unknown) => void;
85
+ /**
86
+ * Answer any visible question by its id — use this in `mode: "all"`.
87
+ * Safe to call in step mode too.
88
+ */
89
+ answerById: (questionId: string, value: unknown) => void;
90
+ /** Advance to the next step. Validates the current required question first. */
91
+ next: () => void;
92
+ /** Go back one step. No-op on step 0. */
93
+ back: () => void;
94
+ /** Jump directly to a step by index (0-based within visible questions). */
95
+ jumpTo: (index: number) => void;
96
+ /**
97
+ * Validate all visible required questions.
98
+ * Useful for a "Submit" gate in `mode: "all"`.
99
+ * Returns a map of questionId → error message for any invalid fields.
100
+ */
101
+ validate: () => Record<string, string>;
102
+ /** Reset all responses and restart from step 0. */
103
+ reset: () => void;
104
+ /**
105
+ * Returns only responses for currently visible questions.
106
+ * Use for form submission — avoids submitting hidden-branch data.
107
+ *
108
+ * NOTE: if you pass `config` with different `questions` after the initial
109
+ * render, the hook will NOT reinitialize. Use a `key` prop on the parent
110
+ * component to force remount when the question set changes.
111
+ */
112
+ getSubmittableResponses: () => Record<string, unknown>;
113
+ };
114
+ declare function useQuestionnaire(config: QuestionnaireConfig): UseQuestionnaireReturn;
115
+
116
+ export { type UseQuestionnaireReturn, useQuestionnaire };
@@ -0,0 +1,350 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/react/index.ts
21
+ var react_exports = {};
22
+ __export(react_exports, {
23
+ useQuestionnaire: () => useQuestionnaire
24
+ });
25
+ module.exports = __toCommonJS(react_exports);
26
+ var import_react = require("react");
27
+
28
+ // src/core/index.ts
29
+ function evaluateSimple(condition, responses) {
30
+ var _a;
31
+ const response = responses[condition.questionId];
32
+ if (response === void 0 || response === null) return false;
33
+ const op = (_a = condition.operator) != null ? _a : "eq";
34
+ const val = condition.value;
35
+ switch (op) {
36
+ case "eq":
37
+ return response === val;
38
+ case "neq":
39
+ return response !== val;
40
+ case "gt":
41
+ return typeof response === "number" && typeof val === "number" && response > val;
42
+ case "lt":
43
+ return typeof response === "number" && typeof val === "number" && response < val;
44
+ case "gte":
45
+ return typeof response === "number" && typeof val === "number" && response >= val;
46
+ case "lte":
47
+ return typeof response === "number" && typeof val === "number" && response <= val;
48
+ case "includes":
49
+ return Array.isArray(response) ? response.includes(val) : String(response).includes(String(val));
50
+ default:
51
+ return response === val;
52
+ }
53
+ }
54
+ var MAX_CONDITION_DEPTH = 20;
55
+ function evaluateRule(rule, responses, depth = 0) {
56
+ if (depth > MAX_CONDITION_DEPTH) {
57
+ console.warn("[questify] showIf condition tree exceeds maximum depth. Defaulting to visible.");
58
+ return true;
59
+ }
60
+ if ("and" in rule && rule.and) {
61
+ if (rule.and.length === 0) return true;
62
+ return rule.and.every((r) => evaluateRule(r, responses, depth + 1));
63
+ }
64
+ if ("or" in rule && rule.or) {
65
+ if (rule.or.length === 0) return false;
66
+ return rule.or.some((r) => evaluateRule(r, responses, depth + 1));
67
+ }
68
+ return evaluateSimple(rule, responses);
69
+ }
70
+ function getVisibleQuestions(questions, responses) {
71
+ return questions.filter((q) => q.showIf ? evaluateRule(q.showIf, responses) : true);
72
+ }
73
+ function isEmpty(value) {
74
+ if (value === void 0 || value === null) return true;
75
+ if (typeof value === "string" && value.trim() === "") return true;
76
+ if (Array.isArray(value) && value.length === 0) return true;
77
+ return false;
78
+ }
79
+ function validateAnswer(question, value) {
80
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i;
81
+ if (question.required && isEmpty(value)) {
82
+ return (_b = (_a = question.validation) == null ? void 0 : _a.message) != null ? _b : "This field is required.";
83
+ }
84
+ if (isEmpty(value)) return null;
85
+ const v = question.validation;
86
+ if (question.type === "number" || question.type === "rating") {
87
+ const num = Number(value);
88
+ if (isNaN(num) || !isFinite(num)) return "Must be a valid number.";
89
+ if ((v == null ? void 0 : v.min) !== void 0 && num < v.min) return (_c = v.message) != null ? _c : `Minimum value is ${v.min}.`;
90
+ if ((v == null ? void 0 : v.max) !== void 0 && num > v.max) return (_d = v.message) != null ? _d : `Maximum value is ${v.max}.`;
91
+ }
92
+ if (question.type === "text") {
93
+ const str = String(value).trim();
94
+ if ((v == null ? void 0 : v.minLength) !== void 0 && str.length < v.minLength)
95
+ return (_e = v.message) != null ? _e : `Minimum ${v.minLength} characters required.`;
96
+ if ((v == null ? void 0 : v.maxLength) !== void 0 && str.length > v.maxLength)
97
+ return (_f = v.message) != null ? _f : `Maximum ${v.maxLength} characters allowed.`;
98
+ if (v == null ? void 0 : v.regex) {
99
+ try {
100
+ if (!new RegExp(v.regex).test(str)) return (_g = v.message) != null ? _g : "Invalid format.";
101
+ } catch {
102
+ }
103
+ }
104
+ }
105
+ if (question.type === "email") {
106
+ const str = String(value).trim();
107
+ if ((v == null ? void 0 : v.minLength) !== void 0 && str.length < v.minLength)
108
+ return (_h = v.message) != null ? _h : `Minimum ${v.minLength} characters required.`;
109
+ if ((v == null ? void 0 : v.maxLength) !== void 0 && str.length > v.maxLength)
110
+ return (_i = v.message) != null ? _i : `Maximum ${v.maxLength} characters allowed.`;
111
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str))
112
+ return "Please enter a valid email address.";
113
+ }
114
+ if (question.type === "date") {
115
+ const str = String(value);
116
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(str)) return "Please enter a valid date (YYYY-MM-DD).";
117
+ const d = /* @__PURE__ */ new Date(str + "T00:00:00");
118
+ if (isNaN(d.getTime())) return "Please enter a valid date.";
119
+ }
120
+ return null;
121
+ }
122
+ function computeProgress(visibleQuestions, responses) {
123
+ if (visibleQuestions.length === 0) return 0;
124
+ const answered = visibleQuestions.filter((q) => !isEmpty(responses[q.id])).length;
125
+ return answered / visibleQuestions.length;
126
+ }
127
+ function checkComplete(visibleQuestions, responses) {
128
+ return visibleQuestions.filter((q) => q.required).every((q) => !isEmpty(responses[q.id]) && validateAnswer(q, responses[q.id]) === null);
129
+ }
130
+ function buildState(config, responses, questionIndex, errors) {
131
+ var _a;
132
+ const visibleQuestions = getVisibleQuestions(config.questions, responses);
133
+ const safeIndex = Math.min(questionIndex, Math.max(0, visibleQuestions.length - 1));
134
+ const question = (_a = visibleQuestions[safeIndex]) != null ? _a : null;
135
+ const visibleIds = new Set(visibleQuestions.map((q) => q.id));
136
+ const visibleErrors = Object.fromEntries(
137
+ Object.entries(errors).filter(([id]) => visibleIds.has(id))
138
+ );
139
+ return {
140
+ question,
141
+ visibleQuestions,
142
+ questionIndex: safeIndex,
143
+ totalQuestions: visibleQuestions.length,
144
+ progress: computeProgress(visibleQuestions, responses),
145
+ // [H-2] Defensive copy — prevents external mutation of internal state
146
+ responses: { ...responses },
147
+ isComplete: checkComplete(visibleQuestions, responses),
148
+ errors: visibleErrors,
149
+ canGoBack: safeIndex > 0,
150
+ canGoNext: safeIndex < visibleQuestions.length - 1
151
+ };
152
+ }
153
+ var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
154
+ var Questionnaire = class {
155
+ constructor(config) {
156
+ this.subscribers = [];
157
+ var _a;
158
+ const ids = config.questions.map((q) => q.id);
159
+ const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
160
+ if (dupes.length > 0) {
161
+ console.warn(
162
+ `[questify] Duplicate question IDs detected: ${[...new Set(dupes)].join(", ")}. Each question must have a unique id.`
163
+ );
164
+ }
165
+ this.config = { mode: "step", ...config };
166
+ this.responses = { ...(_a = config.initialResponses) != null ? _a : {} };
167
+ this.questionIndex = 0;
168
+ this.errors = {};
169
+ this.cachedState = buildState(this.config, this.responses, 0, {});
170
+ }
171
+ recompute() {
172
+ this.cachedState = buildState(
173
+ this.config,
174
+ this.responses,
175
+ this.questionIndex,
176
+ this.errors
177
+ );
178
+ }
179
+ notify() {
180
+ const snapshot = this.subscribers.slice();
181
+ snapshot.forEach((s) => s(this.cachedState));
182
+ }
183
+ // ── Read ──────────────────────────────────────────────────────────────────
184
+ getState() {
185
+ return this.cachedState;
186
+ }
187
+ /**
188
+ * Returns only responses for currently visible questions.
189
+ * Use this for form submission — avoids sending hidden-branch data to the server.
190
+ *
191
+ * `state.responses` retains all answers for back-navigation; this method prunes them.
192
+ */
193
+ getSubmittableResponses() {
194
+ const { visibleQuestions } = this.cachedState;
195
+ const visibleIds = new Set(visibleQuestions.map((q) => q.id));
196
+ return Object.fromEntries(
197
+ Object.entries(this.responses).filter(([id]) => visibleIds.has(id))
198
+ );
199
+ }
200
+ subscribe(callback) {
201
+ this.subscribers.push(callback);
202
+ callback(this.cachedState);
203
+ return () => {
204
+ this.subscribers = this.subscribers.filter((s) => s !== callback);
205
+ };
206
+ }
207
+ // ── Write ─────────────────────────────────────────────────────────────────
208
+ /**
209
+ * Answer the current question (step mode).
210
+ * In `mode: "all"` prefer `answerById()`.
211
+ */
212
+ answer(value) {
213
+ const { visibleQuestions, questionIndex } = this.cachedState;
214
+ const q = visibleQuestions[questionIndex];
215
+ if (!q) return;
216
+ this._setAnswer(q, value);
217
+ }
218
+ /**
219
+ * Answer any visible question by its id — the right API for `mode: "all"`.
220
+ * Safe to call in step mode too (allows answering any step by id).
221
+ */
222
+ answerById(questionId, value) {
223
+ const q = this.cachedState.visibleQuestions.find((q2) => q2.id === questionId);
224
+ if (!q) return;
225
+ this._setAnswer(q, value);
226
+ }
227
+ _setAnswer(question, value) {
228
+ if (UNSAFE_KEYS.has(question.id)) return;
229
+ this.responses = { ...this.responses, [question.id]: value };
230
+ const error = validateAnswer(question, value);
231
+ if (error) {
232
+ this.errors = { ...this.errors, [question.id]: error };
233
+ } else {
234
+ const { [question.id]: _dropped, ...rest } = this.errors;
235
+ this.errors = rest;
236
+ }
237
+ this.recompute();
238
+ this.notify();
239
+ }
240
+ /**
241
+ * Advance to the next step (step mode).
242
+ * Validates the current required question first; populates `errors` if invalid.
243
+ */
244
+ next() {
245
+ const { visibleQuestions, questionIndex } = this.cachedState;
246
+ const q = visibleQuestions[questionIndex];
247
+ if (q) {
248
+ const error = validateAnswer(q, this.responses[q.id]);
249
+ if (error) {
250
+ this.errors = { ...this.errors, [q.id]: error };
251
+ this.recompute();
252
+ this.notify();
253
+ return;
254
+ }
255
+ }
256
+ if (questionIndex < visibleQuestions.length - 1) {
257
+ this.questionIndex = questionIndex + 1;
258
+ this.recompute();
259
+ this.notify();
260
+ }
261
+ }
262
+ /** Go to the previous step. No-op on step 0. */
263
+ back() {
264
+ if (this.questionIndex > 0) {
265
+ this.questionIndex -= 1;
266
+ this.recompute();
267
+ this.notify();
268
+ }
269
+ }
270
+ /**
271
+ * Jump to a specific step index (0-based, within visible questions).
272
+ * Out-of-range indices are silently ignored.
273
+ */
274
+ jumpTo(index) {
275
+ const { visibleQuestions } = this.cachedState;
276
+ if (index >= 0 && index < visibleQuestions.length) {
277
+ this.questionIndex = index;
278
+ this.recompute();
279
+ this.notify();
280
+ }
281
+ }
282
+ /**
283
+ * Validate all visible required questions and return a map of errors.
284
+ * Useful for "submit" gates in `mode: "all"`.
285
+ */
286
+ validate() {
287
+ const newErrors = {};
288
+ for (const q of this.cachedState.visibleQuestions) {
289
+ const error = validateAnswer(q, this.responses[q.id]);
290
+ if (error) newErrors[q.id] = error;
291
+ }
292
+ if (Object.keys(newErrors).length > 0) {
293
+ this.errors = { ...this.errors, ...newErrors };
294
+ this.recompute();
295
+ this.notify();
296
+ }
297
+ return newErrors;
298
+ }
299
+ /** Reset all responses and errors, restart from step 0. */
300
+ reset() {
301
+ var _a;
302
+ this.responses = { ...(_a = this.config.initialResponses) != null ? _a : {} };
303
+ this.questionIndex = 0;
304
+ this.errors = {};
305
+ this.recompute();
306
+ this.notify();
307
+ }
308
+ };
309
+
310
+ // src/react/index.ts
311
+ function useQuestionnaire(config) {
312
+ const qRef = (0, import_react.useRef)(null);
313
+ if (!qRef.current) {
314
+ qRef.current = new Questionnaire(config);
315
+ }
316
+ const [state, setState] = (0, import_react.useState)(
317
+ () => qRef.current.getState()
318
+ );
319
+ (0, import_react.useEffect)(() => {
320
+ const unsubscribe = qRef.current.subscribe(setState);
321
+ return unsubscribe;
322
+ }, []);
323
+ const answer = (0, import_react.useCallback)((value) => qRef.current.answer(value), []);
324
+ const answerById = (0, import_react.useCallback)((id, value) => qRef.current.answerById(id, value), []);
325
+ const next = (0, import_react.useCallback)(() => qRef.current.next(), []);
326
+ const back = (0, import_react.useCallback)(() => qRef.current.back(), []);
327
+ const jumpTo = (0, import_react.useCallback)((index) => qRef.current.jumpTo(index), []);
328
+ const validate = (0, import_react.useCallback)(() => qRef.current.validate(), []);
329
+ const reset = (0, import_react.useCallback)(() => qRef.current.reset(), []);
330
+ const getSubmittableResponses = (0, import_react.useCallback)(
331
+ () => qRef.current.getSubmittableResponses(),
332
+ []
333
+ );
334
+ return {
335
+ ...state,
336
+ answer,
337
+ answerById,
338
+ next,
339
+ back,
340
+ jumpTo,
341
+ validate,
342
+ reset,
343
+ getSubmittableResponses
344
+ };
345
+ }
346
+ // Annotate the CommonJS export names for ESM import in node:
347
+ 0 && (module.exports = {
348
+ useQuestionnaire
349
+ });
350
+ //# sourceMappingURL=index.js.map