feat(surveys): entrance animation, step progress, Esc-to-close

Polish pass on the survey popover:
- Entrance: slide-up + fade on mount (300ms ease-out, respects
  prefers-reduced-motion via motion-reduce:transition-none).
- Step progress: "1 / N" indicator on multi-question surveys so users know
  how much is left.
- Esc closes the card (counts as a dismissal); listener attached once with a
  dismissRef so it captures current answers, not a stale first-render closure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 01:28:45 +03:00
parent 34cb361b67
commit 43dd4d38ac

View File

@@ -41,7 +41,9 @@ function SurveyCard({ survey }: { survey: Survey }) {
const [openText, setOpenText] = useState("");
const [rating, setRating] = useState<number | null>(null);
const [done, setDone] = useState(false);
const [shown, setShown] = useState(false);
const advanceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const dismissRef = useRef<() => void>(() => {});
const questions = survey.questions;
const q = questions[step];
@@ -55,6 +57,25 @@ function SurveyCard({ survey }: { survey: Survey }) {
// Cancel a pending rating auto-advance if the card unmounts mid-debounce.
useEffect(() => () => clearTimeout(advanceTimer.current ?? undefined), []);
// Entrance: mount hidden, flip on the next frame so the CSS transition runs
// (slide-up + fade). prefers-reduced-motion users skip straight to the end
// state via the motion-reduce: utilities on the card.
useEffect(() => {
const id = requestAnimationFrame(() => setShown(true));
return () => cancelAnimationFrame(id);
}, []);
// Esc closes the popover (counts as a dismissal). The listener is attached
// once; dismissRef keeps it pointed at the latest closure so it captures the
// current answers/step rather than a stale first-render one.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") dismissRef.current();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const qid = q?.id ?? `q${step}`;
const choices = q && "choices" in q && Array.isArray(q.choices) ? q.choices : [];
const hasOpenChoice = Boolean(q && "hasOpenChoice" in q && q.hasOpenChoice);
@@ -117,6 +138,7 @@ function SurveyCard({ survey }: { survey: Survey }) {
}
dismissActiveSurvey(collect());
}
dismissRef.current = dismiss;
const buttonLabel =
(q && "buttonText" in q && q.buttonText) || (step + 1 < questions.length ? "Devam" : "Gönder");
@@ -127,7 +149,9 @@ function SurveyCard({ survey }: { survey: Survey }) {
return (
<section
aria-label={survey.name}
className="fixed bottom-28 right-4 z-[70] w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-border bg-card p-5 text-card-foreground shadow-2xl sm:bottom-24"
className={`fixed bottom-28 right-4 z-[70] w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-border bg-card p-5 text-card-foreground shadow-2xl transition-all duration-300 ease-out motion-reduce:transition-none sm:bottom-24 ${
shown ? "translate-y-0 opacity-100" : "translate-y-3 opacity-0"
}`}
>
<button
type="button"
@@ -154,6 +178,11 @@ function SurveyCard({ survey }: { survey: Survey }) {
</div>
) : q ? (
<div>
{questions.length > 1 ? (
<p className="mb-2 text-xs font-medium tabular-nums text-muted-foreground">
{step + 1} / {questions.length}
</p>
) : null}
<p className="pr-6 text-sm font-medium leading-snug">{q.question}</p>
{q.type === "single_choice" ? (