You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

209 lines
5.5 KiB

"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<QuestionProgressContextValue>({
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<HTMLDivElement>(null);
const [answered, setAnswered] = useState(0);
const [total, setTotal] = useState(initialTotal);
const [passedQuestionIndexes, setPassedQuestionIndexes] = useState<
Set<number>
>(() => 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 (
<QuestionProgressContext.Provider value={contextValue}>
<div
ref={containerRef}
className="flex flex-col flex-1 min-h-0"
onChange={updateProgress}
onInput={updateProgress}
>
<div className="w-full shrink-0 px-[17px] pt-2 pb-[24px]">
<div
aria-label={`Answered questions: ${answered} of ${safeTotal}`}
aria-valuemax={safeTotal}
aria-valuemin={0}
aria-valuenow={answered}
role="progressbar"
className="w-full bg-[#F7F1F0]"
>
<div className="mb-[7px] flex items-center justify-between group-10 leading-none font-normal text-[#747474]">
<span>fields to complete</span>
<span>
{answered} /{safeTotal}
</span>
</div>
<div className="relative h-[6px] rounded-full bg-[#D8D8D8]">
<div
className={[
"absolute top-1/2 h-[10px] -translate-y-1/2 rounded-full bg-[#F2465F] transition-[width] duration-200",
answered > 0 ? "min-w-[37px]" : "",
]
.filter(Boolean)
.join(" ")}
style={{ width: `${progress}%` }}
/>
</div>
</div>
</div>
{children}
</div>
</QuestionProgressContext.Provider>
);
}
export default QuestionProgressTracker;