Browse Source

feat: implement questions list overlay with pre-mounting and hook-based state management

master
mortezaei 2 weeks ago
parent
commit
4a02ebe2ad
  1. 181
      src/app/finding-match/finding-match-client.test.tsx
  2. 53
      src/app/finding-match/finding-match-client.tsx
  3. 38
      src/app/questions-list/questions-list-client.tsx
  4. 4
      src/app/questions-list/sections-request.tsx
  5. 90
      src/components/Componentes/page-header.test.tsx
  6. 175
      src/components/Componentes/questions-list-overlay.test.tsx
  7. 91
      src/components/Componentes/questions-list-overlay.tsx
  8. 18
      src/components/Componentes/section-overlay-host.test.tsx
  9. 31
      src/components/Componentes/section-overlay-host.tsx

181
src/app/finding-match/finding-match-client.test.tsx

@ -0,0 +1,181 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/translations/provider";
import FindingMatchClient from "./finding-match-client";
const mockReplace = vi.fn();
const mockPush = vi.fn();
const mockBack = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: mockPush,
replace: mockReplace,
back: mockBack,
prefetch: vi.fn(),
}),
}));
let mockProfileData: any = {
id: 1,
gender: "male",
status: "search_in_progress",
can_edit_profile: true,
unseen_rejection: null,
};
vi.mock("@/hooks/marriage/use-profile-main", () => ({
useMarriageProfileQuery: () => ({
data: mockProfileData,
isLoading: false,
isFetched: true,
refetch: vi.fn(),
}),
}));
vi.mock("@/hooks/marriage/use-form-schema", () => ({
useFormOverviewQuery: () => ({
data: {
sections: [
{
slug: "personal_identity",
title: "Personal Identity",
required: true,
estimated_time_min: 3,
is_completed: false,
progress: {
current_step: 0,
completion_percent: 0,
is_completed: false,
},
},
],
},
isLoading: false,
isFetched: true,
isError: false,
refetch: vi.fn(),
}),
getFormSection: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-cattell", () => ({
useCattellResultQuery: () => ({ data: null }),
getCattellQuestions: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-glasser", () => ({
useGlasserResultQuery: () => ({ data: null }),
getGlasserQuestions: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-match-start", () => ({
useStartMarriageMatchMutation: () => ({
mutate: vi.fn(),
isPending: false,
isError: false,
reset: vi.fn(),
}),
}));
vi.mock("@/hooks/marriage/use-rejection-seen", () => ({
useRejectionSeenMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
}));
vi.mock("@/hooks/marriage/use-marriage-advisors", () => ({
useMarriageAdvisorsQuery: () => ({
data: { results: [] },
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
}));
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<I18nProvider locale="en">{children}</I18nProvider>
</QueryClientProvider>
);
};
}
describe("FindingMatchClient", () => {
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 = "";
mockProfileData = {
id: 1,
gender: "male",
status: "search_in_progress",
can_edit_profile: true,
unseen_rejection: null,
};
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.useRealTimers();
document.body.className = "";
});
it("renders search in progress and Edit Profile button", () => {
const wrapper = createWrapper();
render(<FindingMatchClient />, { wrapper });
expect(screen.getByText("SEARCH IN PROGRESS")).toBeInTheDocument();
expect(screen.getByText("Edit Profile")).toBeInTheDocument();
});
it("opens questions list overlay when Edit Profile button is clicked", () => {
const wrapper = createWrapper();
const { container } = render(<FindingMatchClient />, { wrapper });
// Initial render - overlay is pre-mounted offscreen
const editBtn = screen.getByRole("button", { name: /edit profile/i });
expect(editBtn).toBeInTheDocument();
fireEvent.click(editBtn);
act(() => {
vi.advanceTimersByTime(16);
});
const overlay = container.querySelector('aside[data-slot="section-overlay"][data-state="open"]');
expect(overlay).toBeInTheDocument();
expect(document.body.classList.contains("section-overlay-open")).toBe(true);
});
it("renders locked state when can_edit_profile is false", () => {
mockProfileData = {
id: 1,
gender: "male",
status: "search_in_progress",
can_edit_profile: false,
};
const wrapper = createWrapper();
render(<FindingMatchClient />, { wrapper });
expect(screen.getByText("Profile is locked")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /edit profile/i })).not.toBeInTheDocument();
});
});

53
src/app/finding-match/finding-match-client.tsx

@ -2,7 +2,7 @@
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { Ic } from "@/icons";
@ -10,6 +10,9 @@ import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import MarriageAdvisorsOverlay, {
useMarriageAdvisorsOverlay,
} from "@/components/Componentes/marriage-advisors-overlay";
import QuestionsListOverlay, {
useQuestionsListOverlay,
} from "@/components/Componentes/questions-list-overlay";
import Button from "@/components/Componentes/button";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
import { PageBackground } from "@/components/Componentes/page-background";
@ -31,6 +34,40 @@ export default function FindingMatchClient() {
const { dictionary: t, locale } = useI18n();
const { isAdvisorOpen, openAdvisors, closeAdvisors } =
useMarriageAdvisorsOverlay();
const { isQuestionsOpen, openQuestions, closeQuestions } =
useQuestionsListOverlay();
const [isQuestionsReady, setIsQuestionsReady] = useState(false);
const [isOpeningEditProfile, setIsOpeningEditProfile] = useState(false);
const handleQuestionsReady = useCallback(() => {
setIsQuestionsReady(true);
}, []);
useEffect(() => {
if (isQuestionsReady && isOpeningEditProfile) {
setIsOpeningEditProfile(false);
openQuestions();
}
}, [isQuestionsReady, isOpeningEditProfile, openQuestions]);
// Safety fallback if background preparation takes unexpectedly long
useEffect(() => {
if (!isOpeningEditProfile) return;
const timer = setTimeout(() => {
setIsOpeningEditProfile(false);
openQuestions();
}, 2000);
return () => clearTimeout(timer);
}, [isOpeningEditProfile, openQuestions]);
const handleEditProfileClick = useCallback(() => {
if (isQuestionsReady) {
openQuestions();
} else {
setIsOpeningEditProfile(true);
}
}, [isQuestionsReady, openQuestions]);
const { data: profile, isLoading, isFetched } = useMarriageProfileQuery({
refetchInterval: 3000,
});
@ -43,6 +80,10 @@ export default function FindingMatchClient() {
closeAdvisors();
return true;
}
if (isQuestionsOpen) {
closeQuestions();
return true;
}
return false; // Hardware back closes the service in Flutter
});
@ -211,7 +252,8 @@ export default function FindingMatchClient() {
) : (
<Button
variant="dark"
href={localizePath("/questions-list", locale)}
onClick={handleEditProfileClick}
isLoading={isOpeningEditProfile}
>
<Ic name="pencil" aria-hidden="true" className="size-[18px] shrink-0" />
<span>{copy.editProfile}</span>
@ -223,6 +265,13 @@ export default function FindingMatchClient() {
open={isAdvisorOpen}
onClose={closeAdvisors}
/>
<QuestionsListOverlay
open={isQuestionsOpen}
onClose={closeQuestions}
onReady={handleQuestionsReady}
premount
/>
</>
);
}

38
src/app/questions-list/questions-list-client.tsx

@ -58,14 +58,24 @@ import SectionOverlayHost from "@/components/Componentes/section-overlay-host";
import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client";
import TestCompletedSheet from "@/components/Componentes/test-completed-sheet";
export default function QuestionsListClient() {
export type QuestionsListClientProps = {
onClose?: () => void;
isOverlay?: boolean;
onReady?: () => void;
};
export default function QuestionsListClient({
onClose,
isOverlay = false,
onReady,
}: QuestionsListClientProps = {}) {
const [isTermsSheetOpen, setIsTermsSheetOpen] = useState(false);
const [completedTestSheet, setCompletedTestSheet] = useState<{
isOpen: boolean;
title?: string;
}>({ isOpen: false, title: undefined });
// Hardware back on the root questions list = close the Flutter service.
// Hardware back on the root questions list = close the Flutter service or overlay.
// Unlike the old useCloseServiceOnBack, this does NOT push fake history
// entries. Flutter calls __habibHandleHardwareBack() and we return false
// (meaning "I didn't handle it — you should close").
@ -86,6 +96,10 @@ export default function QuestionsListClient() {
handleCloseSection();
return true; // Handled: closed the section sheet, do not close WebView
}
if (onClose) {
onClose();
return true;
}
return false; // Tell Flutter to close the WebView screen
});
const { dictionary: t, locale } = useI18n();
@ -113,6 +127,7 @@ export default function QuestionsListClient() {
[profile],
);
const isProfileRedirecting = useMemo(() => {
if (isOverlay) return false;
if (!profile || !profileTargetPath) return false;
if (profileTargetPath === "/questions-list") return false;
// Allow users to view and edit profile sections when profile is waiting or in match pool and can_edit_profile is not locked
@ -123,7 +138,7 @@ export default function QuestionsListClient() {
return false;
}
return true;
}, [profile, profileTargetPath]);
}, [isOverlay, profile, profileTargetPath]);
useEffect(() => {
if (isProfileFetched && isProfileRedirecting && profileTargetPath) {
@ -131,13 +146,20 @@ export default function QuestionsListClient() {
}
}, [isProfileFetched, isProfileRedirecting, locale, profileTargetPath, router]);
// Notify parent overlay when initial overview data / skeleton is rendered
useEffect(() => {
if ((!isSchemaLoading && !isProfileLoading && isProfileFetched) || isSchemaError || isProfileError) {
onReady?.();
}
}, [isSchemaLoading, isProfileLoading, isProfileFetched, isSchemaError, isProfileError, onReady]);
// Background prefetch user's geo country code so phone question is pre-warmed
useEffect(() => {
void fetchGeoCountryCode();
}, []);
// Signal Flutter to lift its loading cover immediately on mount
useHabibWebReady(true);
useHabibWebReady(!isOverlay);
const startMatchMutation = useStartMarriageMatchMutation({
onSuccess: () => {
@ -693,7 +715,9 @@ export default function QuestionsListClient() {
iconLabel={t["Close questions list"]}
onClick={(e) => {
e.preventDefault();
if (typeof window !== "undefined" && (window as any).HabibApp) {
if (onClose) {
onClose();
} else if (typeof window !== "undefined" && (window as any).HabibApp) {
(window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" }),
);
@ -898,7 +922,9 @@ export default function QuestionsListClient() {
iconLabel={t["Close questions list"]}
onClick={(e) => {
e.preventDefault();
if (typeof window !== "undefined" && (window as any).HabibApp) {
if (onClose) {
onClose();
} else if (typeof window !== "undefined" && (window as any).HabibApp) {
(window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" }),
);

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

@ -32,8 +32,8 @@ export default function SectionsRequest({
return sections.every(
(section) =>
section.progress.current_step <= 0 &&
section.progress.completion_percent <= 0,
(section.progress?.current_step ?? 0) <= 0 &&
(section.progress?.completion_percent ?? 0) <= 0,
);
}, [sections]);

90
src/components/Componentes/page-header.test.tsx

@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { PageHeader } from "./page-header";
import { NavigationButton } from "./navigation-button";
import { I18nProvider } from "@/translations/provider";
afterEach(() => {
cleanup();
});
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
}),
}));
vi.mock("@/hooks/marriage/use-profile-main", () => ({
useMarriageProfileQuery: () => ({
data: undefined,
isLoading: false,
isFetched: true,
}),
}));
function renderWithI18n(ui: React.ReactNode) {
return render(
<I18nProvider locale="fa" dictionary={{ "Habib Marriage": "ازدواج حبیب", "Subscription Status": "وضعیت اشتراک", "Subscription": "اشتراک", "More": "بیشتر" }}>
{ui}
</I18nProvider>
);
}
describe("PageHeader & NavigationButton Subscription Gender Restriction", () => {
it("renders subscription button for male profile", () => {
const maleProfile = {
id: 101,
gender: "male",
status: "waiting",
};
const { queryByRole } = renderWithI18n(<PageHeader profile={maleProfile} />);
const subButton = queryByRole("button", {
name: /وضعیت اشتراک|اشتراک|subscription/i,
});
expect(subButton).toBeInTheDocument();
});
it("does NOT render subscription button for female profile", () => {
const femaleProfile = {
id: 243,
gender: "female",
status: "waiting",
};
const { queryByRole } = renderWithI18n(<PageHeader profile={femaleProfile} />);
const subButton = queryByRole("button", {
name: /وضعیت اشتراک|اشتراک|subscription/i,
});
expect(subButton).not.toBeInTheDocument();
});
it("NavigationButton icon='subscription' returns null for female user", () => {
const femaleProfile = {
id: 243,
gender: "female",
};
const { container } = renderWithI18n(
<NavigationButton icon="subscription" profile={femaleProfile} />,
);
expect(container).toBeEmptyDOMElement();
});
it("NavigationButton icon='subscription' renders button for male user", () => {
const maleProfile = {
id: 101,
gender: "male",
};
const { getByRole } = renderWithI18n(
<NavigationButton icon="subscription" profile={maleProfile} />,
);
const subButton = getByRole("button");
expect(subButton).toBeInTheDocument();
});
});

175
src/components/Componentes/questions-list-overlay.test.tsx

@ -0,0 +1,175 @@
import { act, cleanup, render, renderHook, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/translations/provider";
import {
QuestionsListOverlay,
useQuestionsListOverlay,
} from "./questions-list-overlay";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
}),
}));
vi.mock("@/hooks/marriage/use-profile-main", () => ({
useMarriageProfileQuery: () => ({
data: {
id: 1,
gender: "male",
status: "search_in_progress",
can_edit_profile: true,
},
isLoading: false,
isFetched: true,
refetch: vi.fn(),
}),
}));
vi.mock("@/hooks/marriage/use-form-schema", () => ({
useFormOverviewQuery: () => ({
data: {
sections: [
{
slug: "personal_identity",
title: "Personal Identity",
required: true,
estimated_time_min: 3,
is_completed: false,
progress: {
current_step: 0,
completion_percent: 0,
is_completed: false,
},
},
],
},
isLoading: false,
isFetched: true,
isError: false,
refetch: vi.fn(),
}),
getFormSection: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-cattell", () => ({
useCattellResultQuery: () => ({ data: null }),
getCattellQuestions: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-glasser", () => ({
useGlasserResultQuery: () => ({ data: null }),
getGlasserQuestions: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-match-start", () => ({
useStartMarriageMatchMutation: () => ({
mutate: vi.fn(),
isPending: false,
isError: false,
reset: vi.fn(),
}),
}));
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<I18nProvider locale="fa">{children}</I18nProvider>
</QueryClientProvider>
);
};
}
describe("QuestionsListOverlay & useQuestionsListOverlay", () => {
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("hook manages open and close state with history sync", () => {
const { result } = renderHook(() => useQuestionsListOverlay());
expect(result.current.isQuestionsOpen).toBe(false);
act(() => {
result.current.openQuestions();
});
expect(result.current.isQuestionsOpen).toBe(true);
act(() => {
result.current.closeQuestions();
});
expect(result.current.isQuestionsOpen).toBe(false);
});
it("pre-mounts in background when open is false and premount is true", () => {
const handleClose = vi.fn();
const handleReady = vi.fn();
const wrapper = createWrapper();
const { container } = render(
<QuestionsListOverlay
open={false}
onClose={handleClose}
onReady={handleReady}
premount={true}
/>,
{ wrapper },
);
const overlay = container.querySelector('aside[data-slot="section-overlay"]');
expect(overlay).toBeInTheDocument();
expect(overlay).toHaveAttribute("data-state", "closed");
expect(overlay).toHaveAttribute("aria-hidden", "true");
expect(document.body.classList.contains("section-overlay-open")).toBe(false);
expect(handleReady).toHaveBeenCalled();
});
it("slides in smoothly when open is true", () => {
const handleClose = vi.fn();
const wrapper = createWrapper();
render(
<QuestionsListOverlay
open={true}
onClose={handleClose}
/>,
{ wrapper },
);
act(() => {
vi.advanceTimersByTime(16);
});
const overlay = screen.getByRole("dialog");
expect(overlay).toBeInTheDocument();
expect(overlay).toHaveAttribute("data-state", "open");
expect(document.body.classList.contains("section-overlay-open")).toBe(true);
});
});

91
src/components/Componentes/questions-list-overlay.tsx

@ -0,0 +1,91 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import QuestionsListClient from "@/app/questions-list/questions-list-client";
import SectionOverlayHost from "@/components/Componentes/section-overlay-host";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
export type QuestionsListOverlayProps = {
open: boolean;
onClose: () => void;
onReady?: () => void;
premount?: boolean;
};
/**
* Hook to manage Questions List ("Edit Profile") slide-in overlay state.
* Syncs with browser history (?edit_profile=open) and intercepts hardware back in Flutter.
*/
export function useQuestionsListOverlay() {
const [isQuestionsOpen, setIsQuestionsOpen] = useState(false);
useEffect(() => {
const readFromUrl = () => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
setIsQuestionsOpen(
params.get("edit_profile") === "open" ||
params.get("questions") === "open",
);
};
readFromUrl();
window.addEventListener("popstate", readFromUrl);
return () => window.removeEventListener("popstate", readFromUrl);
}, []);
const openQuestions = useCallback(() => {
setIsQuestionsOpen(true);
if (typeof window !== "undefined") {
const url = new URL(window.location.href);
url.searchParams.set("edit_profile", "open");
window.history.replaceState({ edit_profile: "open" }, "", url.toString());
}
}, []);
const closeQuestions = useCallback(() => {
setIsQuestionsOpen(false);
if (typeof window !== "undefined") {
const url = new URL(window.location.href);
if (
url.searchParams.get("edit_profile") === "open" ||
url.searchParams.get("questions") === "open"
) {
url.searchParams.delete("edit_profile");
url.searchParams.delete("questions");
window.history.replaceState({}, "", url.toString());
}
}
}, []);
// Intercept hardware back in Flutter WebView when questions overlay is open
useHardwareBackHandler(() => {
closeQuestions();
return true; // handled: keep WebView screen open
}, isQuestionsOpen);
return {
isQuestionsOpen,
openQuestions,
closeQuestions,
};
}
export function QuestionsListOverlay({
open,
onClose,
onReady,
premount = true,
}: QuestionsListOverlayProps) {
return (
<SectionOverlayHost open={open} onClose={onClose} premount={premount}>
<QuestionsListClient
onClose={onClose}
isOverlay={true}
onReady={onReady}
/>
</SectionOverlayHost>
);
}
export default QuestionsListOverlay;

18
src/components/Componentes/section-overlay-host.test.tsx

@ -113,4 +113,22 @@ describe("SectionOverlayHost", () => {
expect(screen.queryByTestId("detail-content")).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("keeps children mounted when premount is true even if open is false", () => {
const { container } = render(
<I18nProvider locale="en">
<SectionOverlayHost open={false} premount={true}>
<div data-testid="premounted-content">Premounted Content</div>
</SectionOverlayHost>
</I18nProvider>,
);
const dialog = container.querySelector('aside[data-slot="section-overlay"]');
expect(dialog).toBeInTheDocument();
expect(dialog).toHaveAttribute("data-state", "closed");
expect(dialog).toHaveAttribute("aria-hidden", "true");
expect(dialog).toHaveAttribute("inert");
expect(screen.getByTestId("premounted-content")).toBeInTheDocument();
expect(document.body.classList.contains("section-overlay-open")).toBe(false);
});
});

31
src/components/Componentes/section-overlay-host.tsx

@ -28,6 +28,7 @@ type SectionOverlayHostProps = {
open?: boolean;
onClose?: () => void;
children?: ReactNode;
premount?: boolean;
};
const REVERSE_DURATION_MS = 200;
@ -36,23 +37,28 @@ export function SectionOverlayHost({
open = false,
onClose,
children,
premount = false,
}: 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,
open || premount ? children ?? null : null,
);
const [mounted, setMounted] = useState(open);
const [mounted, setMounted] = useState(open || premount);
const [state, setState] = useState<"closed" | "open" | "closing">(
open ? "open" : "closed",
);
const isClosingRef = useRef(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevOpenRef = useRef(open);
useEffect(() => {
const wasOpen = prevOpenRef.current;
prevOpenRef.current = open;
if (open) {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
@ -73,8 +79,8 @@ export function SectionOverlayHost({
return () => cancelAnimationFrame(frame);
}
// When closing
if (mounted && !isClosingRef.current) {
// When closing from open state
if (wasOpen && mounted && !isClosingRef.current) {
isClosingRef.current = true;
setState("closing");
if (typeof document !== "undefined") {
@ -82,14 +88,16 @@ export function SectionOverlayHost({
}
closeTimerRef.current = setTimeout(() => {
if (!premount) {
setMounted(false);
setState("closed");
setActiveChild(null);
}
setState("closed");
isClosingRef.current = false;
closeTimerRef.current = null;
}, REVERSE_DURATION_MS);
}
}, [open, children, mounted]);
}, [open, children, mounted, premount]);
useEffect(() => {
return () => {
@ -122,11 +130,11 @@ export function SectionOverlayHost({
[onClose],
);
if (!mounted && !open && state === "closed") {
if (!mounted && !open && state === "closed" && !premount) {
return null;
}
const contentToRender = open ? children || activeChild : activeChild;
const contentToRender = open ? children || activeChild : activeChild || children;
return (
<SectionOverlayContext.Provider value={contextValue}>
@ -135,9 +143,14 @@ export function SectionOverlayHost({
data-state={state}
data-dir={dir}
dir={dir}
aria-modal="true"
aria-modal={state === "open" ? "true" : undefined}
aria-hidden={state === "closed" ? true : undefined}
inert={state === "closed" ? true : undefined}
role="dialog"
className="section-overlay"
style={{
pointerEvents: state === "open" ? "auto" : "none",
}}
>
{contentToRender}
</aside>

Loading…
Cancel
Save