feat(shared): add Modal component

Native <dialog>+showModal() wrapper with imperative open()/close() via ref,
body scroll lock, and backdrop-click/keyboard-close behaviors. Exposes
onCancel and onBackdropClose escape hatches for consumers that need to
wrap the close path (e.g. in a view transition).
This commit is contained in:
Ilia Mashkov
2026-05-23 09:50:42 +03:00
parent c4002ebb4f
commit 82933dedf8
5 changed files with 324 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export { Modal, type ModalHandle } from './ui/Modal';
+56
View File
@@ -0,0 +1,56 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { useRef } from 'react';
import { Button } from '../../Button';
import { Modal, type ModalHandle } from './Modal';
const meta: Meta<typeof Modal> = {
title: 'Shared/Modal',
component: Modal,
};
export default meta;
type Story = StoryObj<typeof Modal>;
/**
* Imperative trigger — Storybook can't drive a ref-based API directly, so each
* story renders its own trigger button that calls `modalRef.current?.open()`.
*/
function TriggerWrapper({ className, children }: { className?: string; children: React.ReactNode }) {
const modalRef = useRef<ModalHandle>(null);
return (
<div className="p-8">
<Button onClick={() => modalRef.current?.open()}>Open modal</Button>
<Modal ref={modalRef} aria-label="Story modal" className={className}>
<div className="p-8 max-w-md">
{children}
<div className="mt-4">
<Button variant="outline" size="sm" onClick={() => modalRef.current?.close()}>
Close
</Button>
</div>
</div>
</Modal>
</div>
);
}
export const Default: Story = {
render: () => (
<TriggerWrapper className="bg-cream brutal-border">
<h3 className="mb-2">Default modal</h3>
<p>Native dialog with scroll lock, backdrop-click close, and ESC close.</p>
</TriggerWrapper>
),
};
export const Brutalist: Story = {
render: () => (
<TriggerWrapper className="bg-blue text-cream brutal-border">
<h3 className="mb-2 text-cream">Brutalist modal</h3>
<p className="text-cream">
Blue background, hard border. Style is fully driven by the className you pass Modal stays neutral.
</p>
</TriggerWrapper>
),
};
+145
View File
@@ -0,0 +1,145 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { createRef } from 'react';
import { Modal, type ModalHandle } from './Modal';
// jsdom does not implement dialog methods — mock them
beforeAll(() => {
HTMLDialogElement.prototype.showModal = vi.fn();
HTMLDialogElement.prototype.close = vi.fn();
});
beforeEach(() => {
vi.clearAllMocks();
document.body.style.overflow = '';
});
describe('Modal', () => {
describe('rendering', () => {
it('renders children', () => {
render(<Modal aria-label="Test">child content</Modal>);
expect(screen.getByText('child content')).toBeInTheDocument();
});
it('forwards className to dialog element', () => {
render(
<Modal aria-label="Test" className="extra-class">
x
</Modal>,
);
expect(document.querySelector('dialog')).toHaveClass('extra-class');
});
it('sets aria-label on dialog', () => {
render(<Modal aria-label="My modal">x</Modal>);
expect(document.querySelector('dialog')).toHaveAttribute('aria-label', 'My modal');
});
});
describe('imperative API', () => {
it('open() calls showModal', () => {
const ref = createRef<ModalHandle>();
render(
<Modal ref={ref} aria-label="Test">
x
</Modal>,
);
ref.current?.open();
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledOnce();
});
it('close() calls dialog.close', () => {
const ref = createRef<ModalHandle>();
render(
<Modal ref={ref} aria-label="Test">
x
</Modal>,
);
ref.current?.close();
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledOnce();
});
});
describe('scroll lock', () => {
it('locks body scroll on open()', () => {
const ref = createRef<ModalHandle>();
render(
<Modal ref={ref} aria-label="Test">
x
</Modal>,
);
ref.current?.open();
expect(document.body.style.overflow).toBe('hidden');
});
it('restores body scroll when the dialog close event fires', () => {
const ref = createRef<ModalHandle>();
render(
<Modal ref={ref} aria-label="Test">
x
</Modal>,
);
ref.current?.open();
const dialog = document.querySelector('dialog') as HTMLDialogElement;
fireEvent(dialog, new Event('close'));
expect(document.body.style.overflow).toBe('');
});
});
describe('backdrop interaction', () => {
it('closes on backdrop click (dialog element itself)', () => {
render(<Modal aria-label="Test">x</Modal>);
const dialog = document.querySelector('dialog') as HTMLDialogElement;
fireEvent.click(dialog, { target: dialog });
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledOnce();
});
it('does not close on click inside content', () => {
render(
<Modal aria-label="Test">
<span>inner</span>
</Modal>,
);
fireEvent.click(screen.getByText('inner'));
expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled();
});
it('routes backdrop click through onBackdropClose when provided (skips default close)', () => {
const onBackdropClose = vi.fn();
render(
<Modal aria-label="Test" onBackdropClose={onBackdropClose}>
x
</Modal>,
);
const dialog = document.querySelector('dialog') as HTMLDialogElement;
fireEvent.click(dialog, { target: dialog });
expect(onBackdropClose).toHaveBeenCalledOnce();
expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled();
});
});
describe('callbacks', () => {
it('invokes onClose when close event fires', () => {
const onClose = vi.fn();
render(
<Modal aria-label="Test" onClose={onClose}>
x
</Modal>,
);
const dialog = document.querySelector('dialog') as HTMLDialogElement;
fireEvent(dialog, new Event('close'));
expect(onClose).toHaveBeenCalledOnce();
});
it('invokes onCancel when cancel event fires', () => {
const onCancel = vi.fn();
render(
<Modal aria-label="Test" onCancel={onCancel}>
x
</Modal>,
);
const dialog = document.querySelector('dialog') as HTMLDialogElement;
fireEvent(dialog, new Event('cancel'));
expect(onCancel).toHaveBeenCalledOnce();
});
});
});
+121
View File
@@ -0,0 +1,121 @@
'use client';
import {
type DialogHTMLAttributes,
forwardRef,
type KeyboardEvent,
type MouseEvent,
useEffect,
useImperativeHandle,
useRef,
} from 'react';
import { cn } from '$shared/lib';
export type ModalHandle = {
/**
* Opens the dialog as a modal and locks body scroll.
*/
open: () => void;
/**
* Closes the dialog. Body scroll is restored on the native `close` event,
* so any close path (ESC, backdrop, this method) restores it.
*/
close: () => void;
};
type Props = DialogHTMLAttributes<HTMLDialogElement> & {
/**
* Called when the user activates the backdrop (click or Enter/Space).
* Replaces the default close — useful when the close path must be wrapped
* (e.g. in a view transition). When omitted, the dialog closes itself.
*/
onBackdropClose?: () => void;
};
/**
* Thin wrapper over native `<dialog>` with `showModal()`. Locks body scroll
* while open, restores it on any close path, and treats backdrop clicks /
* Enter|Space on the backdrop as a close intent. Style the backdrop externally
* via a className on this component + a `::backdrop` selector.
*
* All standard dialog attributes pass through — `aria-label`, `onClose`,
* `onCancel`, etc. Use `onCancel` with `e.preventDefault()` to intercept ESC
* (e.g. to route close through a view transition wrapper).
*/
export const Modal = forwardRef<ModalHandle, Props>(function Modal(
{ className, onClick, onKeyUp, onBackdropClose, children, ...rest },
ref,
) {
const dialogRef = useRef<HTMLDialogElement>(null);
/* Either run consumer-supplied close path (e.g. view-transition-wrapped) or
* fall back to closing the dialog directly. Shared between click and keyboard
* backdrop activation. */
function triggerBackdropClose() {
if (onBackdropClose) {
onBackdropClose();
} else {
dialogRef.current?.close();
}
}
useImperativeHandle(ref, () => ({
open: () => {
document.body.style.overflow = 'hidden';
dialogRef.current?.showModal();
},
close: () => {
dialogRef.current?.close();
},
}));
useEffect(() => {
const el = dialogRef.current;
if (!el) {
return;
}
/* Scroll restore lives on the native event listener (not a React onClose
* prop) so it can't be unintentionally overridden by a caller that passes
* their own onClose. Both fire for the same close event. */
const handleClose = () => {
document.body.style.overflow = '';
};
el.addEventListener('close', handleClose);
return () => el.removeEventListener('close', handleClose);
}, []);
/**
* Closes the dialog when the user clicks the backdrop area directly.
* Target===currentTarget distinguishes the <dialog> element itself (the
* backdrop hit-area) from its content children. Caller's onClick still runs.
*/
function handleClick(e: MouseEvent<HTMLDialogElement>) {
if (e.target === e.currentTarget) {
triggerBackdropClose();
}
onClick?.(e);
}
/**
* Keyboard equivalent of backdrop click — Enter/Space on the backdrop area
* closes the dialog. ESC is handled natively by `showModal()`.
*/
function handleKeyUp(e: KeyboardEvent<HTMLDialogElement>) {
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
triggerBackdropClose();
}
onKeyUp?.(e);
}
return (
<dialog
ref={dialogRef}
{...rest}
className={cn('fixed inset-0 m-auto p-0 overflow-hidden', className)}
onClick={handleClick}
onKeyUp={handleKeyUp}
>
{children}
</dialog>
);
});
+1
View File
@@ -9,6 +9,7 @@ export { InlineSvg } from './InlineSvg';
export { Input, Textarea } from './Input'; export { Input, Textarea } from './Input';
export type { LinkVariant } from './Link'; export type { LinkVariant } from './Link';
export { Link } from './Link'; export { Link } from './Link';
export { Modal, type ModalHandle } from './Modal';
export { RichText } from './RichText'; export { RichText } from './RichText';
export type { ContainerSize, SectionBackground } from './Section'; export type { ContainerSize, SectionBackground } from './Section';
export { Container, Section } from './Section'; export { Container, Section } from './Section';