|
|
@ -1,13 +1,34 @@ |
|
|
"use client"; |
|
|
"use client"; |
|
|
|
|
|
|
|
|
import { |
|
|
import { |
|
|
|
|
|
createContext, |
|
|
type ReactNode, |
|
|
type ReactNode, |
|
|
useCallback, |
|
|
useCallback, |
|
|
|
|
|
useContext, |
|
|
useEffect, |
|
|
useEffect, |
|
|
|
|
|
useMemo, |
|
|
useRef, |
|
|
useRef, |
|
|
useState, |
|
|
useState, |
|
|
} from "react"; |
|
|
} 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 = { |
|
|
type QuestionProgressTrackerProps = { |
|
|
children: ReactNode; |
|
|
children: ReactNode; |
|
|
total: number; |
|
|
total: number; |
|
|
@ -49,6 +70,17 @@ function isQuestionAnswered(question: Element) { |
|
|
}); |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
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({ |
|
|
export function QuestionProgressTracker({ |
|
|
children, |
|
|
children, |
|
|
total: initialTotal, |
|
|
total: initialTotal, |
|
|
@ -56,8 +88,22 @@ export function QuestionProgressTracker({ |
|
|
const containerRef = useRef<HTMLDivElement>(null); |
|
|
const containerRef = useRef<HTMLDivElement>(null); |
|
|
const [answered, setAnswered] = useState(0); |
|
|
const [answered, setAnswered] = useState(0); |
|
|
const [total, setTotal] = useState(initialTotal); |
|
|
const [total, setTotal] = useState(initialTotal); |
|
|
|
|
|
const [passedQuestionIndexes, setPassedQuestionIndexes] = useState< |
|
|
|
|
|
Set<number> |
|
|
|
|
|
>(() => new Set()); |
|
|
const safeTotal = Math.max(total, 0); |
|
|
const safeTotal = Math.max(total, 0); |
|
|
const progress = safeTotal > 0 ? (answered / safeTotal) * 100 : 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 updateProgress = useCallback(() => { |
|
|
const container = containerRef.current; |
|
|
const container = containerRef.current; |
|
|
@ -71,11 +117,20 @@ export function QuestionProgressTracker({ |
|
|
).filter((el) => el.getAttribute("data-question-disabled") !== "true"); |
|
|
).filter((el) => el.getAttribute("data-question-disabled") !== "true"); |
|
|
|
|
|
|
|
|
const nextTotal = activeQuestions.length; |
|
|
const nextTotal = activeQuestions.length; |
|
|
const nextAnswered = activeQuestions.filter(isQuestionAnswered).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); |
|
|
setTotal(nextTotal); |
|
|
setAnswered(nextAnswered); |
|
|
setAnswered(nextAnswered); |
|
|
}, []); |
|
|
|
|
|
|
|
|
}, [passedQuestionIndexes]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
useEffect(() => { |
|
|
const container = containerRef.current; |
|
|
const container = containerRef.current; |
|
|
@ -87,7 +142,13 @@ export function QuestionProgressTracker({ |
|
|
const observer = new MutationObserver(updateProgress); |
|
|
const observer = new MutationObserver(updateProgress); |
|
|
|
|
|
|
|
|
observer.observe(container, { |
|
|
observer.observe(container, { |
|
|
attributeFilter: ["data-question-answered"], |
|
|
|
|
|
|
|
|
attributeFilter: [ |
|
|
|
|
|
"aria-current", |
|
|
|
|
|
"data-question-answered", |
|
|
|
|
|
"data-question-disabled", |
|
|
|
|
|
"data-question-optional", |
|
|
|
|
|
"data-question-required", |
|
|
|
|
|
], |
|
|
attributes: true, |
|
|
attributes: true, |
|
|
subtree: true, |
|
|
subtree: true, |
|
|
}); |
|
|
}); |
|
|
@ -97,45 +158,51 @@ export function QuestionProgressTracker({ |
|
|
return () => observer.disconnect(); |
|
|
return () => observer.disconnect(); |
|
|
}, [updateProgress]); |
|
|
}, [updateProgress]); |
|
|
|
|
|
|
|
|
|
|
|
const contextValue = useMemo( |
|
|
|
|
|
() => ({ answered, total: safeTotal, isCompleted, markQuestionPassed }), |
|
|
|
|
|
[answered, safeTotal, isCompleted, markQuestionPassed], |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
return ( |
|
|
return ( |
|
|
<div |
|
|
|
|
|
ref={containerRef} |
|
|
|
|
|
className="flex flex-col flex-1 min-h-0" |
|
|
|
|
|
onChange={updateProgress} |
|
|
|
|
|
onInput={updateProgress} |
|
|
|
|
|
> |
|
|
|
|
|
<div className="mb-5 h-[68px]" aria-hidden="true" /> |
|
|
|
|
|
<div className="fixed top-[100px] left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 px-[17px]"> |
|
|
|
|
|
<div |
|
|
|
|
|
aria-label={`Answered questions: ${answered} of ${safeTotal}`} |
|
|
|
|
|
aria-valuemax={safeTotal} |
|
|
|
|
|
aria-valuemin={0} |
|
|
|
|
|
aria-valuenow={answered} |
|
|
|
|
|
role="progressbar" |
|
|
|
|
|
className="w-full rounded-none bg-[#F7F1F0] px-[9px] pt-[25px] pb-[16px]" |
|
|
|
|
|
> |
|
|
|
|
|
<div className="mb-[7px] flex items-center justify-between text-xs 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}%` }} |
|
|
|
|
|
/> |
|
|
|
|
|
|
|
|
<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-2"> |
|
|
|
|
|
<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 text-xs 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> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
{children} |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
{children} |
|
|
|
|
|
</div> |
|
|
|
|
|
</QuestionProgressContext.Provider> |
|
|
); |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|