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:
@@ -0,0 +1 @@
|
||||
export { Modal, type ModalHandle } from './ui/Modal';
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export { InlineSvg } from './InlineSvg';
|
||||
export { Input, Textarea } from './Input';
|
||||
export type { LinkVariant } from './Link';
|
||||
export { Link } from './Link';
|
||||
export { Modal, type ModalHandle } from './Modal';
|
||||
export { RichText } from './RichText';
|
||||
export type { ContainerSize, SectionBackground } from './Section';
|
||||
export { Container, Section } from './Section';
|
||||
|
||||
Reference in New Issue
Block a user