Browse Source
feat: implement SectionOverlayHost for animating question details and expose onExit callbacks for modular navigation handling
master
feat: implement SectionOverlayHost for animating question details and expose onExit callbacks for modular navigation handling
master
11 changed files with 720 additions and 21 deletions
-
87.zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md
-
1src/app/[lang]/questions-list/[slug]/loading.tsx
-
66src/app/globals.css
-
31src/app/questions-list/[slug]/question-detail-client.tsx
-
170src/app/questions-list/questions-list-client.tsx
-
87src/app/questions-list/section-prefetch-race.test.ts
-
10src/components/Componentes/question-card.tsx
-
6src/components/Componentes/question-exit-navigation-button.tsx
-
11src/components/Componentes/question-section-flow.tsx
-
116src/components/Componentes/section-overlay-host.test.tsx
-
148src/components/Componentes/section-overlay-host.tsx
@ -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` → صفحه کامل. |
|||
- حفظ اسکرول لیست پس از بستن؛ قفل اسکرول پشت پنل هنگام باز بودن. |
|||
@ -0,0 +1 @@ |
|||
export { default } from "@/app/questions-list/[slug]/loading"; |
|||
@ -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<number, () => 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); |
|||
}); |
|||
}); |
|||
@ -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( |
|||
<I18nProvider locale="en"> |
|||
<SectionOverlayHost open={false}>{null}</SectionOverlayHost> |
|||
</I18nProvider>, |
|||
); |
|||
|
|||
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( |
|||
<I18nProvider locale="en"> |
|||
<SectionOverlayHost open={true}> |
|||
<div data-testid="detail-content">Detail Page Content</div> |
|||
</SectionOverlayHost> |
|||
</I18nProvider>, |
|||
); |
|||
|
|||
// 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( |
|||
<I18nProvider locale="fa"> |
|||
<SectionOverlayHost open={true}> |
|||
<div data-testid="detail-content">محتوای جزئیات</div> |
|||
</SectionOverlayHost> |
|||
</I18nProvider>, |
|||
); |
|||
|
|||
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( |
|||
<I18nProvider locale="en"> |
|||
<SectionOverlayHost open={true}> |
|||
<div data-testid="detail-content">Detail Page Content</div> |
|||
</SectionOverlayHost> |
|||
</I18nProvider>, |
|||
); |
|||
|
|||
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( |
|||
<I18nProvider locale="en"> |
|||
<SectionOverlayHost open={false}> |
|||
<div data-testid="detail-content">Detail Page Content</div> |
|||
</SectionOverlayHost> |
|||
</I18nProvider>, |
|||
); |
|||
|
|||
// 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(); |
|||
}); |
|||
}); |
|||
@ -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<SectionOverlayContextValue | null>(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<ReactNode | null>( |
|||
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<ReturnType<typeof setTimeout> | 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<SectionOverlayContextValue>( |
|||
() => ({ |
|||
isOverlay: true, |
|||
onClose, |
|||
}), |
|||
[onClose], |
|||
); |
|||
|
|||
if (!mounted && !open && state === "closed") { |
|||
return null; |
|||
} |
|||
|
|||
const contentToRender = open ? children || activeChild : activeChild; |
|||
|
|||
return ( |
|||
<SectionOverlayContext.Provider value={contextValue}> |
|||
<aside |
|||
data-slot="section-overlay" |
|||
data-state={state} |
|||
data-dir={dir} |
|||
dir={dir} |
|||
aria-modal="true" |
|||
role="dialog" |
|||
className="section-overlay" |
|||
> |
|||
{contentToRender} |
|||
</aside> |
|||
</SectionOverlayContext.Provider> |
|||
); |
|||
} |
|||
|
|||
export default SectionOverlayHost; |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue