Skip to content
Clone
eeff292
103 lines 3.5 KB
import { useT } from '@app/i18n'
import { IconX as X } from '@tabler/icons-react'
import { type ReactNode, useEffect, useId, useRef } from 'react'
/**
* Generic modal chrome (overlay + panel + Escape/outside-click close), shared
* by feature areas that need layout the config-driven FormModal cannot express.
*/
export function Modal({
title,
description,
open,
onClose,
width = 'max-w-[620px]',
children,
footer,
}: {
title: string
description?: string
open: boolean
onClose: () => void
width?: string
children: ReactNode
footer?: ReactNode
}) {
const { t } = useT()
const panel = useRef<HTMLDivElement>(null)
const closeRef = useRef(onClose)
const titleId = useId()
useEffect(() => {
closeRef.current = onClose
}, [onClose])
useEffect(() => {
if (!open) return
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const frame = requestAnimationFrame(() => {
const firstField = panel.current?.querySelector<HTMLElement>(
'input:not([disabled]), textarea:not([disabled]), select:not([disabled])',
)
;(firstField ?? panel.current)?.focus()
})
const onKey = (event: KeyboardEvent) => {
if (event.defaultPrevented) return
if (event.key === 'Escape') {
event.preventDefault()
closeRef.current()
}
}
window.addEventListener('keydown', onKey)
return () => {
cancelAnimationFrame(frame)
document.body.style.overflow = previousOverflow
window.removeEventListener('keydown', onKey)
if (previousFocus?.isConnected) previousFocus.focus()
}
}, [open])
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div
ref={panel}
tabIndex={-1}
role="dialog"
aria-modal
aria-labelledby={titleId}
onClick={(event) => event.stopPropagation()}
className={`flex max-h-[86vh] w-full flex-col rounded-xl border-2 border-shell-hairline bg-shell-surface p-5 shadow-xl ${width}`}
>
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 id={titleId} className="text-[16px] font-semibold">
{title}
</h2>
{description ? (
<p className="mt-1 text-[12.5px] leading-[1.5] text-shell-ink-muted">{description}</p>
) : null}
</div>
<button
type="button"
aria-label={t('ui.close')}
onClick={onClose}
className="shrink-0 text-shell-ink-muted transition-colors hover:text-shell-ink"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">{children}</div>
{footer ? <div className="mt-4 flex shrink-0 items-center justify-end gap-2">{footer}</div> : null}
</div>
</div>
)
}
export const dialogInputClass =
'h-10 w-full rounded-md border-2 border-shell-hairline bg-shell-field px-3 text-[13.5px] text-shell-ink outline-none transition-colors focus:border-shell-ink'
export const dialogTextareaClass =
'w-full resize-y rounded-md border-2 border-shell-hairline bg-shell-field px-3 py-2 text-[13.5px] leading-6 outline-none transition-colors focus:border-shell-ink'
export const dialogLabelClass = 'mb-1.5 block text-[12px] font-medium text-shell-ink'