diff --git a/frontend/src/components/utility/LazyMount.stories.tsx b/frontend/src/components/utility/LazyMount.stories.tsx index d2dd5a582..556cd89c1 100644 --- a/frontend/src/components/utility/LazyMount.stories.tsx +++ b/frontend/src/components/utility/LazyMount.stories.tsx @@ -1,6 +1,7 @@ import { lazy, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Alert, Button, Card, Skeleton, Space, Switch, Tag, Typography } from 'antd'; +import { Alert, Button, Card, Layout, Modal, Skeleton, Space, Switch, Tag, Typography } from 'antd'; +import { expect, userEvent, within } from 'storybook/test'; import LazyMount from './LazyMount'; @@ -21,7 +22,10 @@ const meta = { when: { description: 'Children mount the first time this becomes true and stay mounted afterwards.', }, - fallback: { description: 'Suspense fallback shown while a React.lazy child is still loading.' }, + fallback: { + description: + 'Suspense fallback shown while a React.lazy child is loading. The default fullscreen spinner does not occupy space in the surrounding layout; custom fallbacks render as supplied.', + }, children: { description: 'Content to mount on demand, typically a lazily imported modal.' }, }, } satisfies Meta; @@ -145,3 +149,53 @@ export const MountedImmediately: Story = { ), }, }; + +function LazyModalInLayoutDemo() { + const [open, setOpen] = useState(false); + const [SlowModal] = useState(() => + lazy( + () => + new Promise<{ default: typeof Modal }>((resolve) => { + setTimeout(() => resolve({ default: Modal }), 1200); + }), + ), + ); + return ( + + + Navigation + + + + + The content width stays fixed while the dialog loads. + + + + setOpen(false)} footer={null}> + Dialog loaded without resizing the page. + + + + ); +} + +export const LazyModalInLayout: Story = { + args: placeholderArgs, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const content = canvas.getByTestId('lazy-modal-content'); + const width = content.getBoundingClientRect().width; + await userEvent.click(canvas.getByRole('button', { name: 'Open lazy dialog' })); + const widths: number[] = []; + for (let frame = 0; frame < 12; frame++) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + widths.push(content.getBoundingClientRect().width); + } + expect(Math.min(...widths)).toBeCloseTo(width, 1); + const page = within(canvasElement.ownerDocument.body); + await page.findByRole('dialog', { name: 'Lazy dialog' }, { timeout: 5000 }); + await userEvent.click(page.getByRole('button', { name: 'Close' })); + }, +}; diff --git a/frontend/src/components/utility/LazyMount.tsx b/frontend/src/components/utility/LazyMount.tsx index ffbb0c8d9..f4bb54dde 100644 --- a/frontend/src/components/utility/LazyMount.tsx +++ b/frontend/src/components/utility/LazyMount.tsx @@ -11,7 +11,11 @@ interface LazyMountProps { // thereafter, so React.lazy modals get loaded on demand but their close // animations still play out. Pair with `lazy(() => import(...))` modal imports // on heavy list pages to keep the initial bundle small. -export default function LazyMount({ when, fallback = , children }: LazyMountProps) { +export default function LazyMount({ + when, + fallback = , + children, +}: LazyMountProps) { const [mounted, setMounted] = useState(when); if (when && !mounted) setMounted(true); if (!mounted) return null;