cpns / apps /web /src /components /test /attempt /FloatingNav.tsx
rogasper's picture
feat: update accessibility guidelines and enhance component structure. Introduce new frontend accessibility conventions in AGENTS.md, including heading hierarchy and ARIA roles for dialogs and buttons. Refactor TourGuide and related components for lazy loading and improved user experience. Update ESLint configuration to enforce stricter TypeScript rules and add new dependencies for linting. Enhance various components with accessibility features and ensure consistent styling across the application.
9205030
Raw
History Blame Contribute Delete
4.58 kB
import type { RefObject, KeyboardEvent } from "react";
import { MaterialIcon } from "@/components/ui/MaterialIcon";
interface FloatingNavProps {
questions: Array<{ id: string }>;
activeQuestionId: string | null;
answers: Record<string, string>;
markedQuestions: Set<string>;
onGoToQuestion: (qId: string) => void;
onScrollToTop: () => void;
navSliderRef?: RefObject<HTMLDivElement | null>;
}
export function FloatingNav({
questions,
activeQuestionId,
answers,
markedQuestions,
onGoToQuestion,
onScrollToTop,
navSliderRef,
}: FloatingNavProps) {
const isAnswered = (qId: string) => !!answers[qId];
const isMarked = (qId: string) => markedQuestions.has(qId);
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (questions.length === 0) return;
const activeIdx = questions.findIndex((q) => q.id === activeQuestionId);
if (e.key === "ArrowRight") {
e.preventDefault();
const next = Math.min(questions.length - 1, Math.max(0, activeIdx) + 1);
onGoToQuestion(questions[next].id);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
const prev = Math.max(0, Math.max(0, activeIdx) - 1);
onGoToQuestion(questions[prev].id);
} else if (e.key === "Home") {
e.preventDefault();
onGoToQuestion(questions[0].id);
} else if (e.key === "End") {
e.preventDefault();
onGoToQuestion(questions[questions.length - 1].id);
}
};
return (
<nav
className="fixed bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 p-2 bg-[var(--clay-black)]/95 backdrop-blur-md rounded-full shadow-2xl border border-white/10 z-40 max-w-[90vw]"
aria-label="Navigasi soal"
>
<button
onClick={() => {
navSliderRef?.current?.scrollBy({ left: -200, behavior: "smooth" });
}}
className="w-8 h-8 shrink-0 flex items-center justify-center rounded-full bg-white/10 text-white/60 hover:bg-white/20 hover:text-white transition-all"
aria-label="Geser navigasi ke kiri"
>
<MaterialIcon name="chevron_left" className="text-sm" />
</button>
<div
ref={navSliderRef}
className="flex items-center gap-1 overflow-x-auto hide-scrollbar max-w-[60vw] sm:max-w-[50vw] md:max-w-[40vw] lg:max-w-[30vw]"
role="tablist"
aria-label="Daftar soal"
onKeyDown={handleKeyDown}
>
{questions.map((q, gIdx) => {
const answered = isAnswered(q.id);
const marked = isMarked(q.id);
const isActive = q.id === activeQuestionId;
return (
<button
key={q.id}
data-qid={q.id}
onClick={() => onGoToQuestion(q.id)}
role="tab"
aria-selected={isActive}
tabIndex={isActive ? 0 : -1}
aria-controls={`question-panel-${q.id}`}
aria-label={`Soal ${gIdx + 1}${marked ? " (ditandai)" : ""}${answered ? " (sudah dijawab)" : ""}`}
className={`relative w-7 h-7 sm:w-8 sm:h-8 shrink-0 flex items-center justify-center rounded-full font-bold text-[10px] sm:text-xs transition-all ${
answered
? "bg-[var(--matcha-600)] text-[var(--pure-white)]"
: isActive
? "bg-white/30 text-white"
: "bg-white/10 text-white/60 hover:bg-white/20"
}`}
>
{gIdx + 1}
{marked && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-[var(--pomegranate-400)] rounded-full border border-[var(--clay-black)]" />
)}
</button>
);
})}
</div>
<button
onClick={() => {
navSliderRef?.current?.scrollBy({ left: 200, behavior: "smooth" });
}}
className="w-8 h-8 shrink-0 flex items-center justify-center rounded-full bg-white/10 text-white/60 hover:bg-white/20 hover:text-white transition-all"
aria-label="Geser navigasi ke kanan"
>
<MaterialIcon name="chevron_right" className="text-sm" />
</button>
<div className="w-px h-6 bg-white/10 shrink-0" />
<button
onClick={onScrollToTop}
className="flex items-center gap-1 px-3 py-2 shrink-0 rounded-full text-white/80 font-semibold text-xs hover:text-white transition-all"
aria-label="Kembali ke atas"
>
<MaterialIcon name="arrow_upward" className="text-sm" />
<span className="hidden sm:inline">Atas</span>
</button>
</nav>
);
}