Modal.tsx 3.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
  1. // Modal: controlled full-viewport dialog (create-workspace and similar).
  2. // The overlay portals to this document's body so ancestor stacking contexts
  3. // cannot leave sticky page controls above the mask. This is still an in-page
  4. // WebUI dialog; it never creates or targets another browser/native window.
  5. import { useEffect } from 'react'
  6. import type { ReactNode } from 'react'
  7. import { createPortal } from 'react-dom'
  8. import clsx from 'clsx'
  9. import { IconCloseOutline16 } from './icons/index.tsx'
  10. import css from './Modal.module.css'
  11. /**
  12. * Render a centered modal over a blurred page mask.
  13. * @param props.open - whether the dialog is showing.
  14. * @param props.onClose - Escape or mask click.
  15. * @param props.title - dialog heading (aria-label in every mode).
  16. * @param props.closeLabel - accessible close-button label.
  17. * @param props.description - optional supporting sentence under the title.
  18. * @param props.children - body (inputs, etc.).
  19. * @param props.footer - action row (Cancel / Create).
  20. * @param props.contentClassName - optional class for a scrollable content region.
  21. * @param props.headless - render children directly in the card (no default
  22. * header/close/body chrome) for dialogs whose figma frame owns its own
  23. * header structure; mask, card, Escape, and aria-label remain.
  24. * @param props.closeLabel - close-button aria label; the owner passes
  25. * localized copy (this package is cordis-free, so copy arrives via props).
  26. * @returns null when closed; otherwise the overlay tree.
  27. */
  28. export function Modal({
  29. open, onClose, title, closeLabel = 'Close', description, children, footer, className, contentClassName, headless = false,
  30. }: {
  31. open: boolean
  32. onClose: () => void
  33. title: string
  34. closeLabel?: string
  35. description?: string
  36. children?: ReactNode
  37. footer?: ReactNode
  38. className?: string
  39. contentClassName?: string
  40. headless?: boolean
  41. }) {
  42. useEffect(() => {
  43. if (!open) return
  44. const onKeyDown = (e: KeyboardEvent) => {
  45. if (e.key === 'Escape') onClose()
  46. }
  47. document.addEventListener('keydown', onKeyDown)
  48. return () => { document.removeEventListener('keydown', onKeyDown) }
  49. }, [open, onClose])
  50. if (!open) return null
  51. return createPortal((
  52. <div className={css.root} role="presentation">
  53. <div className={css.mask} aria-hidden="true" onClick={onClose} />
  54. <div
  55. className={clsx(css.dialog, className)}
  56. role="dialog"
  57. aria-modal="true"
  58. aria-label={title}
  59. >
  60. {headless
  61. ? children
  62. : (
  63. <>
  64. <div className={clsx(css.content, contentClassName)}>
  65. <div className={css.header}>
  66. <h2 className={css.title}>{title}</h2>
  67. <button type="button" className={css.close} aria-label={closeLabel} onClick={onClose}>
  68. <IconCloseOutline16 size={14} />
  69. </button>
  70. </div>
  71. {description !== undefined && description !== '' && (
  72. <p className={css.description}>{description}</p>
  73. )}
  74. {children !== undefined && <div className={css.body}>{children}</div>}
  75. </div>
  76. {footer !== undefined && <div className={css.footer}>{footer}</div>}
  77. </>
  78. )}
  79. </div>
  80. </div>
  81. ), document.body)
  82. }