"use client"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; type QuestionProgressContextValue = { answered: number; total: number; isCompleted: boolean; markQuestionPassed: (questionIndex: number) => void; }; const QuestionProgressContext = createContext({ answered: 0, total: 0, isCompleted: false, markQuestionPassed: () => {}, }); export function useQuestionProgress() { return useContext(QuestionProgressContext); } type QuestionProgressTrackerProps = { children: ReactNode; total: number; }; function isQuestionAnswered(question: Element) { const explicitAnsweredState = question.getAttribute("data-question-answered") ?? question .querySelector("[data-question-answered]") ?.getAttribute("data-question-answered"); if (explicitAnsweredState === "true") { return true; } if (explicitAnsweredState === "false") { return false; } const inputs = Array.from( question.querySelectorAll< HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement >("input, select, textarea"), ); return inputs.some((input) => { if (input instanceof HTMLInputElement) { if (input.type === "checkbox" || input.type === "radio") { return input.checked; } if (input.type === "file") { return input.files !== null && input.files.length > 0; } } return input.value.trim().length > 0; }); } function isQuestionOptional(question: Element) { return ( question.getAttribute("data-question-optional") === "true" || question.getAttribute("data-question-required") === "false" ); } function isCurrentQuestion(question: Element) { return question.closest('[aria-current="step"]') !== null; } export function QuestionProgressTracker({ children, total: initialTotal, }: QuestionProgressTrackerProps) { const containerRef = useRef(null); const [answered, setAnswered] = useState(0); const [total, setTotal] = useState(initialTotal); const [passedQuestionIndexes, setPassedQuestionIndexes] = useState< Set >(() => new Set()); const safeTotal = Math.max(total, 0); const progress = safeTotal > 0 ? (answered / safeTotal) * 100 : 0; const isCompleted = safeTotal === 0 || answered >= safeTotal; const markQuestionPassed = useCallback((questionIndex: number) => { setPassedQuestionIndexes((currentIndexes) => { if (currentIndexes.has(questionIndex)) { return currentIndexes; } return new Set(currentIndexes).add(questionIndex); }); }, []); const updateProgress = useCallback(() => { const container = containerRef.current; if (!container) { return; } const activeQuestions = Array.from( container.querySelectorAll("[data-question-disabled]"), ).filter((el) => el.getAttribute("data-question-disabled") !== "true"); const nextTotal = activeQuestions.length; const nextAnswered = activeQuestions.filter((question) => { const questionIndex = Number( question.getAttribute("data-question-index"), ); return ( isQuestionAnswered(question) || passedQuestionIndexes.has(questionIndex) || (isQuestionOptional(question) && isCurrentQuestion(question)) ); }).length; setTotal(nextTotal); setAnswered(nextAnswered); }, [passedQuestionIndexes]); useEffect(() => { const container = containerRef.current; if (!container) { return; } const observer = new MutationObserver(updateProgress); observer.observe(container, { attributeFilter: [ "aria-current", "data-question-answered", "data-question-disabled", "data-question-optional", "data-question-required", ], attributes: true, subtree: true, }); updateProgress(); return () => observer.disconnect(); }, [updateProgress]); const contextValue = useMemo( () => ({ answered, total: safeTotal, isCompleted, markQuestionPassed }), [answered, safeTotal, isCompleted, markQuestionPassed], ); return (
fields to complete {answered} /{safeTotal}
0 ? "min-w-[37px]" : "", ] .filter(Boolean) .join(" ")} style={{ width: `${progress}%` }} />
{children}
); } export default QuestionProgressTracker;