Browse Source

fix(sheets): resolve bottom sheet bounce and double-reopen lifecycle bug on exit

master
mortezaei 12 hours ago
parent
commit
3560001496
  1. 27
      src/app/globals.css
  2. 5
      src/app/questions-list/[slug]/answer-pace-sheet.tsx
  3. 5
      src/app/questions-list/sections-request.tsx
  4. 48
      src/components/Componentes/currency-sheet.tsx
  5. 52
      src/components/Componentes/help-modal.tsx
  6. 54
      src/components/Componentes/information-sheet.tsx
  7. 2
      src/components/Componentes/question-birthplace.tsx
  8. 2
      src/components/Componentes/question-date-sheet.tsx
  9. 2
      src/components/Componentes/question-phone.tsx
  10. 2
      src/components/Componentes/question-sheet.tsx
  11. 46
      src/components/Componentes/support-sheet.tsx
  12. 5
      src/components/Componentes/test-completed-sheet.tsx
  13. 5
      src/components/Componentes/test-exit-sheet.tsx
  14. 29
      src/components/Componentes/test-questions-flow.tsx

27
src/app/globals.css

@ -268,6 +268,15 @@ body.section-overlay-open .app-shell {
}
}
@keyframes flutter-bottomsheet-exit {
0% {
transform: translate3d(0, 0, 0);
}
100% {
transform: translate3d(0, 100%, 0);
}
}
@keyframes flutter-scrim-enter {
0% {
opacity: 0;
@ -277,15 +286,24 @@ body.section-overlay-open .app-shell {
}
}
@keyframes flutter-scrim-exit {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.flutter-sheet-surface {
animation: flutter-bottomsheet-enter 250ms cubic-bezier(0, 0, 0.2, 1) forwards;
will-change: transform;
}
.flutter-sheet-surface-exit {
transform: translate3d(0, 100%, 0) !important;
transition: transform 200ms cubic-bezier(0.4, 0, 1, 1) !important;
animation: flutter-bottomsheet-exit 200ms cubic-bezier(0.4, 0, 1, 1) forwards !important;
will-change: transform;
pointer-events: none;
}
.flutter-scrim {
@ -295,9 +313,10 @@ body.section-overlay-open .app-shell {
}
.flutter-scrim-exit {
opacity: 0 !important;
transition: opacity 200ms cubic-bezier(0.4, 0, 1, 1) !important;
background-color: rgba(0, 0, 0, 0.54) !important;
animation: flutter-scrim-exit 200ms cubic-bezier(0.4, 0, 1, 1) forwards !important;
will-change: opacity;
pointer-events: none;
}
/* ── Section Overlay Slide-in Panel (Exact Flutter Najm Matching: 350ms in, 200ms out, easeInOut) ── */

5
src/app/questions-list/[slug]/answer-pace-sheet.tsx

@ -55,12 +55,9 @@ export default function AnswerPaceSheet({
setIsOpen(false);
};
if (!isOpen) {
return null;
}
return (
<InformationSheet
isOpen={isOpen}
icon="play"
title={title}
description={description}

5
src/app/questions-list/sections-request.tsx

@ -77,15 +77,12 @@ export default function SectionsRequest({
controlledOnClose?.();
};
if (!isSheetOpen) {
return null;
}
const termsTitle = t["terms & conditions"] || "Terms & Conditions";
const gotItLabel = t["Got it"] || "Got it";
return (
<InformationSheet
isOpen={isSheetOpen}
icon={null}
showCloseButton={false}
title={({ close }) => (

48
src/components/Componentes/currency-sheet.tsx

@ -24,29 +24,65 @@ export function CurrencySheet({
title,
}: CurrencySheetProps) {
const { dictionary: t, locale } = useI18n();
const [isRendered, setIsRendered] = useState(isOpen);
const [isClosing, setIsClosing] = useState(false);
const isClosingRef = useRef(false);
const [searchQuery, setSearchQuery] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const sheetRef = useRef<HTMLElement>(null);
const isMountedRef = useRef(true);
const prevIsOpenRef = useRef(isOpen);
const timerRef = useRef<number | null>(null);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const isRtl = locale === "fa" || locale === "ar" || locale === "ur";
const defaultTitle = isRtl ? "انتخاب ارز" : "Select Currency";
const sheetTitle = title || defaultTitle;
const closeSheet = useCallback(() => {
if (isClosingRef.current) return;
isClosingRef.current = true;
setIsClosing(true);
window.setTimeout(() => {
onClose();
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
if (isMountedRef.current) {
setIsRendered(false);
setIsClosing(false);
isClosingRef.current = false;
setSearchQuery("");
}
onClose();
}, EXIT_ANIMATION_MS);
}, [onClose]);
useSheetScrollLock(isOpen && !isClosing, { onBack: closeSheet });
// When isOpen changes from outside
useEffect(() => {
if (prevIsOpenRef.current !== isOpen) {
prevIsOpenRef.current = isOpen;
if (isOpen) {
if (timerRef.current) clearTimeout(timerRef.current);
isClosingRef.current = false;
setIsClosing(false);
setIsRendered(true);
} else if (!isClosingRef.current && isRendered) {
closeSheet();
}
}
}, [isOpen, isRendered, closeSheet]);
useSheetScrollLock(isRendered, { onBack: closeSheet });
// Escape key handler
useEffect(() => {
if (!isOpen) return;
if (!isRendered) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeSheet();
@ -54,7 +90,7 @@ export function CurrencySheet({
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, closeSheet]);
}, [isRendered, closeSheet]);
const filteredCurrencies = useMemo(() => {
const q = searchQuery.toLowerCase().trim();
@ -87,7 +123,7 @@ export function CurrencySheet({
? "لم يتم العثور على عملات"
: "No currencies found";
if (!isOpen && !isClosing) return null;
if (!isRendered) return null;
return createPortal(
<div

52
src/components/Componentes/help-modal.tsx

@ -26,23 +26,52 @@ export function HelpModal({
}: HelpModalProps) {
const { dictionary: t } = useI18n();
const [mounted, setMounted] = useState(false);
const [isRendered, setIsRendered] = useState(isOpen);
const [isClosing, setIsClosing] = useState(false);
const isClosingRef = useRef(false);
const isMountedRef = useRef(true);
const prevIsOpenRef = useRef(isOpen);
const timerRef = useRef<number | null>(null);
useEffect(() => {
isMountedRef.current = true;
setMounted(true);
return () => {
isMountedRef.current = false;
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
// When isOpen changes from outside, reset isClosing
const closeSheet = useCallback(() => {
if (isClosingRef.current) return;
isClosingRef.current = true;
setIsClosing(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
if (isMountedRef.current) {
setIsRendered(false);
setIsClosing(false);
isClosingRef.current = false;
}
onClose();
}, EXIT_ANIMATION_MS);
}, [onClose]);
// When isOpen changes from outside
useEffect(() => {
if (prevIsOpenRef.current !== isOpen) {
prevIsOpenRef.current = isOpen;
if (isOpen) {
if (timerRef.current) clearTimeout(timerRef.current);
isClosingRef.current = false;
setIsClosing(false);
setIsRendered(true);
} else if (!isClosingRef.current && isRendered) {
closeSheet();
}
}, [isOpen]);
}
}, [isOpen, isRendered, closeSheet]);
const resolvedTitle = "Tips";
const resolvedDescription =
@ -53,22 +82,11 @@ export function HelpModal({
"Psychologically, this practice fosters a sense of empathy and contentment, which can reduce financial stress. Socially, lending strengthens neighborhood bonds and creates support networks that can lead to economic opportunities. This hadith encourages believers to lend dough, bread, and fire to increase their sustenance.";
const resolvedButtonText = buttonText ?? t["Got it"] ?? "Got it";
const closeSheet = useCallback(() => {
if (isClosing) return;
setIsClosing(true);
window.setTimeout(() => {
if (isMountedRef.current) {
setIsClosing(false);
}
onClose();
}, EXIT_ANIMATION_MS);
}, [isClosing, onClose]);
useHardwareBackHandler(closeSheet, isOpen && !isClosing);
useHardwareBackHandler(closeSheet, isRendered && !isClosing);
// Lock body scroll
useEffect(() => {
if (!isOpen || !mounted) return;
if (!isRendered || !mounted) return;
const previousBodyOverflow = document.body.style.overflow;
const previousHtmlOverflow = document.documentElement.style.overflow;
document.body.style.overflow = "hidden";
@ -77,9 +95,9 @@ export function HelpModal({
document.body.style.overflow = previousBodyOverflow;
document.documentElement.style.overflow = previousHtmlOverflow;
};
}, [isOpen, mounted]);
}, [isRendered, mounted]);
if (!isOpen || !mounted) return null;
if (!isRendered || !mounted) return null;
return createPortal(
<div

54
src/components/Componentes/information-sheet.tsx

@ -3,6 +3,7 @@
import Image, { type StaticImageData } from "next/image";
import type { HTMLAttributes, ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Button from "./button";
import { LoadingSkeleton } from "./loading-skeleton";
import { LoadingThreeDot } from "./loading-three-dot";
@ -41,6 +42,7 @@ export type InformationSheetProps = Omit<
HTMLAttributes<HTMLDivElement>,
"children" | "title"
> & {
isOpen?: boolean;
icon?: InformationSheetIcon | null;
title?: ReactNode | ((controls: { close: () => void }) => ReactNode);
description?: ReactNode;
@ -182,6 +184,7 @@ function resolveIcon(icon: InformationSheetIcon | null | undefined) {
}
export function InformationSheet({
isOpen = true,
icon,
title,
description,
@ -200,36 +203,63 @@ export function InformationSheet({
locale === "ur" ||
locale === "he" ||
locale === "ks";
const [isVisible, setIsVisible] = useState(true);
const [mounted, setMounted] = useState(false);
const [isRendered, setIsRendered] = useState(isOpen);
const [isClosing, setIsClosing] = useState(false);
const isClosingRef = useRef(false);
const isMountedRef = useRef(true);
const prevIsOpenRef = useRef(isOpen);
const timerRef = useRef<number | null>(null);
useEffect(() => {
isMountedRef.current = true;
setMounted(true);
return () => {
isMountedRef.current = false;
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const resolvedIcon = resolveIcon(icon);
const closeSheet = useCallback(() => {
if (isClosing) {
if (isClosingRef.current) {
return;
}
isClosingRef.current = true;
setIsClosing(true);
window.setTimeout(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
if (isMountedRef.current) {
setIsVisible(false);
setIsRendered(false);
setIsClosing(false);
isClosingRef.current = false;
}
onClose?.();
}, EXIT_ANIMATION_MS);
}, [isClosing, onClose]);
}, [onClose]);
// Synchronize external isOpen prop changes safely without bounce
useEffect(() => {
if (prevIsOpenRef.current !== isOpen) {
prevIsOpenRef.current = isOpen;
if (isOpen) {
if (timerRef.current) clearTimeout(timerRef.current);
isClosingRef.current = false;
setIsClosing(false);
setIsRendered(true);
} else if (!isClosingRef.current && isRendered) {
closeSheet();
}
}
}, [isOpen, isRendered, closeSheet]);
const resolvedIcon = resolveIcon(icon);
useHardwareBackHandler(() => {
closeSheet();
return true;
}, isVisible && !isClosing);
}, isRendered && !isClosing);
const controls = { close: closeSheet };
const resolvedTitle = typeof title === "function" ? title(controls) : title;
@ -243,7 +273,7 @@ export function InformationSheet({
);
useEffect(() => {
if (!isVisible) {
if (!isRendered) {
return;
}
@ -257,13 +287,13 @@ export function InformationSheet({
document.body.style.overflow = previousBodyOverflow;
document.documentElement.style.overflow = previousHtmlOverflow;
};
}, [isVisible]);
}, [isRendered]);
if (!isVisible) {
if (!isRendered || !mounted) {
return null;
}
return (
const content = (
<div
className={[
"fixed inset-0 z-50 flex items-end justify-center overflow-y-auto",
@ -396,6 +426,8 @@ export function InformationSheet({
</section>
</div>
);
return createPortal(content, document.body);
}
export default InformationSheet;

2
src/components/Componentes/question-birthplace.tsx

@ -249,7 +249,7 @@ export function QuestionBirthplace({
setIsClosing(false);
}, [disabled]);
useSheetScrollLock(isOpen && !isClosing, { onBack: closeSheet });
useSheetScrollLock(isOpen, { onBack: closeSheet });
// Handle escape key
useEffect(() => {

2
src/components/Componentes/question-date-sheet.tsx

@ -181,7 +181,7 @@ export function QuestionDateSheet({
window.setTimeout(onClose, EXIT_ANIMATION_MS);
}, [onClose]);
useSheetScrollLock(!isClosing, { onBack: closeSheet });
useSheetScrollLock(true, { onBack: closeSheet });
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {

2
src/components/Componentes/question-phone.tsx

@ -586,7 +586,7 @@ export function QuestionPhone({
);
}, [countryList, searchQuery]);
useSheetScrollLock(isOpen && !isClosing, { onBack: closeSheet });
useSheetScrollLock(isOpen, { onBack: closeSheet });
useEffect(() => {
if (!isOpen) return;

2
src/components/Componentes/question-sheet.tsx

@ -81,7 +81,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
setIsClosing(false);
}, [disabled, selectedList]);
useSheetScrollLock(isOpen && !isClosing, { onBack: closeSheet });
useSheetScrollLock(isOpen, { onBack: closeSheet });
// Handle escape key
useEffect(() => {

46
src/components/Componentes/support-sheet.tsx

@ -17,40 +17,58 @@ export type SupportSheetProps = {
export function SupportSheet({ isOpen, onClose }: SupportSheetProps) {
const { dictionary: t } = useI18n();
const [mounted, setMounted] = useState(false);
const [isRendered, setIsRendered] = useState(isOpen);
const [isClosing, setIsClosing] = useState(false);
const isClosingRef = useRef(false);
const isMountedRef = useRef(true);
const prevIsOpenRef = useRef(isOpen);
const timerRef = useRef<number | null>(null);
useEffect(() => {
isMountedRef.current = true;
setMounted(true);
return () => {
isMountedRef.current = false;
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
// When isOpen changes from outside, reset isClosing
useEffect(() => {
if (isOpen) {
setIsClosing(false);
}
}, [isOpen]);
const closeSheet = useCallback(() => {
if (isClosing) return;
if (isClosingRef.current) return;
isClosingRef.current = true;
setIsClosing(true);
window.setTimeout(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
if (isMountedRef.current) {
setIsRendered(false);
setIsClosing(false);
isClosingRef.current = false;
}
onClose();
}, EXIT_ANIMATION_MS);
}, [isClosing, onClose]);
}, [onClose]);
// When isOpen changes from outside
useEffect(() => {
if (prevIsOpenRef.current !== isOpen) {
prevIsOpenRef.current = isOpen;
if (isOpen) {
if (timerRef.current) clearTimeout(timerRef.current);
isClosingRef.current = false;
setIsClosing(false);
setIsRendered(true);
} else if (!isClosingRef.current && isRendered) {
closeSheet();
}
}
}, [isOpen, isRendered, closeSheet]);
useHardwareBackHandler(closeSheet, isOpen && !isClosing);
useHardwareBackHandler(closeSheet, isRendered && !isClosing);
// Lock body scroll
useEffect(() => {
if (!isOpen || !mounted) return;
if (!isRendered || !mounted) return;
const previousBodyOverflow = document.body.style.overflow;
const previousHtmlOverflow = document.documentElement.style.overflow;
document.body.style.overflow = "hidden";
@ -59,7 +77,7 @@ export function SupportSheet({ isOpen, onClose }: SupportSheetProps) {
document.body.style.overflow = previousBodyOverflow;
document.documentElement.style.overflow = previousHtmlOverflow;
};
}, [isOpen, mounted]);
}, [isRendered, mounted]);
const handleSupportContact = () => {
if (typeof window !== "undefined" && "HabibApp" in window) {
@ -84,7 +102,7 @@ export function SupportSheet({ isOpen, onClose }: SupportSheetProps) {
closeSheet();
};
if (!isOpen || !mounted) return null;
if (!isRendered || !mounted) return null;
return createPortal(
<div

5
src/components/Componentes/test-completed-sheet.tsx

@ -19,10 +19,6 @@ export function TestCompletedSheet({
}: TestCompletedSheetProps) {
const { dictionary: t, locale } = useI18n();
if (!isOpen) {
return null;
}
const isFa = locale === "fa";
const sheetTitle =
@ -39,6 +35,7 @@ export function TestCompletedSheet({
return (
<InformationSheet
isOpen={isOpen}
icon="check"
title={sheetTitle}
description={

5
src/components/Componentes/test-exit-sheet.tsx

@ -19,10 +19,6 @@ export function TestExitSheet({
}: TestExitSheetProps) {
const { dictionary: t, locale } = useI18n();
if (!isOpen) {
return null;
}
const isFa = locale === "fa";
const tr = t as Record<string, string>;
@ -50,6 +46,7 @@ export function TestExitSheet({
return (
<InformationSheet
isOpen={isOpen}
icon="warning"
title={title}
description={

29
src/components/Componentes/test-questions-flow.tsx

@ -131,7 +131,9 @@ export default function TestQuestionsFlow({
if (typeof window !== "undefined") {
try {
window.localStorage.removeItem("marriage:tests:personality_test:draft");
window.localStorage.removeItem("marriage:tests:glasser_5_needs_test:draft");
window.localStorage.removeItem(
"marriage:tests:glasser_5_needs_test:draft",
);
} catch {}
}
setAnswers({});
@ -190,8 +192,12 @@ export default function TestQuestionsFlow({
}
if (typeof window !== "undefined") {
try {
window.localStorage.removeItem("marriage:tests:personality_test:draft");
window.localStorage.removeItem("marriage:tests:glasser_5_needs_test:draft");
window.localStorage.removeItem(
"marriage:tests:personality_test:draft",
);
window.localStorage.removeItem(
"marriage:tests:glasser_5_needs_test:draft",
);
} catch {}
}
if (onClose) {
@ -287,22 +293,24 @@ export default function TestQuestionsFlow({
key={q.id}
aria-hidden={offset !== 0}
className={[
"absolute inset-0 flex flex-col overflow-y-auto pt-4 pb-2 transition-transform duration-250 ease-[cubic-bezier(0.25,1,0.5,1)]",
offset === 0 ? "pointer-events-auto" : "pointer-events-none",
"absolute inset-0 flex flex-col overflow-y-auto pt-2 pb-4 transition-transform duration-250 ease-[cubic-bezier(0.25,1,0.5,1)]",
offset === 0
? "pointer-events-auto"
: "pointer-events-none",
].join(" ")}
style={{
transform: `translate3d(${offset * 100}%, 0, 0)`,
}}
>
{/* Question Title */}
<h2
className="text-[17px] sm:text-[18px] font-bold text-[#1F2024] leading-[1.6] text-center px-3 mb-2 flex items-center justify-center shrink-0 min-h-[90px] sm:min-h-[100px]"
>
<span className="w-full">{q.text}</span>
<div className="w-full min-h-[100px] sm:min-h-[120px] flex items-center justify-center my-3 sm:my-4 shrink-0 px-2">
<h2 className="text-[17px] sm:text-[18px] font-bold text-[#1F2024] leading-[1.6] text-center w-full">
{q.text}
</h2>
</div>
{/* Answer Options Stack */}
<div className="flex-1 flex flex-col justify-center gap-3.5 my-auto pb-3">
<div className="w-full flex flex-col gap-3 pb-3">
{options.map((option) => {
const isSelected = qSelectedValue === option.value;
@ -422,4 +430,3 @@ export default function TestQuestionsFlow({
</>
);
}
Loading…
Cancel
Save