diff --git a/src/shared/ui/Modal/index.ts b/src/shared/ui/Modal/index.ts new file mode 100644 index 0000000..506d976 --- /dev/null +++ b/src/shared/ui/Modal/index.ts @@ -0,0 +1 @@ +export { Modal, type ModalHandle } from './ui/Modal'; diff --git a/src/shared/ui/Modal/ui/Modal.stories.tsx b/src/shared/ui/Modal/ui/Modal.stories.tsx new file mode 100644 index 0000000..e5364c4 --- /dev/null +++ b/src/shared/ui/Modal/ui/Modal.stories.tsx @@ -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 = { + title: 'Shared/Modal', + component: Modal, +}; + +export default meta; + +type Story = StoryObj; + +/** + * 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(null); + return ( +
+ + +
+ {children} +
+ +
+
+
+
+ ); +} + +export const Default: Story = { + render: () => ( + +

Default modal

+

Native dialog with scroll lock, backdrop-click close, and ESC close.

+
+ ), +}; + +export const Brutalist: Story = { + render: () => ( + +

Brutalist modal

+

+ Blue background, hard border. Style is fully driven by the className you pass — Modal stays neutral. +

+
+ ), +}; diff --git a/src/shared/ui/Modal/ui/Modal.test.tsx b/src/shared/ui/Modal/ui/Modal.test.tsx new file mode 100644 index 0000000..edcc911 --- /dev/null +++ b/src/shared/ui/Modal/ui/Modal.test.tsx @@ -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(child content); + expect(screen.getByText('child content')).toBeInTheDocument(); + }); + + it('forwards className to dialog element', () => { + render( + + x + , + ); + expect(document.querySelector('dialog')).toHaveClass('extra-class'); + }); + + it('sets aria-label on dialog', () => { + render(x); + expect(document.querySelector('dialog')).toHaveAttribute('aria-label', 'My modal'); + }); + }); + + describe('imperative API', () => { + it('open() calls showModal', () => { + const ref = createRef(); + render( + + x + , + ); + ref.current?.open(); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledOnce(); + }); + + it('close() calls dialog.close', () => { + const ref = createRef(); + render( + + x + , + ); + ref.current?.close(); + expect(HTMLDialogElement.prototype.close).toHaveBeenCalledOnce(); + }); + }); + + describe('scroll lock', () => { + it('locks body scroll on open()', () => { + const ref = createRef(); + render( + + x + , + ); + ref.current?.open(); + expect(document.body.style.overflow).toBe('hidden'); + }); + + it('restores body scroll when the dialog close event fires', () => { + const ref = createRef(); + render( + + x + , + ); + 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(x); + 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( + + inner + , + ); + 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( + + x + , + ); + 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( + + x + , + ); + 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( + + x + , + ); + const dialog = document.querySelector('dialog') as HTMLDialogElement; + fireEvent(dialog, new Event('cancel')); + expect(onCancel).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/src/shared/ui/Modal/ui/Modal.tsx b/src/shared/ui/Modal/ui/Modal.tsx new file mode 100644 index 0000000..4ac1b68 --- /dev/null +++ b/src/shared/ui/Modal/ui/Modal.tsx @@ -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 & { + /** + * 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 `` 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(function Modal( + { className, onClick, onKeyUp, onBackdropClose, children, ...rest }, + ref, +) { + const dialogRef = useRef(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 element itself (the + * backdrop hit-area) from its content children. Caller's onClick still runs. + */ + function handleClick(e: MouseEvent) { + 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) { + if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { + triggerBackdropClose(); + } + onKeyUp?.(e); + } + + return ( + + {children} + + ); +}); diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 1f2e162..b8ec55c 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -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';