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
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;
|