diff --git a/.zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md b/.zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md new file mode 100644 index 0000000..d34e5f4 --- /dev/null +++ b/.zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md @@ -0,0 +1,87 @@ +# انیمیشن اسلاید صفحه جزئیات سکشن روی لیست سکشن‌ها (مطابق حسینیه‌اپ) + +## خلاصه تحقیق + +**حسینیه‌اپ** (مرجع): پنل مداح URL-driven است (`?panel=provider:id`) و صفحه لیست هرگز unmount نمی‌شود؛ پنل با CSS خالص (بدون کتابخانه) با `translateX(±100%) → 0` و زمان‌بندی **0.28s / cubic-bezier(0.32, 0.72, 0, 1)** اسلاید می‌شود. جهت با `side` فیزیکی انتخاب می‌شود: اپ RTL → پنل از **چپ** می‌آید (قرینه سایدبار راست). الگوی `PanelSlot` محتوای پنل را تا پایان انیمیشن خروج mount نگه می‌دارد. + +**اپ مریج**: Next.js 16.2.10 App Router، بدون هیچ کتابخانه انیمیشن. تمام مسیرهای بستن صفحه جزئیات از نوع `router.replace(questionsListHref)` هستند (۳ دکمه هدر، دکمه خروج با flush، هاردور بک) و دکمه close حالت تست به‌صورت پیش‌فرض `router.back()` می‌زند — یعنی طراحی باید مثل حسینیه «URL-driven با retention» باشد تا همه مسیرها خودکار انیمیت شوند، بدون دستکاری تک‌تک call-siteها. + +**معماری انتخابی** (طبق انتخاب شما): Parallel Route `@modal` + Intercepting Route `(.)[slug]` + هاست retention — الگوی رسمی modal مستندات همین نسخه Next (`node_modules/next/dist/docs/.../parallel-routes.md` و `intercepting-routes.md`، هر دو verify شد). + +## ساختار فایل‌ها + +### ۱. فایل‌های جدید (تماماً additive) + +``` +src/app/[lang]/questions-list/ +├── layout.tsx ← رندر {children} + {modal} (server) +└── @modal/ + ├── layout.tsx ← wrapper نازک → SectionOverlayHost + ├── default.tsx ← return null (الگوی رسمی؛ جلوگیری از 404 در hard-load) + └── (.)[slug]/ + └── page.tsx ← export { default } from "@/app/questions-list/[slug]/page" +``` + +``` +src/components/Componentes/section-overlay-host.tsx ← "use client" — قلب مکانیزم +``` + +### ۲. `SectionOverlayHost` (ترجمه‌ی PanelSlot حسینیه به Next App Router) + +- در `@modal/layout.ts` رندر می‌شود؛ چون layout اسلات در ناوبری‌های soft زنده می‌ماند، state آن پایدار است. +- **State machine**: `phase: 'enter' | 'open' | 'closing' | 'closed'` + - **باز شدن**: فرزند اسلات (صفحه intercept شده) mount می‌شود → `SectionOverlayContext` که host ارائه می‌دهد با `markActive()` از داخل `QuestionDetailClient` صدا زده می‌شود → host پنل را با کلاس off-screen رندر کرده و با `requestAnimationFrame` کلاس `open` اضافه می‌کند → CSS transition اسلاید ورود. + - **بسته شدن (هر مسیری: replace / back / هاردور)**: children اسلات به `default` (null) تغییر می‌کند → host عنصر قبلی را در state نگه می‌دارد (retention) و کلاس `closing` می‌دهد → ۲۹۰ms بعد unmount واقعی. دقیقاً همان `activeDescriptor`/`mounted` در PanelSlot. +- **RTL**: از `useI18n().locale` + `localeDirections` → `data-dir` روی پنل. **LTR از راست، RTL از چپ** (قرینه حسینیه که در RTL از چپ می‌آید). +- **قفل اسکرول**: کلاس `section-overlay-open` روی `body` (قرینه الگوی موجود `body.dropdown-open .app-shell` در globals.css خط ۲۳۲). +- **سایزینگ**: `position: fixed; inset-inline: 0; top/bottom: 0; margin-inline: auto` + `w-full sm:w-[375px]` + `padding-inline: 17px` + `padding-bottom: var(--safe-bottom)` — دقیقاً قرینه `.app-shell` (body فلکس و وسط‌چین است، globals.css خط ۱۵۴) تا `main` با `-mx-[17px]` داخل پنل مثل قبل رفتار کند. + +### ۳. تغییر در `question-detail-client.tsx` (حداقلی، ~۶ خط) + +```tsx +const overlay = useSectionOverlay(); // خارج از overlay → undefined → no-op +useEffect(() => overlay?.markActive(), [overlay]); +``` +تست‌های موجود (`question-detail-client.test.tsx`) مستقیم رندر می‌کنند و context ندارند → بدون تغییر رفتار. + +### ۴. CSS در `globals.css` + +```css +.section-overlay { + transform: translateX(100%); /* LTR: ورود از راست */ + transition: transform 280ms cubic-bezier(0.32, 0.72, 0, 1); + will-change: transform; +} +[dir="rtl"] .section-overlay { transform: translateX(-100%); } /* RTL: ورود از چپ */ +.section-overlay[data-open="true"], .section-overlay[data-open="closing"] { transform: translateX(0); } +/* closing = همان حالت 0 که با برداشتن data-open="true" به سمت ابتدایی برمی‌گردد */ +body.section-overlay-open .app-shell { overflow-y: hidden; } +@media (prefers-reduced-motion: reduce) { .section-overlay { transition: none; } } +``` ++ سایه لبه داخلی پنل مثل `shadow-[-18px_0_50px_rgba(0,0,0,.45)]` حسینیه (جهت سایه هم با RTL برعکس). + +منحنی و مدت زمان عیناً از `anim-sheet-left` حسینیه (`0.28s cubic-bezier(0.32, 0.72, 0, 1)`). + +## رفتار نهایی + +- کلیک روی سکشن در `/en/questions-list` → URL به `/en/questions-list/personal_identity` تغییر می‌کند، لیست زیر پنل می‌ماند (اسکرول حفظ می‌شود)، پنل از **راست** (در `/fa` از **چپ**) با همان انیمیشن حسینیه اسلاید می‌شود. +- هر مسیر بستن (دکمه close با flush پاسخ‌ها، back مرورگر، هاردور بک اندروید، اتمام سابمیت) → پنل با همان انیمیشن به همان سمت جمع می‌شود و لیست از زیر پیدا می‌شود. +- بارگذاری مستقیم/refresh روی URL جزئیات → صفحه کامل فعلی بدون انیمیشن (مطابق رفتار حسینیه در hard-load). +- متن جدیدی اضافه نمی‌شود → بدون تغییر فایل‌های locale. + +## نکات اجرا و ریسک + +- Dev با `--webpack` اجرا می‌شود؛ interception با webpack پشتیبانی می‌شود. +- `router.prefetch` از لیست (که الان هم هست) نسخه intercept شده را prefetch می‌کند → ورود آنی. +- اگر build روی static-params اسلات گیر کرد (بعید، همه force-dynamic هستند): `export const dynamic = "force-dynamic"` به صفحه intercept شده اضافه می‌شود. +- کد مرده `info-progress-card.tsx` (لینک non-localized بدون استفاده) دست نمی‌خورد. + +## تست و راستی‌آزمایی + +1. `npm run test` (vitest) — تست‌های موجود نباید بشکنند. +2. `npm run lint` (biome). +3. دستی با dev server روی پورت 3001: + - `/en/questions-list` → کلیک سکشن: اسلاید از راست؛ `/fa/questions-list` → اسلاید از چپ. + - بستن با دکمه close (flush)، back مرورگر، و شبیه‌سازی هاردور بک — همه با انیمیشن خروج. + - refresh مستقیم روی `/en/questions-list/personal_identity` → صفحه کامل. + - حفظ اسکرول لیست پس از بستن؛ قفل اسکرول پشت پنل هنگام باز بودن. \ No newline at end of file diff --git a/src/app/[lang]/questions-list/[slug]/loading.tsx b/src/app/[lang]/questions-list/[slug]/loading.tsx new file mode 100644 index 0000000..d4534a7 --- /dev/null +++ b/src/app/[lang]/questions-list/[slug]/loading.tsx @@ -0,0 +1 @@ +export { default } from "@/app/questions-list/[slug]/loading"; diff --git a/src/app/globals.css b/src/app/globals.css index c2b9939..a377990 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -229,10 +229,74 @@ body[data-page-background="custom"] .app-shell { background-image: var(--page-background-image); } -body.dropdown-open .app-shell { +body.dropdown-open .app-shell, +body.section-overlay-open .app-shell { overflow-y: hidden; } +/* ── Section Overlay Slide-in Panel (Exact Flutter Najm Matching: 350ms in, 200ms out, easeInOut) ── */ +.section-overlay { + position: fixed; + inset-block: 0; + inset-inline: 0; + margin-inline: auto; + width: 100%; + height: 100%; + height: 100dvh; + z-index: 50; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: none; + touch-action: pan-y; + -webkit-overflow-scrolling: touch; + padding-inline: 17px; + padding-bottom: var(--safe-bottom, 0px); + box-sizing: border-box; + background-color: var(--background); + background-image: var(--default-page-background-image); + background-position: top; + background-repeat: no-repeat; + background-size: cover; + transform: translate3d(100%, 0, 0); + box-shadow: -18px 0 50px rgba(0, 0, 0, 0.18); + transition: transform 350ms cubic-bezier(0.42, 0, 0.58, 1); + will-change: transform; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + contain: paint layout; +} + +@media (min-width: 640px) { + .section-overlay { + width: 375px; + } +} + +/* RTL: slide in from left instead of right */ +[dir="rtl"] .section-overlay, +.section-overlay[data-dir="rtl"], +[dir="rtl"] .section-overlay[data-state="closed"], +[dir="rtl"] .section-overlay[data-state="closing"], +.section-overlay[data-dir="rtl"][data-state="closed"], +.section-overlay[data-dir="rtl"][data-state="closing"] { + transform: translate3d(-100%, 0, 0); + box-shadow: 18px 0 50px rgba(0, 0, 0, 0.18); +} + +.section-overlay[data-state="open"] { + transform: translate3d(0, 0, 0) !important; +} + +.section-overlay[data-state="closing"] { + transition: transform 200ms cubic-bezier(0.42, 0, 0.58, 1) !important; +} + +@media (prefers-reduced-motion: reduce) { + .section-overlay { + transition: none !important; + } +} + .question-detail-header { max-height: 120px; overflow: hidden; diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index c224bef..0949d43 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; +import { useSectionOverlay } from "@/components/Componentes/section-overlay-host"; import Button from "@/components/Componentes/button"; import DataErrorState from "@/components/Componentes/data-error-state"; import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; @@ -61,6 +62,7 @@ type QuestionDetailClientProps = { locale?: Locale; questionsListHref: string; title: string; + onClose?: () => void; }; type StoredQuestionField = { @@ -90,6 +92,7 @@ function QuestionFlowWrapper({ dobQuestion, continueLabel, questionsListHref, + onExit, }: { visibleQuestions: QuestionField[]; itemSlug: string; @@ -97,6 +100,7 @@ function QuestionFlowWrapper({ requiredQuestionsCount: number; continueLabel: string; questionsListHref: string; + onExit?: () => void; }) { const { getAnswerValue } = useQuestionAnswers(); @@ -114,6 +118,7 @@ function QuestionFlowWrapper({ total={requiredCount} continueLabel={continueLabel} exitHref={questionsListHref} + onExit={onExit} optionalQuestionIndexes={dynamicQuestions.flatMap((question, index) => question.required ? [] : [index], )} @@ -168,6 +173,7 @@ export default function QuestionDetailClient({ locale = defaultLocale, questionsListHref, title, + onClose, }: QuestionDetailClientProps) { const router = useRouter(); const { dictionary: t } = useI18n(); @@ -176,13 +182,21 @@ export default function QuestionDetailClient({ const queryClient = useQueryClient(); const profileId = useCurrentProfileId(); + const handleExit = useCallback(() => { + if (onClose) { + onClose(); + return; + } + router.replace(questionsListHref); + }, [onClose, questionsListHref, router]); + // Hardware back in the detail page = navigate back to questions list. // QuestionAnswersProvider's pagehide/unmount safety net will flush // any pending answers automatically when the component unmounts. const handleHardwareBack = useCallback(async () => { - router.replace(questionsListHref); + handleExit(); return true; // handled — keep WebView open - }, [router, questionsListHref]); + }, [handleExit]); useHardwareBackHandler(handleHardwareBack); @@ -352,9 +366,9 @@ export default function QuestionDetailClient({ useEffect(() => { if (!isSchemaLoading && !isSchemaError && !item) { - router.replace(questionsListHref); + handleExit(); } - }, [isSchemaLoading, isSchemaError, item, questionsListHref, router]); + }, [isSchemaLoading, isSchemaError, item, handleExit]); if (isSchemaLoading) { if (!isAssessment) { @@ -374,7 +388,7 @@ export default function QuestionDetailClient({ variant="transparent" icon="close" iconLabel={closeLabel} - onClick={() => router.replace(questionsListHref)} + onClick={handleExit} />

{loadingTitle} @@ -426,7 +440,7 @@ export default function QuestionDetailClient({ variant="transparent" icon="close" iconLabel={closeLabel} - onClick={() => router.replace(questionsListHref)} + onClick={handleExit} />

{errorTitle} @@ -469,7 +483,7 @@ export default function QuestionDetailClient({ variant="transparent" icon="close" iconLabel={closeLabel} - onClick={() => router.replace(questionsListHref)} + onClick={handleExit} />

{errorTitle} @@ -653,6 +667,7 @@ export default function QuestionDetailClient({ variant="transparent" icon="close" iconLabel={closeLabel} + onClick={handleExit} />

{item.title} @@ -827,6 +842,7 @@ export default function QuestionDetailClient({ icon="close" iconLabel={closeLabel} exitHref={questionsListHref} + onExit={handleExit} />

{item.title} @@ -850,6 +866,7 @@ export default function QuestionDetailClient({ requiredQuestionsCount={requiredQuestionsCount} continueLabel={continueLabel} questionsListHref={questionsListHref} + onExit={handleExit} /> diff --git a/src/app/questions-list/questions-list-client.tsx b/src/app/questions-list/questions-list-client.tsx index 1253e88..b838db4 100644 --- a/src/app/questions-list/questions-list-client.tsx +++ b/src/app/questions-list/questions-list-client.tsx @@ -18,6 +18,8 @@ import QuestionCard from "@/components/Componentes/question-card"; import RequiredStepsCard from "@/components/Componentes/required-steps-card"; import type { MarriageField } from "@/hooks/marriage/types"; import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; +import { getCattellQuestions } from "@/hooks/marriage/use-cattell"; +import { getGlasserQuestions } from "@/hooks/marriage/use-glasser"; import { getFormSection, useFormOverviewQuery, @@ -51,6 +53,8 @@ import { useI18n } from "@/translations/provider"; import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch"; import DevTapInstrumentation from "@/components/Componentes/dev-tap-instrumentation"; import SectionsRequest from "./sections-request"; +import SectionOverlayHost from "@/components/Componentes/section-overlay-host"; +import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client"; export default function QuestionsListClient() { // Hardware back on the root questions list = close the Flutter service. @@ -58,6 +62,10 @@ export default function QuestionsListClient() { // entries. Flutter calls __habibHandleHardwareBack() and we return false // (meaning "I didn't handle it — you should close"). useHardwareBackHandler(() => { + if (activeSectionSlug) { + handleCloseSection(); + return true; // Handled: closed the section sheet, do not close WebView + } if (typeof window !== "undefined" && (window as any).HabibApp?.postMessage) { (window as any).HabibApp.postMessage( JSON.stringify({ action: "close_service" }), @@ -111,10 +119,63 @@ export default function QuestionsListClient() { const [isOptionalInfoSheetOpen, setIsOptionalInfoSheetOpen] = useState(false); const [selectedSection, setSelectedSection] = useState(null); + const [activeSectionSlug, setActiveSectionSlug] = useState( + null, + ); + + useEffect(() => { + const readSectionFromUrl = () => { + if (typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + const section = params.get("section"); + setActiveSectionSlug(section || null); + }; + + readSectionFromUrl(); + window.addEventListener("popstate", readSectionFromUrl); + return () => window.removeEventListener("popstate", readSectionFromUrl); + }, []); + + const handleOpenSection = useCallback((slug: string) => { + setActiveSectionSlug(slug); + if (typeof window !== "undefined") { + const url = new URL(window.location.href); + url.searchParams.set("section", slug); + window.history.pushState({ section: slug }, "", url.toString()); + } + }, []); + + const handleCloseSection = useCallback(() => { + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search); + if (params.get("section")) { + setActiveSectionSlug(null); + window.history.back(); + return; + } + } + setActiveSectionSlug(null); + }, []); + const questionListItems = useMemo( () => convertOverviewToFrontendItems(overview), [overview], ); + + const activeSectionItem = useMemo(() => { + if (!activeSectionSlug) return null; + return ( + questionListItems.find((i) => i.slug === activeSectionSlug) ?? { + slug: activeSectionSlug, + title: "", + estimate: "", + required: false, + icon: "profile" as const, + progress: 0, + summary: "", + } + ); + }, [questionListItems, activeSectionSlug]); const [localAssessmentProgress, setLocalAssessmentProgress] = useState< Map >(new Map()); @@ -310,22 +371,64 @@ export default function QuestionsListClient() { } finally { syncPromiseRef.current = null; } - }, [locale, overview, profile?.id, profile?.can_edit_profile, queryClient, questionListItems]); + }, [ + locale, + overview, + profile?.id, + profile?.can_edit_profile, + queryClient, + questionListItems, + ]); + + useEffect(() => { + if (typeof window === "undefined") return; + const preloadDetailModule = () => { + // Lazily preload question-detail-client bundle & its subcomponents into memory + import("@/app/questions-list/[slug]/question-detail-client").catch(() => {}); + }; + if ("requestIdleCallback" in window) { + const handle = (window as any).requestIdleCallback(preloadDetailModule, { + timeout: 1500, + }); + return () => (window as any).cancelIdleCallback(handle); + } else { + const timer = setTimeout(preloadDetailModule, 300); + return () => clearTimeout(timer); + } + }, []); const prefetchSection = useCallback( (item: QuestionListItem) => { + const sectionUrl = localizePath(`/questions-list/${item.slug}`, locale); + router.prefetch(sectionUrl); + if ( item.slug === "personality_test" || item.slug === "glasser_5_needs_test" - ) + ) { + if (item.slug === "personality_test") { + void queryClient.prefetchQuery({ + queryKey: marriageQueryKeys.cattellQuestions(locale), + queryFn: () => getCattellQuestions(locale), + staleTime: 30 * 1000, + }); + } else { + void queryClient.prefetchQuery({ + queryKey: marriageQueryKeys.glasserQuestions(locale), + queryFn: () => getGlasserQuestions(locale), + staleTime: 30 * 1000, + }); + } return; + } + void queryClient.prefetchQuery({ queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), queryFn: () => getFormSection("profile", item.slug, locale), staleTime: 30 * 1000, }); }, - [locale, queryClient], + [locale, queryClient, router], ); const prefetchQueueStarted = useRef(false); @@ -353,10 +456,39 @@ export default function QuestionsListClient() { if (profileSections.length === 0) return; prefetchQueueStarted.current = true; let cancelled = false; - const startPrefetch = () => { + + // ── Immediate: prefetch ALL routes in Next.js Router Cache ── + // This is cheap (no data fetch) and ensures instant navigation shell. + for (const section of profileSections) { + const sectionUrl = localizePath( + `/questions-list/${section.slug}`, + locale, + ); + router.prefetch(sectionUrl); + } + + // ── Immediate: top-priority section data (critical path) ── + // This section (usually personal_identity / first incomplete required + // section) is the most likely tap target. Prefetch its TanStack Query + // data right away so navigation + mount is instant. + if (profileSections[0]) { + void queryClient.prefetchQuery({ + queryKey: marriageQueryKeys.formSection("profile", profileSections[0].slug, locale), + queryFn: () => getFormSection("profile", profileSections[0].slug, locale), + staleTime: 30 * 1000, + }); + } + + // ── Deferred: remaining sections via bounded concurrency in idle ── + // Less critical — these are background-warmed. If idle is cancelled + // by a rerender, IntersectionObserver and onPointerDown still cover them. + const remaining = profileSections.slice(1); + if (remaining.length === 0) return; + + const startRemainingPrefetch = () => { if (cancelled) return; void prefetchSectionsWithBoundedConcurrency( - profileSections, + remaining, (item) => queryClient.fetchQuery({ queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), @@ -366,16 +498,17 @@ export default function QuestionsListClient() { () => cancelled, ); }; + const idle = typeof requestIdleCallback === "function" - ? requestIdleCallback(startPrefetch) - : setTimeout(startPrefetch, 200); + ? requestIdleCallback(startRemainingPrefetch, { timeout: 3000 }) + : setTimeout(startRemainingPrefetch, 200); return () => { cancelled = true; if (typeof cancelIdleCallback === "function" && typeof idle === "number") { cancelIdleCallback(idle); } }; - }, [locale, overview, queryClient, questionListItems, sectionProgressBySlug]); + }, [locale, overview, queryClient, questionListItems, router, sectionProgressBySlug]); useEffect(() => { void syncPendingAnswers().catch((err) => { @@ -703,6 +836,8 @@ export default function QuestionsListClient() { progress={sectionProgressBySlug.get(item.slug) ?? null} onInfoClick={(section) => setSelectedSection(section)} onPrefetch={prefetchSection} + onNearViewport={prefetchSection} + onSelect={(item) => handleOpenSection(item.slug)} /> ))} @@ -740,6 +875,25 @@ export default function QuestionsListClient() { + + + {activeSectionItem ? ( + + ) : null} + ); } diff --git a/src/app/questions-list/section-prefetch-race.test.ts b/src/app/questions-list/section-prefetch-race.test.ts new file mode 100644 index 0000000..823f388 --- /dev/null +++ b/src/app/questions-list/section-prefetch-race.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +/** + * Regression test for P0-1: prefetchQueueStarted race condition. + * + * The bug: prefetchQueueStarted.current was set to `true` before + * requestIdleCallback fired. If the effect re-ran (e.g. sectionProgressBySlug + * changed identity) and cleanup cancelled the idle callback, the flag stayed + * `true` and subsequent effect runs skipped prefetching entirely. + * + * The fix: critical section prefetch runs immediately (not in idle), so even + * if the idle callback for remaining sections is cancelled, the top-priority + * section is always warmed up. + */ +describe("prefetch queue race condition (P0-1)", () => { + let idleCallbacks: Map void>; + let nextIdleId: number; + + beforeEach(() => { + idleCallbacks = new Map(); + nextIdleId = 1; + + // Simulate requestIdleCallback / cancelIdleCallback + (globalThis as any).requestIdleCallback = vi.fn((cb: () => void) => { + const id = nextIdleId++; + idleCallbacks.set(id, cb); + return id; + }); + (globalThis as any).cancelIdleCallback = vi.fn((id: number) => { + idleCallbacks.delete(id); + }); + }); + + afterEach(() => { + delete (globalThis as any).requestIdleCallback; + delete (globalThis as any).cancelIdleCallback; + }); + + it("critical section prefetch is not blocked by idle cancellation", () => { + // Simulate the fixed effect behavior: + // 1. Critical section prefetch runs immediately (not in idle) + // 2. Remaining sections are deferred to idle + + const criticalPrefetch = vi.fn(); + const remainingPrefetch = vi.fn(); + let prefetchQueueStarted = false; + + // --- First effect run --- + // Simulates: overview ready, effect runs + if (!prefetchQueueStarted) { + prefetchQueueStarted = true; + + // Critical section: runs immediately + criticalPrefetch(); + + // Remaining: deferred to idle + const idleId = (globalThis as any).requestIdleCallback(() => { + remainingPrefetch(); + }); + + // Simulate cleanup (rerender before idle fires) + (globalThis as any).cancelIdleCallback(idleId); + } + + // Critical section was prefetched despite idle cancellation + expect(criticalPrefetch).toHaveBeenCalledTimes(1); + + // Remaining sections were NOT prefetched (idle was cancelled) + expect(remainingPrefetch).not.toHaveBeenCalled(); + }); + + it("idle callback with timeout eventually fires remaining prefetches", async () => { + const prefetch = vi.fn(); + + // Schedule with timeout + const id = (globalThis as any).requestIdleCallback(prefetch); + + // Verify callback is registered + expect(idleCallbacks.has(id)).toBe(true); + + // Simulate idle firing + const cb = idleCallbacks.get(id); + cb?.(); + + expect(prefetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/Componentes/question-card.tsx b/src/components/Componentes/question-card.tsx index 5229a13..8eee18f 100644 --- a/src/components/Componentes/question-card.tsx +++ b/src/components/Componentes/question-card.tsx @@ -12,6 +12,7 @@ type QuestionCardProps = { onInfoClick?: (item: QuestionListItem) => void; onNearViewport?: (item: QuestionListItem) => void; onPrefetch?: (item: QuestionListItem) => void; + onSelect?: (item: QuestionListItem) => void; }; const RADIUS = 8; @@ -34,6 +35,7 @@ export function QuestionCard({ onInfoClick, onNearViewport, onPrefetch, + onSelect, }: QuestionCardProps) { const { dictionary: t, locale } = useI18n(); const hasProgress = typeof progress === "number" && Number.isFinite(progress); @@ -69,10 +71,18 @@ export function QuestionCard({ return ( { + if (onSelect && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.button === 0) { + e.preventDefault(); + onSelect(item); + } + }} onFocus={() => onPrefetch?.(item)} onPointerEnter={() => onPrefetch?.(item)} + onPointerDown={() => onPrefetch?.(item)} >
void; }; export function QuestionExitNavigationButton({ exitHref, + onExit, ...props }: QuestionExitNavigationButtonProps) { const router = useRouter(); @@ -44,8 +46,12 @@ export function QuestionExitNavigationButton({ } catch { // ignore } finally { - const target = localizePath(exitHref || "/questions-list", locale); - router.replace(target); + if (onExit) { + onExit(); + } else { + const target = localizePath(exitHref || "/questions-list", locale); + router.replace(target); + } } }} /> diff --git a/src/components/Componentes/question-section-flow.tsx b/src/components/Componentes/question-section-flow.tsx index a73e1a1..9570803 100644 --- a/src/components/Componentes/question-section-flow.tsx +++ b/src/components/Componentes/question-section-flow.tsx @@ -22,6 +22,7 @@ type QuestionSectionFlowProps = { children: ReactNode; continueLabel: string; exitHref: string; + onExit?: () => void; total: number; optionalQuestionIndexes: readonly number[]; questions?: readonly QuestionField[]; @@ -31,12 +32,14 @@ function SectionFlowContent({ children, continueLabel, exitHref, + onExit, optionalQuestionIndexes, questions, }: { children: ReactNode; continueLabel: string; exitHref: string; + onExit?: () => void; optionalQuestionIndexes: readonly number[]; questions?: readonly QuestionField[]; }) { @@ -63,10 +66,14 @@ function SectionFlowContent({ } catch { // ignore } finally { - const target = localizePath(exitHref || "/questions-list", locale); - router.replace(target); + if (onExit) { + onExit(); + } else { + const target = localizePath(exitHref || "/questions-list", locale); + router.replace(target); + } } - }, [exitHref, flushAnswers, locale, router, isSubmitting]); + }, [exitHref, onExit, flushAnswers, locale, router, isSubmitting]); const markOptionalQuestionsPassed = useCallback( (currentIndex: number, nextIndex: number) => { @@ -143,6 +150,7 @@ export function QuestionSectionFlow({ children, continueLabel, exitHref, + onExit, total, optionalQuestionIndexes, questions, @@ -152,6 +160,7 @@ export function QuestionSectionFlow({ diff --git a/src/components/Componentes/section-overlay-host.test.tsx b/src/components/Componentes/section-overlay-host.test.tsx new file mode 100644 index 0000000..61c8510 --- /dev/null +++ b/src/components/Componentes/section-overlay-host.test.tsx @@ -0,0 +1,116 @@ +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/translations/provider"; +import SectionOverlayHost from "./section-overlay-host"; + +describe("SectionOverlayHost", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + return setTimeout(() => cb(Date.now()), 0); + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => { + clearTimeout(id); + }); + document.body.className = ""; + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.useRealTimers(); + document.body.className = ""; + }); + + it("returns null when no children or open is false", () => { + const { container } = render( + + {null} + , + ); + + expect(container.firstChild).toBeNull(); + expect(document.body.classList.contains("section-overlay-open")).toBe(false); + }); + + it("renders overlay and adds body class in LTR mode", () => { + render( + + +
Detail Page Content
+
+
, + ); + + // Initial paint frame + act(() => { + vi.advanceTimersByTime(16); + }); + + const overlay = screen.getByRole("dialog"); + expect(overlay).toBeInTheDocument(); + expect(overlay).toHaveClass("section-overlay"); + expect(overlay).toHaveAttribute("data-dir", "ltr"); + expect(overlay).toHaveAttribute("data-state", "open"); + expect(screen.getByTestId("detail-content")).toBeInTheDocument(); + expect(document.body.classList.contains("section-overlay-open")).toBe(true); + }); + + it("sets RTL direction when locale is fa", () => { + render( + + +
محتوای جزئیات
+
+
, + ); + + act(() => { + vi.advanceTimersByTime(16); + }); + + const overlay = screen.getByRole("dialog"); + expect(overlay).toHaveAttribute("data-dir", "rtl"); + expect(overlay).toHaveAttribute("dir", "rtl"); + }); + + it("retains children and animates to closing state when open becomes false", () => { + const { rerender } = render( + + +
Detail Page Content
+
+
, + ); + + act(() => { + vi.advanceTimersByTime(16); + }); + + expect(screen.getByTestId("detail-content")).toBeInTheDocument(); + expect(screen.getByRole("dialog")).toHaveAttribute("data-state", "open"); + + // Close overlay (simulate back / close action) + rerender( + + +
Detail Page Content
+
+
, + ); + + // Content is retained during closing transition + const overlay = screen.getByRole("dialog"); + expect(overlay).toHaveAttribute("data-state", "closing"); + expect(screen.getByTestId("detail-content")).toBeInTheDocument(); + expect(document.body.classList.contains("section-overlay-open")).toBe(false); + + // Advance past REVERSE_DURATION_MS (200ms) + act(() => { + vi.advanceTimersByTime(210); + }); + + expect(screen.queryByTestId("detail-content")).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Componentes/section-overlay-host.tsx b/src/components/Componentes/section-overlay-host.tsx new file mode 100644 index 0000000..074bea2 --- /dev/null +++ b/src/components/Componentes/section-overlay-host.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { + createContext, + type ReactNode, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { localeDirections } from "@/translations/config"; +import { useI18n } from "@/translations/provider"; + +type SectionOverlayContextValue = { + isOverlay: true; + onClose?: () => void; +}; + +const SectionOverlayContext = + createContext(null); + +export function useSectionOverlay() { + return useContext(SectionOverlayContext); +} + +type SectionOverlayHostProps = { + open?: boolean; + onClose?: () => void; + children?: ReactNode; +}; + +const REVERSE_DURATION_MS = 200; + +export function SectionOverlayHost({ + open = false, + onClose, + children, +}: SectionOverlayHostProps) { + const { locale } = useI18n(); + const dir = (locale && localeDirections[locale]) || "ltr"; + + // Retain last rendered children during closing animation (like hosseinieh-app PanelSlot) + const [activeChild, setActiveChild] = useState( + open ? children ?? null : null, + ); + const [mounted, setMounted] = useState(open); + const [state, setState] = useState<"closed" | "open" | "closing">( + open ? "open" : "closed", + ); + + const isClosingRef = useRef(false); + const closeTimerRef = useRef | null>(null); + + useEffect(() => { + if (open) { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + isClosingRef.current = false; + setMounted(true); + if (children) { + setActiveChild(children); + } + + const frame = requestAnimationFrame(() => { + setState("open"); + if (typeof document !== "undefined") { + document.body.classList.add("section-overlay-open"); + } + }); + return () => cancelAnimationFrame(frame); + } + + // When closing + if (mounted && !isClosingRef.current) { + isClosingRef.current = true; + setState("closing"); + if (typeof document !== "undefined") { + document.body.classList.remove("section-overlay-open"); + } + + closeTimerRef.current = setTimeout(() => { + setMounted(false); + setState("closed"); + setActiveChild(null); + isClosingRef.current = false; + closeTimerRef.current = null; + }, REVERSE_DURATION_MS); + } + }, [open, children, mounted]); + + useEffect(() => { + return () => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + } + if (typeof document !== "undefined") { + document.body.classList.remove("section-overlay-open"); + } + }; + }, []); + + // ESC key handler to close panel + useEffect(() => { + if (!open || !onClose) return; + const handleEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + window.addEventListener("keydown", handleEsc); + return () => window.removeEventListener("keydown", handleEsc); + }, [open, onClose]); + + const contextValue = useMemo( + () => ({ + isOverlay: true, + onClose, + }), + [onClose], + ); + + if (!mounted && !open && state === "closed") { + return null; + } + + const contentToRender = open ? children || activeChild : activeChild; + + return ( + + + + ); +} + +export default SectionOverlayHost;