| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283 |
- import { useEffect } from 'react'
- import type { ReactNode } from 'react'
- import { createPortal } from 'react-dom'
- import clsx from 'clsx'
- import { IconCloseOutline16 } from './icons/index.tsx'
- import css from './Modal.module.css'
- interface ModalBaseProps {
- open: boolean
- onClose: () => void
- title: string
- description?: string
- children?: ReactNode
- footer?: ReactNode
- className?: string
- contentClassName?: string
- }
- type ModalProps = ModalBaseProps & (
- | { headless: true; closeLabel?: never }
- | { headless?: false; closeLabel: string }
- )
- /**
- * Render a centered, body-portaled modal over a blurred page mask.
- * @param props.open - whether the dialog is showing.
- * @param props.onClose - Escape or mask click.
- * @param props.title - dialog heading (aria-label in every mode).
- * @param props.closeLabel - localized accessible close-button label.
- * @param props.description - optional supporting sentence under the title.
- * @param props.children - body (inputs, etc.).
- * @param props.footer - action row (Cancel / Create).
- * @param props.contentClassName - optional class for a scrollable content region.
- * @param props.headless - render children directly in the card (no default
- * header/close/body chrome); mask, card, Escape, and aria-label remain.
- * @returns null when closed; otherwise the overlay tree.
- */
- export function Modal({
- open, onClose, title, closeLabel, description, children, footer, className, contentClassName, headless = false,
- }: ModalProps) {
- useEffect(() => {
- if (!open) return
- const onKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape') onClose()
- }
- document.addEventListener('keydown', onKeyDown)
- return () => { document.removeEventListener('keydown', onKeyDown) }
- }, [open, onClose])
- if (!open) return null
- return createPortal((
- <div className={css.root} role="presentation">
- <div className={css.mask} aria-hidden="true" onClick={onClose} />
- <div
- className={clsx(css.dialog, className)}
- role="dialog"
- aria-modal="true"
- aria-label={title}
- >
- {headless
- ? children
- : (
- <>
- <div className={clsx(css.content, contentClassName)}>
- <div className={css.header}>
- <h2 className={css.title}>{title}</h2>
- <button type="button" className={css.close} aria-label={closeLabel} onClick={onClose}>
- <IconCloseOutline16 size={14} />
- </button>
- </div>
- {description !== undefined && description !== '' && (
- <p className={css.description}>{description}</p>
- )}
- {children !== undefined && <div className={css.body}>{children}</div>}
- </div>
- {footer !== undefined && <div className={css.footer}>{footer}</div>}
- </>
- )}
- </div>
- </div>
- ), document.body)
- }
|