Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,27 @@ import type { Preview } from '@storybook/react';
import { LayoutContextProvider } from '../src/contexts/layout.context';
import '../src/index.css';

// The Modal component reads `topOffset` from `modalRootRef.getBoundingClientRect().top`
// to leave room for the app's layout header. To make stories render the same way
// the deployed app does, the decorator provides:
// - a visible header surrogate (scrollRef-tagged div, 64 px tall) at the top of
// the host so screenshots include the blue band the app shows above any modal,
// - a measurable modalRootRef anchor placed immediately below the header so its
// bounding rect resolves to y = 64 in time for Modal's useEffect.
function StorybookLayoutHost({ children }: { children: ReactNode }) {
const rootRef = useRef<HTMLDivElement | null>(null);
const modalRootRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);

return (
<div ref={rootRef} className="min-h-screen w-full">
<div ref={scrollRef} className="h-16 w-full bg-dfxBlue-800" />
<div ref={modalRootRef} />
<div ref={rootRef} className="min-h-screen w-full bg-white">
<div
ref={scrollRef}
className="h-16 w-full bg-dfxBlue-800 flex items-center px-4 text-sm font-medium text-white"
>
DFX Services
</div>
<div ref={modalRootRef} className="relative" data-testid="modal-root-anchor" />
<LayoutContextProvider rootRef={rootRef} modalRootRef={modalRootRef} scrollRef={scrollRef}>
{children}
</LayoutContextProvider>
Expand Down
23 changes: 20 additions & 3 deletions e2e/storybook/modal-visual.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,28 @@ for (const story of STORIES) {
await page.setViewportSize(viewport);
await page.goto(`/iframe.html?id=${story.id}&viewMode=story`);

// Wait for Storybook's story root to confirm the component is mounted,
// then for fonts to settle so subpixel differences don't flake the diff.
await page.locator('#storybook-root').waitFor({ state: 'attached' });
// Story root must be mounted before we look for the modal portal.
await page.locator('#storybook-root').waitFor({ state: 'visible' });

// The Modal portal lands on document.body; wait for its outer wrapper
// (`z-50` is on both variants) to ensure the component has reached
// its second render after `mounted` and refs have been set.
await page.locator('div.z-50').waitFor({ state: 'visible' });

// Fonts must be ready or text rendering shifts subpixels between runs.
await page.evaluate(() => document.fonts.ready);

// Modal's fullscreen variant computes `topOffset` from a ResizeObserver
// on documentElement which fires asynchronously after the initial paint.
// Wait two animation frames so the re-render with the final `top` style
// has been committed before the snapshot is taken.
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);

await expect(page).toHaveScreenshot(`${story.name}-${viewportName}.png`, {
fullPage: true,
});
Expand Down
89 changes: 69 additions & 20 deletions src/components/modal.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,88 @@ import { Modal } from './modal';

function SampleFullscreenContent(): JSX.Element {
return (
<div className="flex flex-col gap-4 text-dfxBlue-800">
<h1 className="text-2xl font-semibold">Fullscreen modal</h1>
<p className="text-sm">
The fullscreen variant fills the viewport below the layout header. It is used for primary content flows such as
KYC, Safe Deposit, and Buy / Sell.
<div className="flex flex-col gap-6 text-dfxBlue-800">
<header className="flex flex-col gap-2">
<span className="text-xs uppercase tracking-wider text-dfxGray-800">Step 2 of 4</span>
<h1 className="text-2xl font-semibold">Identity verification</h1>
<p className="text-sm text-dfxGray-800">
Confirm the information on your government-issued ID. All fields are required.
</p>
</header>

<div className="flex flex-col gap-4">
<label className="flex flex-col gap-1">
<span className="text-sm font-medium">First name</span>
<input
type="text"
defaultValue="Jane"
readOnly
className="rounded-md border border-dfxGray-500 px-3 py-2 text-sm"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-sm font-medium">Last name</span>
<input
type="text"
defaultValue="Müller"
readOnly
className="rounded-md border border-dfxGray-500 px-3 py-2 text-sm"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-sm font-medium">Date of birth</span>
<input
type="text"
defaultValue="1985-04-12"
readOnly
className="rounded-md border border-dfxGray-500 px-3 py-2 text-sm"
/>
</label>
</div>

<p className="rounded-md bg-dfxGray-300 p-3 text-xs text-dfxGray-800">
We share these details with our regulated KYC partner. Your data is never sold and is deleted on request.
</p>
<div className="rounded-md bg-dfxGray-300 p-4 text-sm">
Body content area — fills the available viewport width up to <code>max-w-screen-md</code>.

<div className="flex flex-row gap-2 self-start">
<button
type="button"
className="rounded-md border border-dfxGray-500 px-4 py-2 text-sm font-medium text-dfxBlue-800"
>
Cancel
</button>
<button type="button" className="rounded-md bg-dfxRed-100 px-4 py-2 text-sm font-semibold text-white">
Continue
</button>
</div>
<button
type="button"
className="self-start rounded-md bg-dfxRed-100 px-4 py-2 text-sm font-semibold text-white"
>
Continue
</button>
</div>
);
}

function SampleDialogContent(): JSX.Element {
return (
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md mx-auto w-full">
<h2 className="text-lg font-semibold text-dfxBlue-800 mb-3 text-left">Confirm action</h2>
<p className="text-sm text-dfxBlue-800 mb-6 text-left">
The dialog variant is used for confirmations and short compliance flows. The card sits centered on a translucent
backdrop.
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md mx-auto w-full text-dfxBlue-800">
<h2 className="text-lg font-semibold mb-3 text-left">Refund transaction</h2>
<p className="text-sm mb-4 text-left">
This will return the full amount to the sender via SEPA. The transaction cannot be undone.
</p>
<dl className="grid grid-cols-2 gap-y-1 text-sm mb-6 rounded-md bg-dfxGray-300 p-3">
<dt className="text-dfxGray-800">Amount</dt>
<dd className="text-right font-medium">CHF 1,250.00</dd>
<dt className="text-dfxGray-800">Recipient IBAN</dt>
<dd className="text-right font-mono text-xs">CH00 0000 0000 0000 0000 0</dd>
<dt className="text-dfxGray-800">Reference</dt>
<dd className="text-right text-xs">TX-2026-04-1893</dd>
</dl>
<div className="flex justify-end gap-2">
<button type="button" className="rounded-md border border-dfxGray-500 px-4 py-2 text-sm text-dfxBlue-800">
<button
type="button"
className="rounded-md border border-dfxGray-500 px-4 py-2 text-sm font-medium text-dfxBlue-800"
>
Cancel
</button>
<button type="button" className="rounded-md bg-dfxRed-100 px-4 py-2 text-sm font-semibold text-white">
Confirm
Confirm refund
</button>
</div>
</div>
Expand Down