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:
@@ -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" ? (
|
||||
|
||||
Reference in New Issue
Block a user