Modal.tsx 2.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
  1. import { useEffect } from 'react'
  2. import type { ReactNode } from 'react'
  3. import { createPortal } from 'react-dom'
  4. import clsx from 'clsx'
  5. import { IconCloseOutline16 } from './icons/index.tsx'
  6. import css from './Modal.module.css'
  7. interface ModalBaseProps {
  8. open: boolean
  9. onClose: () => void
  10. title: string
  11. description?: string
  12. children?: ReactNode
  13. footer?: ReactNode
  14. className?: string
  15. contentClassName?: string
  16. }
  17. type ModalProps = ModalBaseProps & (
  18. | { headless: true; closeLabel?: never }
  19. | { headless?: false; closeLabel: string }
  20. )
  21. /**
  22. * Render a centered, body-portaled modal over a blurred page mask.
  23. * @param props.open - whether the dialog is showing.
  24. * @param props.onClose - Escape or mask click.
  25. * @param props.title - dialog heading (aria-label in every mode).
  26. * @param props.closeLabel - localized accessible close-button label.
  27. * @param props.description - optional supporting sentence under the title.
  28. * @param props.children - body (inputs, etc.).
  29. * @param props.footer - action row (Cancel / Create).
  30. * @param props.contentClassName - optional class for a scrollable content region.
  31. * @param props.headless - render children directly in the card (no default
  32. * header/close/body chrome); mask, card, Escape, and aria-label remain.
  33. * @returns null when closed; otherwise the overlay tree.
  34. */
  35. export function Modal({
  36. open, onClose, title, closeLabel, description, children, footer, className, contentClassName, headless = false,
  37. }: ModalProps) {
  38. useEffect(() => {
  39. if (!open) return
  40. const onKeyDown = (e: KeyboardEvent) => {
  41. if (e.key === 'Escape') onClose()
  42. }
  43. document.addEventListener('keydown', onKeyDown)
  44. return () => { document.removeEventListener('keydown', onKeyDown) }
  45. }, [open, onClose])
  46. if (!open) return null
  47. return createPortal((
  48. <div className={css.root} role="presentation">
  49. <div className={css.mask} aria-hidden="true" onClick={onClose} />
  50. <div
  51. className={clsx(css.dialog, className)}
  52. role="dialog"
  53. aria-modal="true"
  54. aria-label={title}
  55. >
  56. {headless
  57. ? children
  58. : (
  59. <>
  60. <div className={clsx(css.content, contentClassName)}>
  61. <div className={css.header}>
  62. <h2 className={css.title}>{title}</h2>
  63. <button type="button" className={css.close} aria-label={closeLabel} onClick={onClose}>
  64. <IconCloseOutline16 size={14} />
  65. </button>
  66. </div>
  67. {description !== undefined && description !== '' && (
  68. <p className={css.description}>{description}</p>
  69. )}
  70. {children !== undefined && <div className={css.body}>{children}</div>}
  71. </div>
  72. {footer !== undefined && <div className={css.footer}>{footer}</div>}
  73. </>
  74. )}
  75. </div>
  76. </div>
  77. ), document.body)
  78. }