# Conflicts: # packages/client/ui-command/src/client/PopupSelectView.tsx # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/input-matrix.spec.tsx # packages/client/ui-conversation/tests/input-scenarios.spec.tsx # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/client/ui-primitives/src/Modal.tsx # packages/client/ui-primitives/tests/atoms.spec.tsx
87 lines
3.3 KiB
TypeScript
87 lines
3.3 KiB
TypeScript
// Modal: controlled full-viewport dialog (create-workspace and similar).
|
|
// The overlay portals to this document's body so ancestor stacking contexts
|
|
// cannot leave sticky page controls above the mask. This is still an in-page
|
|
// WebUI dialog; it never creates or targets another browser/native window.
|
|
|
|
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'
|
|
|
|
/**
|
|
* Render a centered 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 - 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) for dialogs whose figma frame owns its own
|
|
* header structure; mask, card, Escape, and aria-label remain.
|
|
* @param props.closeLabel - close-button aria label; the owner passes
|
|
* localized copy (this package is cordis-free, so copy arrives via props).
|
|
* @returns null when closed; otherwise the overlay tree.
|
|
*/
|
|
export function Modal({
|
|
open, onClose, title, closeLabel = 'Close', description, children, footer, className, contentClassName, headless = false,
|
|
}: {
|
|
open: boolean
|
|
onClose: () => void
|
|
title: string
|
|
closeLabel?: string
|
|
description?: string
|
|
children?: ReactNode
|
|
footer?: ReactNode
|
|
className?: string
|
|
contentClassName?: string
|
|
headless?: boolean
|
|
}) {
|
|
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)
|
|
}
|