Skip to content
Clone
eeff292
148 lines 5.2 KB
import { useT } from '@app/i18n'
import { IconX as X } from '@tabler/icons-react'
import { type ReactNode, useEffect, useId, useRef } from 'react'
/**
* Feature-local dialog chrome (overlay + panel + Escape/outside-click close),
* matching `shared/ui/form-modal`'s look. It exists because the issue forms need
* multi-column pickers that the config-driven `FormModal` cannot lay out.
*/
export function Dialog({
title,
description,
open,
onClose,
width = 'max-w-[560px]',
panelClassName = '',
header,
children,
footer,
}: {
title: string
description?: string
open: boolean
onClose: () => void
width?: string
/** Extra classes for the panel (feature-specific chrome). */
panelClassName?: string
/** Replaces the default title block; the close button and the a11y name stay. */
header?: ReactNode
children: ReactNode
footer?: ReactNode
}) {
const { t } = useT()
const panel = useRef<HTMLDivElement>(null)
const closeRef = useRef(onClose)
const titleId = useId()
const descriptionId = 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()
}
if (event.key !== 'Tab') return
const controls = Array.from(
panel.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href], [tabindex="0"]',
) ?? [],
).filter((element) => element.getClientRects().length > 0)
const first = controls[0]
const last = controls.at(-1)
if (!first) {
event.preventDefault()
panel.current?.focus()
return
}
if (event.shiftKey && (document.activeElement === first || document.activeElement === panel.current)) {
event.preventDefault()
last?.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
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="issue-dialog-overlay 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}
aria-describedby={description ? descriptionId : undefined}
onClick={(event) => event.stopPropagation()}
className={`issue-dialog flex max-h-[86vh] w-full flex-col rounded-xl border-2 border-shell-hairline bg-shell-surface p-5 shadow-xl ${width} ${panelClassName}`}
>
<div className="issue-dialog-header mb-3 flex items-start justify-between gap-3">
{header ? (
<>
<h2 id={titleId} className="sr-only">
{title}
</h2>
{header}
</>
) : (
<div className="min-w-0">
<h2 id={titleId} className="text-[16px] font-semibold">
{title}
</h2>
{description ? (
<p id={descriptionId} className="mt-1 text-[12.5px] leading-[1.5] text-shell-ink-muted">
{description}
</p>
) : null}
</div>
)}
<button
type="button"
aria-label={t('issue.board.closeDialog')}
onClick={onClose}
className="issue-dialog-close shrink-0 text-shell-ink-muted transition-colors hover:text-shell-ink"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="issue-dialog-content min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">{children}</div>
{footer ? (
<div className="issue-dialog-footer mt-4 flex shrink-0 items-center justify-end gap-2">{footer}</div>
) : null}
</div>
</div>
)
}
export const fieldLabelClass = 'issue-field-label text-[12px] font-medium text-shell-ink'
export const fieldInputClass =
'issue-field-input 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 smallInputClass =
'issue-field-input issue-field-small h-7 w-full rounded-md border-2 border-shell-hairline bg-shell-field px-2 text-[12.5px] outline-none focus-visible:border-shell-ink'