diff --git a/src/components/layout/RootLayout.tsx b/src/components/layout/RootLayout.tsx index 1aa7275bc..106fe4321 100644 --- a/src/components/layout/RootLayout.tsx +++ b/src/components/layout/RootLayout.tsx @@ -6,6 +6,7 @@ import { SidebarProvider } from "@/components/ui/sidebar"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; import { BackendProvider } from "@/providers/BackendProvider"; import { ComponentSpecProvider } from "@/providers/ComponentSpecProvider"; +import { DialogProvider } from "@/providers/DialogProvider/DialogProvider"; import AppFooter from "./AppFooter"; import AppMenu from "./AppMenu"; @@ -15,25 +16,27 @@ const RootLayout = () => { return ( - - - - - - - - - - - - - - {import.meta.env.VITE_ENABLE_ROUTER_DEVTOOLS === "true" && ( - - )} - - - + + + + + + + + + + + + + + + {import.meta.env.VITE_ENABLE_ROUTER_DEVTOOLS === "true" && ( + + )} + + + + ); }; diff --git a/src/components/ui/animated-height.tsx b/src/components/ui/animated-height.tsx new file mode 100644 index 000000000..2b5fc24e2 --- /dev/null +++ b/src/components/ui/animated-height.tsx @@ -0,0 +1,129 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; + +import { cn } from "@/lib/utils"; + +interface AnimatedHeightProps { + children: React.ReactNode; + className?: string; + /** Duration of the height transition in milliseconds */ + duration?: number; + /** Easing function for the transition */ + easing?: string; + /** Key that changes when content changes - triggers re-measurement */ + contentKey?: string | number; +} + +/** + * A component that smoothly animates its height based on content changes. + * Uses ResizeObserver to detect content size changes and applies CSS transitions. + * During shrink transitions, overflow is visible to prevent content clipping. + */ +export function AnimatedHeight({ + children, + className, + duration = 200, + easing = "ease-out", + contentKey, +}: AnimatedHeightProps) { + const contentRef = useRef(null); + const [height, setHeight] = useState(null); + const [enableTransition, setEnableTransition] = useState(false); + const [isShrinking, setIsShrinking] = useState(false); + const prevHeightRef = useRef(null); + const shrinkTimeoutRef = useRef | null>(null); + const isFirstMeasurementRef = useRef(true); + const isTransitioningRef = useRef(false); + + const measureHeight = useCallback( + (force = false) => { + if (isTransitioningRef.current && !force) return; + + const contentEl = contentRef.current; + if (!contentEl) return; + + const newHeight = contentEl.offsetHeight; + const prevHeight = prevHeightRef.current; + + if (newHeight !== prevHeight && newHeight > 0) { + const shrinking = prevHeight !== null && newHeight < prevHeight; + + if (shrinkTimeoutRef.current) { + clearTimeout(shrinkTimeoutRef.current); + shrinkTimeoutRef.current = null; + } + + if (shrinking) { + setIsShrinking(true); + shrinkTimeoutRef.current = setTimeout(() => { + setIsShrinking(false); + }, duration); + } + + prevHeightRef.current = newHeight; + setHeight(newHeight); + + if (isFirstMeasurementRef.current) { + isFirstMeasurementRef.current = false; + requestAnimationFrame(() => { + setEnableTransition(true); + }); + } + } + }, + [duration], + ); + + // Handle contentKey changes synchronously before paint + useLayoutEffect(() => { + isTransitioningRef.current = true; + + // Measure synchronously - useLayoutEffect runs after DOM update but before paint + measureHeight(true); + + isTransitioningRef.current = false; + }, [contentKey, measureHeight]); + + // ResizeObserver for dynamic content changes (not during key transitions) + useEffect(() => { + const contentEl = contentRef.current; + if (!contentEl) return; + + const resizeObserver = new ResizeObserver(() => { + if (!isTransitioningRef.current) { + measureHeight(); + } + }); + + resizeObserver.observe(contentEl); + + return () => { + resizeObserver.disconnect(); + if (shrinkTimeoutRef.current) { + clearTimeout(shrinkTimeoutRef.current); + } + }; + }, [measureHeight]); + + return ( + + {children} + + ); +} diff --git a/src/hooks/useDialog.test.tsx b/src/hooks/useDialog.test.tsx new file mode 100644 index 000000000..458e94fea --- /dev/null +++ b/src/hooks/useDialog.test.tsx @@ -0,0 +1,336 @@ +import { + fireEvent, + render, + renderHook, + screen, + waitFor, +} from "@testing-library/react"; +import { type ReactNode, useState } from "react"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock ResizeObserver for tests (not available in jsdom) +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +beforeAll(() => { + global.ResizeObserver = ResizeObserverMock; +}); + +import { + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { DialogProvider } from "@/providers/DialogProvider/DialogProvider"; +import type { DialogProps } from "@/providers/DialogProvider/types"; + +import { useDialog } from "./useDialog"; + +// Mock Tanstack Router (needed since we're not in a router context) +let mockSearchParams: Record = {}; +const mockNavigate = vi.fn((options: any) => { + if (options?.search) { + mockSearchParams = { ...options.search }; + } +}); + +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => mockNavigate, + useSearch: () => ({ ...mockSearchParams }), // Return a copy to trigger updates +})); + +// Test dialog component with proper types +interface TestDialogProps extends DialogProps { + message: string; +} + +function TestDialog({ close, cancel, message }: TestDialogProps) { + const [input, setInput] = useState(""); + + return ( + <> + + {message} + Test dialog for unit testing + + + setInput(e.target.value)} + data-testid="dialog-input" + className="w-full border rounded px-2 py-1" + /> + + + + Cancel + + close(input)} + data-testid="dialog-confirm" + className="px-3 py-1 border rounded bg-blue-500 text-white" + > + Confirm + + + > + ); +} + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe("useDialog", () => { + beforeEach(() => { + // Reset mock search params before each test + mockSearchParams = {}; + mockNavigate.mockClear(); + }); + + it("should throw error when used outside DialogProvider", () => { + // Suppress console error for this test + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(() => { + renderHook(() => useDialog()); + }).toThrow("useDialog must be used within DialogProvider"); + + consoleSpy.mockRestore(); + }); + + it("should provide dialog methods when used inside DialogProvider", () => { + const { result } = renderHook(() => useDialog(), { wrapper }); + + expect(result.current).toHaveProperty("open"); + expect(result.current).toHaveProperty("close"); + expect(result.current).toHaveProperty("closeAll"); + expect(typeof result.current.open).toBe("function"); + }); + + it("should open and close a dialog with result", async () => { + let dialogResult: string | undefined; + + function TestComponent() { + const dialog = useDialog(); + + const handleOpen = async () => { + try { + dialogResult = await dialog.open({ + component: TestDialog as any, + props: { message: "Test Dialog" }, + }); + } catch { + dialogResult = "cancelled"; + } + }; + + return Open Dialog; + } + + render( + + + , + ); + + // Open dialog + fireEvent.click(screen.getByText("Open Dialog")); + await waitFor(() => { + expect(screen.getByText("Test Dialog")).toBeInTheDocument(); + }); + + // Type in input + const input = screen.getByTestId("dialog-input"); + fireEvent.change(input, { target: { value: "test result" } }); + + // Confirm dialog + fireEvent.click(screen.getByTestId("dialog-confirm")); + + // Check result + await waitFor(() => { + expect(dialogResult).toBe("test result"); + expect(screen.queryByText("Test Dialog")).not.toBeInTheDocument(); + }); + }); + + it("should reject promise when dialog is cancelled", async () => { + let dialogResult: string | undefined; + + function TestComponent() { + const dialog = useDialog(); + + const handleOpen = async () => { + try { + dialogResult = await dialog.open({ + component: TestDialog as any, + props: { message: "Test Dialog" }, + }); + } catch { + dialogResult = "cancelled"; + } + }; + + return Open Dialog; + } + + render( + + + , + ); + + // Open dialog + fireEvent.click(screen.getByText("Open Dialog")); + await waitFor(() => { + expect(screen.getByText("Test Dialog")).toBeInTheDocument(); + }); + + // Cancel dialog + fireEvent.click(screen.getByTestId("dialog-cancel")); + + // Check result + await waitFor(() => { + expect(dialogResult).toBe("cancelled"); + expect(screen.queryByText("Test Dialog")).not.toBeInTheDocument(); + }); + }); + + it("should handle multiple dialogs in stack", async () => { + const results: string[] = []; + + function TestComponent() { + const dialog = useDialog(); + + const handleOpenFirst = async () => { + const result = await dialog.open({ + component: TestDialog as any, + props: { message: "First Dialog" }, + }); + results.push(result); + }; + + const handleOpenSecond = async () => { + const result = await dialog.open({ + component: TestDialog as any, + props: { message: "Second Dialog" }, + }); + results.push(result); + }; + + return ( + <> + Open First + Open Second + > + ); + } + + render( + + + , + ); + + // Open first dialog + fireEvent.click(screen.getByText("Open First")); + await waitFor(() => { + expect(screen.getByText("First Dialog")).toBeInTheDocument(); + }); + + // Open second dialog (stacked on top - replaces first dialog content) + fireEvent.click(screen.getByText("Open Second")); + await waitFor(() => { + expect(screen.getByText("Second Dialog")).toBeInTheDocument(); + }); + + // Only the top dialog (Second Dialog) should be visible in the DOM + // The dialog system shows one dialog at a time within a single dialog shell + expect(screen.queryByText("First Dialog")).not.toBeInTheDocument(); + expect(screen.getByText("Second Dialog")).toBeInTheDocument(); + + // Back button should be visible when there are multiple dialogs in stack + expect( + screen.getByLabelText("Go back to previous dialog"), + ).toBeInTheDocument(); + + // Close second dialog + const input = screen.getByTestId("dialog-input"); + fireEvent.change(input, { target: { value: "second result" } }); + fireEvent.click(screen.getByTestId("dialog-confirm")); + + await waitFor(() => { + expect(screen.queryByText("Second Dialog")).not.toBeInTheDocument(); + expect(results).toContain("second result"); + }); + + // First dialog should now be visible again + await waitFor(() => { + expect(screen.getByText("First Dialog")).toBeInTheDocument(); + }); + }); + + it("should close all dialogs when closeAll is called", async () => { + function TestComponent() { + const dialog = useDialog(); + + const handleOpen = () => { + // Open dialogs and catch rejections to avoid unhandled promise errors + dialog + .open({ + component: TestDialog as any, + props: { message: "Dialog 1" }, + }) + .catch(() => {}); // Expected rejection when closeAll is called + + dialog + .open({ + component: TestDialog as any, + props: { message: "Dialog 2" }, + }) + .catch(() => {}); // Expected rejection when closeAll is called + }; + + return ( + <> + Open Dialogs + dialog.closeAll()}>Close All + > + ); + } + + render( + + + , + ); + + // Open dialogs - only the top dialog (Dialog 2) will be visible + fireEvent.click(screen.getByText("Open Dialogs")); + await waitFor(() => { + // Only the top dialog is visible at a time + expect(screen.getByText("Dialog 2")).toBeInTheDocument(); + }); + + // Verify multiple dialogs are stacked (back button should be visible) + expect( + screen.getByLabelText("Go back to previous dialog"), + ).toBeInTheDocument(); + + // Close all + fireEvent.click(screen.getByText("Close All")); + + await waitFor(() => { + expect(screen.queryByText("Dialog 1")).not.toBeInTheDocument(); + expect(screen.queryByText("Dialog 2")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/hooks/useDialog.ts b/src/hooks/useDialog.ts new file mode 100644 index 000000000..bf9f30721 --- /dev/null +++ b/src/hooks/useDialog.ts @@ -0,0 +1,16 @@ +import { useContext } from "react"; + +import { DialogContext } from "@/providers/DialogProvider/DialogContext"; + +export function useDialog() { + const context = useContext(DialogContext); + if (!context) { + throw new Error("useDialog must be used within DialogProvider"); + } + + return { + open: context.open, + close: context.close, + closeAll: context.closeAll, + }; +} diff --git a/src/providers/DialogProvider/DialogContext.tsx b/src/providers/DialogProvider/DialogContext.tsx new file mode 100644 index 000000000..c49b7ce29 --- /dev/null +++ b/src/providers/DialogProvider/DialogContext.tsx @@ -0,0 +1,7 @@ +import { createContext } from "react"; + +import type { DialogContextValue } from "./types"; + +export const DialogContext = createContext( + undefined, +); diff --git a/src/providers/DialogProvider/DialogProvider.test.tsx b/src/providers/DialogProvider/DialogProvider.test.tsx new file mode 100644 index 000000000..f1e3615ee --- /dev/null +++ b/src/providers/DialogProvider/DialogProvider.test.tsx @@ -0,0 +1,476 @@ +import { + act, + fireEvent, + render, + renderHook, + screen, + waitFor, +} from "@testing-library/react"; +import { type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useDialog } from "../../hooks/useDialog"; +import { DialogProvider } from "./DialogProvider"; +import type { DialogProps } from "./types"; + +// Mock ResizeObserver for AnimatedHeight component +class ResizeObserverMock { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); +} +vi.stubGlobal("ResizeObserver", ResizeObserverMock); + +// Mock navigation functions +let mockSearchParams: Record = {}; + +// Mock navigate that updates mockSearchParams to simulate real router behavior +const mockNavigate = vi.fn((options: { search: Record }) => { + mockSearchParams = options.search; +}); + +// Mock Tanstack Router +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => mockNavigate, + useSearch: () => mockSearchParams, +})); + +// Test dialog component +function TestDialog({ + close, + cancel, + title, +}: DialogProps & { title: string }) { + return ( + + {title} + close(true)} data-testid="dialog-confirm"> + Confirm + + + Cancel + + + ); +} + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe("DialogProvider", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSearchParams = {}; + }); + + it("should render children and dialog stack", () => { + render( + + App Content + , + ); + + expect(screen.getByText("App Content")).toBeInTheDocument(); + }); + + it("should update URL when dialog with routeKey opens", async () => { + const { result } = renderHook(() => useDialog(), { wrapper }); + + // Open dialog with routeKey + act(() => { + result.current.open({ + component: TestDialog, + props: { title: "Test" }, + routeKey: "test-dialog", + }); + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith({ + search: expect.objectContaining({ + dialog: "test-dialog", + dialogId: expect.any(String), + }), + }); + }); + }); + + it("should not update URL when dialog without routeKey opens", async () => { + const { result } = renderHook(() => useDialog(), { wrapper }); + + // Open dialog without routeKey + act(() => { + result.current.open({ + component: TestDialog, + props: { title: "Test" }, + }); + }); + + await waitFor(() => { + expect(screen.getByTestId("test-dialog")).toBeInTheDocument(); + }); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("should clear URL params when dialog with routeKey closes", async () => { + function TestComponent() { + const dialog = useDialog(); + + const handleOpen = () => { + dialog.open({ + component: TestDialog, + props: { title: "Test" }, + routeKey: "test-dialog", + }); + }; + + return Open Dialog; + } + + render( + + + , + ); + + // Open dialog + fireEvent.click(screen.getByText("Open Dialog")); + await waitFor(() => { + expect(screen.getByTestId("test-dialog")).toBeInTheDocument(); + }); + + // Reset mock to track close navigation + mockNavigate.mockClear(); + + // Close dialog + fireEvent.click(screen.getByTestId("dialog-confirm")); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith({ + search: {}, + }); + }); + }); + + it("should handle browser back button", async () => { + let dialogClosed = false; + + function TestComponent() { + const dialog = useDialog(); + + const handleOpen = async () => { + try { + await dialog.open({ + component: TestDialog, + props: { title: "Test" }, + routeKey: "test-dialog", + }); + } catch { + dialogClosed = true; + } + }; + + return Open Dialog; + } + + const { rerender } = render( + + + , + ); + + // Open dialog + fireEvent.click(screen.getByText("Open Dialog")); + + await waitFor(() => { + expect(screen.getByTestId("test-dialog")).toBeInTheDocument(); + }); + + // Simulate browser back by clearing search params and re-rendering + mockSearchParams = {}; + + rerender( + + + , + ); + + // Dialog should close + await waitFor(() => { + expect(screen.queryByTestId("test-dialog")).not.toBeInTheDocument(); + expect(dialogClosed).toBe(true); + }); + }); + + it("should handle closeOnEsc option", async () => { + let dialogResult: string | undefined; + + function TestComponent() { + const dialog = useDialog(); + + const handleOpen = async () => { + try { + await dialog.open({ + component: TestDialog, + props: { title: "Test" }, + closeOnEsc: false, + }); + dialogResult = "closed"; + } catch { + dialogResult = "cancelled"; + } + }; + + return Open Dialog; + } + + render( + + + , + ); + + // Open dialog + fireEvent.click(screen.getByText("Open Dialog")); + await waitFor(() => { + expect(screen.getByTestId("test-dialog")).toBeInTheDocument(); + }); + + // Press ESC + fireEvent.keyDown(document.activeElement || document.body, { + key: "Escape", + }); + + // Dialog should still be open because closeOnEsc is false + await waitFor(() => { + expect(screen.getByTestId("test-dialog")).toBeInTheDocument(); + expect(dialogResult).toBeUndefined(); + }); + }); + + it("should handle dialog size prop", async () => { + const { result } = renderHook(() => useDialog(), { wrapper }); + + // Open dialog with size + act(() => { + result.current.open({ + component: TestDialog, + props: { title: "Test" }, + size: "xl", + }); + }); + + await waitFor(() => { + const dialogContent = screen + .getByTestId("test-dialog") + .closest("[data-slot='dialog-content']"); + expect(dialogContent).toHaveClass("sm:max-w-4xl"); + }); + }); + + it("should maintain dialog stack order (only topmost dialog is rendered)", async () => { + const { result } = renderHook(() => useDialog(), { wrapper }); + + // Open multiple dialogs + act(() => { + result.current.open({ + component: TestDialog, + props: { title: "Dialog 1" }, + }); + }); + + act(() => { + result.current.open({ + component: TestDialog, + props: { title: "Dialog 2" }, + }); + }); + + act(() => { + result.current.open({ + component: TestDialog, + props: { title: "Dialog 3" }, + }); + }); + + // Only the topmost dialog (Dialog 3) should be rendered + await waitFor(() => { + const dialogs = screen.getAllByTestId("test-dialog"); + expect(dialogs).toHaveLength(1); + expect(screen.getByText("Dialog 3")).toBeInTheDocument(); + }); + + // Other dialogs should not be in the DOM + expect(screen.queryByText("Dialog 1")).not.toBeInTheDocument(); + expect(screen.queryByText("Dialog 2")).not.toBeInTheDocument(); + }); + + it("should only close top dialog when nested dialogs with routeKeys are used", async () => { + // Test component that opens nested dialogs + function NestedDialogOpener({ + close, + title, + }: DialogProps & { title: string }) { + const dialog = useDialog(); + + const openNestedDialog = () => { + dialog.open({ + component: TestDialog, + props: { title: "Dialog B (nested)" }, + routeKey: "dialog-b", + }); + }; + + return ( + + {title} + + Open Nested Dialog + + close(true)} data-testid="close-parent"> + Close Parent + + + ); + } + + function TestComponent() { + const dialog = useDialog(); + + const handleOpenDialogA = () => { + dialog.open({ + component: NestedDialogOpener, + props: { title: "Dialog A" }, + routeKey: "dialog-a", + }); + }; + + return Open Dialog A; + } + + render( + + + , + ); + + // Step 1: Open Dialog A + fireEvent.click(screen.getByText("Open Dialog A")); + await waitFor(() => { + expect(screen.getByTestId("dialog-Dialog A")).toBeInTheDocument(); + }); + + // Step 2: Open Dialog B from inside Dialog A + fireEvent.click(screen.getByTestId("open-nested")); + await waitFor(() => { + // Only the top dialog (Dialog B) is rendered - Dialog A is in the stack but not visible + expect(screen.getByTestId("test-dialog")).toBeInTheDocument(); + expect(screen.getByText("Dialog B (nested)")).toBeInTheDocument(); + }); + + // Dialog A is no longer visible (only topmost dialog is rendered) + expect(screen.queryByTestId("dialog-Dialog A")).not.toBeInTheDocument(); + + // Step 3: Confirm Dialog B (the nested one) - use the confirm button inside test-dialog + const dialogB = screen.getByTestId("test-dialog"); + const confirmButton = dialogB.querySelector( + '[data-testid="dialog-confirm"]', + ); + expect(confirmButton).not.toBeNull(); + fireEvent.click(confirmButton!); + + // Step 4: Only Dialog B should be closed, Dialog A should become visible again + await waitFor(() => { + // Dialog B should be gone + expect(screen.queryByText("Dialog B (nested)")).not.toBeInTheDocument(); + // Dialog A should now be visible (it's now the top of the stack) + expect(screen.getByTestId("dialog-Dialog A")).toBeInTheDocument(); + }); + }); + + it("should restore parent dialog URL when closing nested dialog with routeKey", async () => { + function NestedDialogOpener({ + close, + title, + }: DialogProps & { title: string }) { + const dialog = useDialog(); + + const openNestedDialog = () => { + dialog.open({ + component: TestDialog, + props: { title: "Dialog B" }, + routeKey: "dialog-b", + }); + }; + + return ( + + {title} + + Open Nested + + close(true)} data-testid="close-parent"> + Close + + + ); + } + + function TestComponent() { + const dialog = useDialog(); + + const handleOpenDialogA = () => { + dialog.open({ + component: NestedDialogOpener, + props: { title: "Dialog A" }, + routeKey: "dialog-a", + }); + }; + + return Open Dialog A; + } + + render( + + + , + ); + + // Open Dialog A + fireEvent.click(screen.getByText("Open Dialog A")); + await waitFor(() => { + expect(screen.getByTestId("dialog-Dialog A")).toBeInTheDocument(); + }); + + // Capture the dialogId for Dialog A + const dialogACall = mockNavigate.mock.calls.find( + (call) => call[0].search.dialog === "dialog-a", + ); + const dialogAId = dialogACall?.[0].search.dialogId; + expect(dialogAId).toBeTruthy(); + + // Open Dialog B + fireEvent.click(screen.getByTestId("open-nested")); + await waitFor(() => { + expect(screen.getByText("Dialog B")).toBeInTheDocument(); + }); + + // Reset mock to check close navigation + mockNavigate.mockClear(); + + // Close Dialog B + fireEvent.click(screen.getByTestId("dialog-confirm")); + + // URL should be restored to Dialog A's info + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith({ + search: expect.objectContaining({ + dialog: "dialog-a", + dialogId: dialogAId, + }), + }); + }); + }); +}); diff --git a/src/providers/DialogProvider/DialogProvider.tsx b/src/providers/DialogProvider/DialogProvider.tsx new file mode 100644 index 000000000..3e8908d0b --- /dev/null +++ b/src/providers/DialogProvider/DialogProvider.tsx @@ -0,0 +1,176 @@ +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { type ReactNode, useCallback, useRef, useState } from "react"; + +import { DialogContext } from "./DialogContext"; +import DialogRenderer from "./DialogRenderer"; +import { + type DialogConfig, + type DialogContextValue, + type DialogInstance, + type PromiseCallbacks, +} from "./types"; +import { useDialogRouter } from "./useDialogRouter"; +import { + createCancellationError, + DIALOG_PARAM_KEYS, + DIALOG_SEARCH_PARAMS, + generateDialogId, + getTopRoutedDialog, + omitSearchParams, +} from "./utils"; + +interface DialogProviderProps { + children: ReactNode; + disableRouterSync?: boolean; +} + +export function DialogProvider({ + children, + disableRouterSync = false, +}: DialogProviderProps) { + const [stack, setStack] = useState([]); + const resolvers = useRef(new Map()); + const pendingDialogIds = useRef(new Set()); + const closingDialogIds = useRef(new Set()); + const navigate = useNavigate(); + const searchParams = useSearch({ strict: false }) as Record; + const searchParamsRef = useRef(searchParams); + searchParamsRef.current = searchParams; + + const open = useCallback( + async (config: DialogConfig): Promise => { + const id = generateDialogId(); + + return new Promise((resolve, reject) => { + const dialog = { + ...config, + id, + resolve, + reject, + } as DialogInstance; + + setStack((prev) => [...prev, dialog]); + + // Update URL if routeKey provided + if (config.routeKey) { + // Mark as pending BEFORE navigate to prevent race condition with useDialogRouter + pendingDialogIds.current.add(id); + + navigate({ + search: { + ...searchParams, + [DIALOG_SEARCH_PARAMS.DIALOG_KEY]: config.routeKey, + [DIALOG_SEARCH_PARAMS.DIALOG_ID]: id, + } as any, + }); + } + + resolvers.current.set(id, { resolve, reject }); + }); + }, + [navigate, searchParams], + ); + + const close = useCallback( + (id: string, result?: unknown) => { + const resolver = resolvers.current.get(id); + if (resolver) { + resolver.resolve(result); + resolvers.current.delete(id); + } + + // Mark as closing BEFORE updating stack to prevent race with useDialogRouter + closingDialogIds.current.add(id); + + setStack((prev) => { + const dialogIndex = prev.findIndex((d) => d.id === id); + const dialog = dialogIndex !== -1 ? prev[dialogIndex] : undefined; + const remainingStack = prev.filter((d) => d.id !== id); + + // Update URL if this dialog had a routeKey + if (dialog?.routeKey) { + const nextRoutedDialog = getTopRoutedDialog(remainingStack); + + // Handle URL update outside of setState to avoid issues + setTimeout(() => { + const baseSearch = omitSearchParams( + searchParamsRef.current, + DIALOG_PARAM_KEYS, + ); + + const nextSearch = nextRoutedDialog + ? { + ...baseSearch, + [DIALOG_SEARCH_PARAMS.DIALOG_KEY]: nextRoutedDialog.routeKey, + [DIALOG_SEARCH_PARAMS.DIALOG_ID]: nextRoutedDialog.id, + } + : baseSearch; + + navigate({ search: nextSearch as any }); + closingDialogIds.current.delete(id); + }, 0); + } else { + closingDialogIds.current.delete(id); + } + + return remainingStack; + }); + }, + [navigate], + ); + + const cancel = useCallback((id: string) => { + const resolver = resolvers.current.get(id); + if (resolver) { + resolver.reject(createCancellationError()); + resolvers.current.delete(id); + } + + // Remove from stack (URL cleanup is handled by useDialogRouter) + setStack((prev) => prev.filter((d) => d.id !== id)); + }, []); + + const closeAll = useCallback(() => { + setStack((prev) => { + prev.forEach((dialog) => { + const resolver = resolvers.current.get(dialog.id); + if (resolver) { + resolver.reject(new Error("All dialogs closed")); + resolvers.current.delete(dialog.id); + } + }); + return []; + }); + + navigate({ + search: omitSearchParams( + searchParamsRef.current, + DIALOG_PARAM_KEYS, + ) as any, + }); + }, [navigate]); + + // Router synchronization - always called but can be disabled via parameter + useDialogRouter( + stack, + cancel, + pendingDialogIds, + closingDialogIds, + disableRouterSync, + ); + + const value: DialogContextValue = { + open, + close, + cancel, + closeAll, + stack, + }; + + return ( + + {children} + + + ); +} diff --git a/src/providers/DialogProvider/DialogRenderer.tsx b/src/providers/DialogProvider/DialogRenderer.tsx new file mode 100644 index 000000000..1cd27f55d --- /dev/null +++ b/src/providers/DialogProvider/DialogRenderer.tsx @@ -0,0 +1,170 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { AnimatedHeight } from "@/components/ui/animated-height"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { Icon } from "@/components/ui/icon"; +import { InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; + +import type { DialogInstance } from "./types"; +import { createCancellationError } from "./utils"; + +type TransitionDirection = "forward" | "backward"; + +/** Map dialog size prop to Tailwind CSS classes */ +const DIALOG_SIZE_CLASSES = { + sm: "sm:max-w-sm", + md: "sm:max-w-lg", + lg: "sm:max-w-2xl", + xl: "sm:max-w-4xl", + full: "sm:max-w-[90vw]", +} as const; + +interface DialogRendererProps { + stack: DialogInstance[]; + onClose: (id: string, result?: unknown) => void; +} + +export default function DialogRenderer({ + stack, + onClose, +}: DialogRendererProps) { + const activeDialog = stack.length > 0 ? stack[stack.length - 1] : null; + const hasMultipleDialogs = stack.length > 1; + + const prevStackLengthRef = useRef(stack.length); + const [direction, setDirection] = useState("forward"); + const [animationKey, setAnimationKey] = useState(0); + const [shouldAnimate, setShouldAnimate] = useState(false); + + // Track direction based on stack length changes + useEffect(() => { + const prevLength = prevStackLengthRef.current; + const currentLength = stack.length; + + if (currentLength !== prevLength) { + // Reset animation state when stack is empty + if (currentLength === 0) { + setShouldAnimate(false); + } else { + // Only animate when navigating between dialogs, not when opening the first one + const isFirstDialogOpening = prevLength === 0 && currentLength === 1; + + if (!isFirstDialogOpening) { + setDirection(currentLength > prevLength ? "forward" : "backward"); + setShouldAnimate(true); + setAnimationKey((k) => k + 1); + } + } + } + + prevStackLengthRef.current = currentLength; + }, [stack.length]); + + const handleOpenChange = useCallback( + (open: boolean, dialog: DialogInstance) => { + if (!open) { + // Dialog is being closed via ESC or overlay click + if ( + dialog.closeOnEsc === false && + dialog.closeOnOverlayClick === false + ) { + // Don't close if both are disabled + return; + } + + // Cancel the dialog (reject the promise) + dialog.reject(createCancellationError()); + onClose(dialog.id); + } + }, + [onClose], + ); + + const handleBack = useCallback(() => { + if (!activeDialog) return; + + // Cancel the current dialog and pop back to the previous one + activeDialog.reject(createCancellationError()); + onClose(activeDialog.id); + }, [activeDialog, onClose]); + + if (!activeDialog) return null; + + const Component = activeDialog.component as React.ComponentType<{ + close: (result?: unknown) => void; + cancel: () => void; + [key: string]: unknown; + }>; + + const dialogProps = { + close: (result?: unknown) => { + activeDialog.resolve(result); + onClose(activeDialog.id, result); + }, + cancel: () => { + activeDialog.reject(createCancellationError()); + onClose(activeDialog.id); + }, + ...activeDialog.props, + }; + + const sizeClass = DIALOG_SIZE_CLASSES[activeDialog.size ?? "md"]; + + const animationClass = shouldAnimate + ? direction === "forward" + ? "dialog-slide-in-forward" + : "dialog-slide-in-backward" + : ""; + + return ( + handleOpenChange(open, activeDialog)} + > + e.preventDefault() + : undefined + } + onInteractOutside={ + activeDialog.closeOnOverlayClick === false + ? (e) => e.preventDefault() + : undefined + } + > + {hasMultipleDialogs && ( + + + + Back + + + )} + + + + + + + + ); +} diff --git a/src/providers/DialogProvider/types.ts b/src/providers/DialogProvider/types.ts new file mode 100644 index 000000000..ad9e8ba24 --- /dev/null +++ b/src/providers/DialogProvider/types.ts @@ -0,0 +1,39 @@ +import { type ComponentType } from "react"; + +export type DialogProps = { + close: (result?: T) => void; + cancel: () => void; +} & TProps; + +export interface DialogConfig { + component: ComponentType>; + props?: TProps; + routeKey?: string; + size?: "sm" | "md" | "lg" | "xl" | "full"; + closeOnEsc?: boolean; + closeOnOverlayClick?: boolean; +} + +export interface DialogInstance + extends DialogConfig { + id: string; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: any) => void; +} + +export interface DialogContextValue { + open: (config: DialogConfig) => Promise; + close: (id: string, result?: any) => void; + cancel: (id: string) => void; + closeAll: () => void; + stack: DialogInstance[]; +} + +export interface PromiseCallbacks { + resolve: (value: T | PromiseLike) => void; + reject: (reason?: any) => void; +} + +export class DialogCancelledError extends Error { + name = "DialogCancelledError"; +} diff --git a/src/providers/DialogProvider/useDialogRouter.test.tsx b/src/providers/DialogProvider/useDialogRouter.test.tsx new file mode 100644 index 000000000..bfbc320ea --- /dev/null +++ b/src/providers/DialogProvider/useDialogRouter.test.tsx @@ -0,0 +1,392 @@ +import { renderHook } from "@testing-library/react"; +import type { RefObject } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { DialogInstance } from "./types"; +import { useDialogRouter } from "./useDialogRouter"; + +// Mock navigation functions +const mockNavigate = vi.fn(); +let mockSearchParams: Record = {}; + +// Mock Tanstack Router +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => mockNavigate, + useSearch: () => mockSearchParams, +})); + +// Helper to create a mock ref for dialog IDs +const createDialogIdsRef = (ids: string[] = []): RefObject> => ({ + current: new Set(ids), +}); + +describe("useDialogRouter", () => { + const mockCancel = vi.fn(); + const emptyPendingRef = createDialogIdsRef(); + const emptyClosingRef = createDialogIdsRef(); + + beforeEach(() => { + vi.clearAllMocks(); + mockSearchParams = {}; + }); + + it("should cancel dialog when URL params are cleared (back button)", () => { + const stack: DialogInstance[] = [ + { + id: "dialog1", + component: vi.fn() as any, + resolve: vi.fn(), + reject: vi.fn(), + routeKey: "test", + }, + ]; + + // Set initial search params + mockSearchParams = { dialog: "test", dialogId: "dialog1" }; + + const { rerender } = renderHook( + ({ stack }) => + useDialogRouter(stack, mockCancel, emptyPendingRef, emptyClosingRef), + { + initialProps: { stack }, + }, + ); + + // Simulate back button (params cleared) + mockSearchParams = {}; + rerender({ stack }); + + // Should call cancel on the dialog + expect(mockCancel).toHaveBeenCalledWith("dialog1"); + }); + + it("should clean URL when dialog in URL but not in stack", () => { + const stack: DialogInstance[] = []; + + // Set search params with dialog that doesn't exist + mockSearchParams = { + dialog: "test", + dialogId: "non-existent", + other: "param", + }; + + renderHook(() => + useDialogRouter(stack, mockCancel, emptyPendingRef, emptyClosingRef), + ); + + // Should navigate to clean URL + expect(mockNavigate).toHaveBeenCalledWith({ + search: { other: "param" }, + }); + }); + + it("should NOT clean URL when dialog is pending (being opened)", () => { + const stack: DialogInstance[] = []; + const pendingRef = createDialogIdsRef(["pending-dialog"]); + + // Set search params with dialog that is pending + mockSearchParams = { + dialog: "test", + dialogId: "pending-dialog", + other: "param", + }; + + renderHook(() => + useDialogRouter(stack, mockCancel, pendingRef, emptyClosingRef), + ); + + // Should NOT navigate because dialog is pending + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("should update URL when stack shrinks (programmatic close)", () => { + const initialStack: DialogInstance[] = [ + { + id: "dialog1", + component: vi.fn() as any, + resolve: vi.fn(), + reject: vi.fn(), + }, + { + id: "dialog2", + component: vi.fn() as any, + resolve: vi.fn(), + reject: vi.fn(), + }, + ]; + + mockSearchParams = { + dialog: "test", + dialogId: "dialog2", + }; + + const { rerender } = renderHook( + ({ stack }) => + useDialogRouter(stack, mockCancel, emptyPendingRef, emptyClosingRef), + { + initialProps: { stack: initialStack }, + }, + ); + + // Simulate stack shrinking (dialog closed) + const newStack = [initialStack[0]]; + rerender({ stack: newStack }); + + // Should navigate to clean URL + expect(mockNavigate).toHaveBeenCalledWith({ + search: {}, + }); + }); + + it("should not navigate when stack grows", () => { + const initialStack: DialogInstance[] = [ + { + id: "dialog1", + component: vi.fn() as any, + resolve: vi.fn(), + reject: vi.fn(), + }, + ]; + + const { rerender } = renderHook( + ({ stack }) => + useDialogRouter(stack, mockCancel, emptyPendingRef, emptyClosingRef), + { + initialProps: { stack: initialStack }, + }, + ); + + mockNavigate.mockClear(); + + // Simulate stack growing (new dialog opened) + const newStack = [ + ...initialStack, + { + id: "dialog2", + component: vi.fn() as any, + resolve: vi.fn(), + reject: vi.fn(), + }, + ]; + rerender({ stack: newStack }); + + // Should not navigate + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("should cancel multiple dialogs with routeKey when back button pressed", () => { + const stack: DialogInstance[] = [ + { + id: "dialog1", + component: vi.fn() as any, + resolve: vi.fn(), + reject: vi.fn(), + routeKey: "first", + }, + { + id: "dialog2", + component: vi.fn() as any, + resolve: vi.fn(), + reject: vi.fn(), + routeKey: "second", + }, + ]; + + // Set initial params + mockSearchParams = { + dialog: "second", + dialogId: "dialog2", + }; + + const { rerender } = renderHook(() => + useDialogRouter(stack, mockCancel, emptyPendingRef, emptyClosingRef), + ); + + // Clear params to simulate back button + mockSearchParams = {}; + rerender(); + + // Should cancel the top dialog + expect(mockCancel).toHaveBeenCalledWith("dialog2"); + }); + + it("should cancel nested dialog when URL points to parent dialog (back button from nested dialog)", () => { + // Scenario: Dialog A is open, Dialog B opens inside A + // User presses back button, URL now points to A's params + // Dialog B should be cancelled, Dialog A should remain + const stack: DialogInstance[] = [ + { + id: "dialog-a", + component: vi.fn() as any, + resolve: vi.fn(), + reject: vi.fn(), + routeKey: "dialog-a", + }, + { + id: "dialog-b", + component: vi.fn() as any, + resolve: vi.fn(), + reject: vi.fn(), + routeKey: "dialog-b", + }, + ]; + + // URL initially points to dialog B + mockSearchParams = { + dialog: "dialog-b", + dialogId: "dialog-b", + }; + + const { rerender } = renderHook(() => + useDialogRouter(stack, mockCancel, emptyPendingRef, emptyClosingRef), + ); + + // Simulate back button - URL now points to dialog A + mockSearchParams = { + dialog: "dialog-a", + dialogId: "dialog-a", + }; + rerender(); + + // Should cancel dialog B (the nested one), not dialog A + expect(mockCancel).toHaveBeenCalledWith("dialog-b"); + expect(mockCancel).toHaveBeenCalledTimes(1); + }); + + it("should cancel only routed dialogs above when navigating back to parent", () => { + // Scenario: Dialog A (routed) -> Dialog B (not routed) -> Dialog C (routed) + // Back button should close Dialog C + const stack: DialogInstance[] = [ + { + id: "dialog-a", + component: vi.fn() as any, + resolve: vi.fn(), + reject: vi.fn(), + routeKey: "dialog-a", + }, + { + id: "dialog-b-no-route", + component: vi.fn() as any, + resolve: vi.fn(), + reject: vi.fn(), + // No routeKey + }, + { + id: "dialog-c", + component: vi.fn() as any, + resolve: vi.fn(), + reject: vi.fn(), + routeKey: "dialog-c", + }, + ]; + + mockSearchParams = { + dialog: "dialog-c", + dialogId: "dialog-c", + }; + + const { rerender } = renderHook(() => + useDialogRouter(stack, mockCancel, emptyPendingRef, emptyClosingRef), + ); + + // Back to dialog A + mockSearchParams = { + dialog: "dialog-a", + dialogId: "dialog-a", + }; + rerender(); + + // Should cancel dialog C (routed), not dialog B (not routed) + expect(mockCancel).toHaveBeenCalledWith("dialog-c"); + expect(mockCancel).toHaveBeenCalledTimes(1); + }); + + it("should NOT clean URL when stack shrinks but URL dialog is still in stack", () => { + // Scenario: Dialog A and B are open, URL points to A + // Dialog B is removed from stack (closed by back nav handler) + // URL should NOT be cleaned because A is still in the stack + const initialStack: DialogInstance[] = [ + { + id: "dialog-a", + component: vi.fn() as any, + resolve: vi.fn(), + reject: vi.fn(), + routeKey: "dialog-a", + }, + { + id: "dialog-b", + component: vi.fn() as any, + resolve: vi.fn(), + reject: vi.fn(), + routeKey: "dialog-b", + }, + ]; + + // URL points to dialog A (after user pressed back) + mockSearchParams = { + dialog: "dialog-a", + dialogId: "dialog-a", + }; + + const { rerender } = renderHook( + ({ stack }) => + useDialogRouter(stack, mockCancel, emptyPendingRef, emptyClosingRef), + { + initialProps: { stack: initialStack }, + }, + ); + + mockNavigate.mockClear(); + + // Stack shrinks to just dialog A (dialog B was closed) + const newStack = [initialStack[0]]; + rerender({ stack: newStack }); + + // Should NOT navigate because dialog A (in URL) is still in the stack + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("should NOT clean URL when dialog is being closed (closingDialogIds)", () => { + const stack: DialogInstance[] = []; + const closingRef = createDialogIdsRef(["closing-dialog"]); + + // Set search params with dialog that is being closed + mockSearchParams = { + dialog: "test", + dialogId: "closing-dialog", + other: "param", + }; + + renderHook(() => + useDialogRouter(stack, mockCancel, emptyPendingRef, closingRef), + ); + + // Should NOT navigate because dialog is being closed + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("should NOT cancel dialogs when any dialog is being closed", () => { + const stack: DialogInstance[] = [ + { + id: "dialog-a", + component: vi.fn() as any, + resolve: vi.fn(), + reject: vi.fn(), + routeKey: "dialog-a", + }, + ]; + + // URL is empty (simulating the race condition when close updates URL) + mockSearchParams = {}; + + // Mark that a dialog is being closed + const closingRef = createDialogIdsRef(["some-closing-dialog"]); + + renderHook(() => + useDialogRouter(stack, mockCancel, emptyPendingRef, closingRef), + ); + + // Should NOT cancel because a dialog is being closed (close function handles URL) + expect(mockCancel).not.toHaveBeenCalled(); + }); +}); diff --git a/src/providers/DialogProvider/useDialogRouter.ts b/src/providers/DialogProvider/useDialogRouter.ts new file mode 100644 index 000000000..a992cb5cc --- /dev/null +++ b/src/providers/DialogProvider/useDialogRouter.ts @@ -0,0 +1,148 @@ +import { useNavigate, useSearch } from "@tanstack/react-router"; +import type { RefObject } from "react"; +import { useEffect, useRef } from "react"; + +import type { DialogInstance } from "./types"; +import { + DIALOG_PARAM_KEYS, + DIALOG_SEARCH_PARAMS, + getRoutedDialogsAbove, + getTopRoutedDialog, + omitSearchParams, +} from "./utils"; + +function isPendingOrClosing( + dialogId: string, + pendingIds: RefObject>, + closingIds: RefObject>, +): boolean { + return ( + Boolean(pendingIds.current?.has(dialogId)) || + Boolean(closingIds.current?.has(dialogId)) + ); +} + +export function useDialogRouter( + stack: DialogInstance[], + cancel: (id: string) => void, + pendingDialogIds: RefObject>, + closingDialogIds: RefObject>, + disabled: boolean = false, +) { + const navigate = useNavigate(); + const searchParams = useSearch({ strict: false }) as Record; + const previousStackLength = useRef(stack.length); + + // Handle URL changes + useEffect(() => { + if (disabled) return; + + const dialogId = searchParams[DIALOG_SEARCH_PARAMS.DIALOG_ID] as + | string + | undefined; + const dialogKey = searchParams[DIALOG_SEARCH_PARAMS.DIALOG_KEY] as + | string + | undefined; + + // If URL now has the dialogId we were waiting for, clear the pending flag + // This must happen BEFORE checking cancel conditions + if (dialogId && pendingDialogIds.current?.has(dialogId)) { + pendingDialogIds.current.delete(dialogId); + return; + } + + // If URL has no dialog params but we have dialogs with routeKey open, cancel them + // This happens when user clicks browser back button + if (!dialogId && !dialogKey && stack.length > 0) { + const topDialogWithRoute = getTopRoutedDialog(stack); + + if (topDialogWithRoute) { + // Skip if this dialog is still pending (being opened) to avoid race condition + if (pendingDialogIds.current?.has(topDialogWithRoute.id)) { + return; + } + + // Skip if any dialog is currently being closed - the close function will handle URL + if (closingDialogIds.current?.size) { + return; + } + + cancel(topDialogWithRoute.id); + } + return; + } + + // If URL has a dialog ID that matches a dialog in the stack, but there are + // routed dialogs above it, close the top one (handles nested dialog back navigation) + if (dialogId) { + const matchingDialogIndex = stack.findIndex((d) => d.id === dialogId); + + if (matchingDialogIndex !== -1) { + const routedDialogsAbove = getRoutedDialogsAbove( + stack, + matchingDialogIndex, + ); + + if (routedDialogsAbove.length > 0) { + const topRoutedDialog = + routedDialogsAbove[routedDialogsAbove.length - 1]; + + if ( + !isPendingOrClosing( + topRoutedDialog.id, + pendingDialogIds, + closingDialogIds, + ) + ) { + cancel(topRoutedDialog.id); + } + } + return; + } + } + + // If URL has dialog params but no matching dialog in stack AND not pending/closing, clean URL + if ( + dialogId && + !stack.find((d) => d.id === dialogId) && + !isPendingOrClosing(dialogId, pendingDialogIds, closingDialogIds) + ) { + navigate({ + search: omitSearchParams(searchParams, DIALOG_PARAM_KEYS) as any, + }); + } + }, [ + disabled, + searchParams, + stack, + cancel, + navigate, + pendingDialogIds, + closingDialogIds, + ]); + + // Handle stack changes + useEffect(() => { + if (disabled) return; + + // If stack shrunk (dialog closed programmatically), update URL + if (stack.length < previousStackLength.current) { + const dialogIdInUrl = searchParams[DIALOG_SEARCH_PARAMS.DIALOG_ID] as + | string + | undefined; + + // Only clean URL if the dialog in the URL is no longer in the stack + // AND we're not in the middle of closing a dialog (which will handle URL itself) + if ( + dialogIdInUrl && + !stack.find((d) => d.id === dialogIdInUrl) && + !closingDialogIds.current?.has(dialogIdInUrl) + ) { + navigate({ + search: omitSearchParams(searchParams, DIALOG_PARAM_KEYS) as any, + }); + } + } + previousStackLength.current = stack.length; + }, [disabled, stack.length, navigate, searchParams, stack, closingDialogIds]); +} diff --git a/src/providers/DialogProvider/utils.ts b/src/providers/DialogProvider/utils.ts new file mode 100644 index 000000000..b87860086 --- /dev/null +++ b/src/providers/DialogProvider/utils.ts @@ -0,0 +1,51 @@ +import { DialogCancelledError, type DialogInstance } from "./types"; + +/** URL search parameter keys used for dialog routing */ +export const DIALOG_SEARCH_PARAMS = { + DIALOG_KEY: "dialog", + DIALOG_ID: "dialogId", +} as const; + +/** Array of dialog-related search param keys for easy omission */ +export const DIALOG_PARAM_KEYS = [ + DIALOG_SEARCH_PARAMS.DIALOG_KEY, + DIALOG_SEARCH_PARAMS.DIALOG_ID, +] as const; + +export function generateDialogId(): string { + return crypto.randomUUID(); +} + +export function omitSearchParams( + params: Record, + keys: readonly string[], +): Record { + const result = { ...params }; + keys.forEach((key) => delete result[key]); + return result; +} + +/** Creates a standardized cancellation error for dialog operations */ +export function createCancellationError( + message = "Dialog cancelled", +): DialogCancelledError { + return new DialogCancelledError(message); +} + +/** Gets the topmost dialog with a routeKey from the stack */ +export function getTopRoutedDialog( + stack: DialogInstance[], +): DialogInstance | null { + const dialogsWithRouteKey = stack.filter((d) => d.routeKey); + return dialogsWithRouteKey.length > 0 + ? dialogsWithRouteKey[dialogsWithRouteKey.length - 1] + : null; +} + +/** Gets all routed dialogs above a given index in the stack */ +export function getRoutedDialogsAbove( + stack: DialogInstance[], + targetIndex: number, +): DialogInstance[] { + return stack.slice(targetIndex + 1).filter((d) => d.routeKey); +} diff --git a/src/styles/global.css b/src/styles/global.css index b9ff3d49c..41f01f7df 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -166,3 +166,39 @@ code { display: none; /* Chrome, Safari, Opera */ } } + +/* Dialog stack transition animations */ +.dialog-content-inner { + display: grid; + gap: 1rem; +} + +@keyframes dialog-slide-forward { + from { + opacity: 0; + transform: translateX(30px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes dialog-slide-backward { + from { + opacity: 0; + transform: translateX(-30px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.dialog-slide-in-forward { + animation: dialog-slide-forward 200ms ease-out; +} + +.dialog-slide-in-backward { + animation: dialog-slide-backward 200ms ease-out; +}