import { useEffect, useRef, useState, type ButtonHTMLAttributes, type ClipboardEvent, type CSSProperties, type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react' import { createPortal } from 'react-dom' import { Trash2, X } from 'lucide-react' import { useTranslation } from '../../i18n' import { lockBodyScroll } from '../../utils/bodyScrollLock' import { Tooltip } from './Tooltip' /** A font size that follows the user's text size setting for its tier. */ export const fs = (px: number, tier: 'caption' | 'body' | 'subtitle' = 'caption'): CSSProperties => ({ fontSize: `calc(${px}px * var(--fs-scale-${tier}, 1))` }) const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint' const BACKDROP = 'trek-modal-backdrop trek-backdrop-enter fixed inset-0 z-[10000] flex justify-center bg-[rgba(15,23,42,0.5)] px-4' // theme-lint-disable: the dim every shared/Modal draws const PANEL_WIDTH = { narrow: 'max-w-[520px]', detail: 'max-w-[600px]', editor: 'max-w-[720px]', wide: 'max-w-[960px]', xwide: 'max-w-[1280px]' } as const const BODY = 'flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-6 pb-6 pt-5' /** The head band of a dialog that belongs to no booking: a faint wash of the accent. */ export const NEUTRAL_TINT = 'color-mix(in srgb, var(--accent) 7%, transparent)' export interface DialogShellProps { /** A dialog that stays mounted passes its open state; one mounted only while shown leaves it out. */ open?: boolean onClose: () => void /** The id DialogHeader gets as labelId. */ labelledBy: string /** 520 px for a single question, 600 px for a detail, 720 px for an editor, 960 px for one in two columns. */ width?: keyof typeof PANEL_WIDTH /** 'top' pins the upper edge, so a dialog whose content changes height does not jump. */ align?: 'center' | 'top' /** True while a question opened from here is being answered: Escape and the backdrop leave the dialog alone. */ blocked?: boolean header: ReactNode footer?: ReactNode /** Makes the scrolling body a form. */ onSubmit?: (e: FormEvent) => void /** The scrolling body; a dialog that is all head and foot leaves it out. */ children?: ReactNode /** Replaces the body's padded column, for content that fills the panel (a document preview). */ bodyClassName?: string /** A paste anywhere in the panel, head band included (a note takes pasted images from its title too). */ onPaste?: (e: ClipboardEvent) => void } /** * The frame of the planner's dialogs: dimmed backdrop, the rounded panel, a * head band that stays put, a body that scrolls and a bar that stays in reach. */ export function DialogShell({ open = true, ...frame }: DialogShellProps) { return open ? : null } function DialogFrame({ onClose, labelledBy, width = 'detail', align = 'center', blocked = false, header, footer, onSubmit, children, bodyClassName, onPaste }: Omit) { const panelRef = useRef(null) const pressedOn = useRef(null) // Read while rendering, before a field inside can take the focus with autoFocus. const [focusedBefore] = useState(() => document.activeElement) useEffect(() => { if (blocked) return const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !e.defaultPrevented) onClose() } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, [blocked, onClose]) // The page stays put behind the dialog, and whatever opened it gets the focus back. useEffect(() => { const panel = panelRef.current const focusedInside = !!panel && panel.contains(document.activeElement) const opener = focusedInside ? focusedBefore : document.activeElement const release = lockBodyScroll() if (!focusedInside) panel?.focus() return () => { release() if (opener instanceof HTMLElement) opener.focus() } }, [focusedBefore]) const bodyClass = bodyClassName ?? BODY const body = children == null ? null : onSubmit ?
{children}
:
{children}
return createPortal(
{ pressedOn.current = e.target }} onClick={e => { if (!blocked && e.target === e.currentTarget && pressedOn.current === e.currentTarget) onClose() pressedOn.current = null }} > {/* No aria-modal: the pickers inside open their lists in a portal on the body, and a screen reader would hide those along with the page. */}
{header} {body} {footer}
, document.body, ) } /** The raised square on the left of a head band, holding the dialog's icon. */ export function DialogTile({ children }: { children: ReactNode }) { return {children} } export interface HeaderTitleInput { value: string onChange: (value: string) => void /** The field's accessible name, e.g. the old "Title *" label. */ label: string placeholder?: string autoFocus?: boolean required?: boolean maxLength?: number onKeyDown?: (e: ReactKeyboardEvent) => void onBlur?: () => void } export interface DialogHeaderProps { /** The raised tile on the left, usually a DialogTile around an icon. */ tile: ReactNode /** The band's background: a status tint, or NEUTRAL_TINT. */ tint: string /** Same value as the shell's labelledBy. It lands on the eyebrow when there is one, otherwise on the title. */ labelId: string onClose: () => void /** A small line above the title; editors keep their dialog title ("Edit transport") here. */ eyebrow?: ReactNode /** A title to read. With onTitleClick it becomes a button (a rename). */ title?: ReactNode onTitleClick?: () => void titleTooltip?: string /** A title to type into, in place of title. */ titleInput?: HeaderTitleInput sub?: ReactNode /** Lets a sentence of a sub (a hint) wrap instead of ending in an ellipsis like a route does. */ subWraps?: boolean /** The pill row under the title. */ pills?: ReactNode } /** The head band: tile, eyebrow, the title to read or type, a sub line and a row of pills. */ export function DialogHeader(p: DialogHeaderProps) { const { t } = useTranslation() const titleId = p.eyebrow ? undefined : p.labelId const titleStyle = fs(20, 'subtitle') let title: ReactNode if (p.titleInput) { const f = p.titleInput title = ( f.onChange(e.target.value)} onKeyDown={e => { // Enter is spent here: once it has closed the dialog and the focus is // back on the opener, the rest of the key press must not click it again. if (e.key === 'Enter') e.preventDefault() f.onKeyDown?.(e) }} onBlur={f.onBlur} aria-label={f.label} placeholder={f.placeholder} autoFocus={f.autoFocus} required={f.required} maxLength={f.maxLength} className="-mx-2 block w-[calc(100%+16px)] rounded-[10px] border-0 bg-transparent px-2 py-0.5 font-bold tracking-[-0.01em] text-content outline-none placeholder:font-semibold placeholder:text-content-faint hover:bg-surface-card focus:bg-surface-card focus:shadow-sm dark:bg-transparent dark:hover:bg-surface-card dark:focus:bg-surface-card" style={titleStyle} /> ) } else if (p.onTitleClick) { title = ( ) } else { title =

{p.title}

} return (
{/* With nothing under it the title sits level with the middle of the tile. */}
{p.tile}
{p.eyebrow && (

{p.eyebrow}

)} {title} {p.sub &&
{p.sub}
}
{p.pills &&
{p.pills}
}
) } /** A pill on the head band's row; wrap text and an icon in a span with it. */ export const PILL = 'inline-flex flex-none items-center gap-1.5 rounded-full bg-surface-card px-2.5 py-1 font-semibold text-content shadow-sm' /** A labelled block of the body, with room for an action on the right of its label. */ export function DialogSection({ label, action, className, children }: { label: ReactNode; action?: ReactNode; className?: string; children: ReactNode }) { return (
{label}
{action && {action}}
{children}
) } /** The bar under the body. Put a FooterSpacer between what sits left and what sits right. */ export function DialogFooter({ children }: { children: ReactNode }) { return
{children}
} export function FooterSpacer() { return } const BUTTON_LOOK = { secondary: 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50', active: 'inline-flex items-center gap-1.5 rounded-[10px] bg-accent px-3.5 py-2 font-medium text-accent-text disabled:cursor-default disabled:opacity-50', primary: 'inline-flex items-center gap-1.5 rounded-[10px] bg-accent px-4 py-2 font-medium text-accent-text hover:opacity-90 disabled:cursor-default disabled:opacity-50', } as const export type DialogButtonProps = Omit, 'className' | 'style'> & { variant?: 'primary' | 'secondary' /** A secondary button that is switched on (shown on the map) takes the accent fill. */ active?: boolean icon?: ReactNode } /** A footer button: white on a hairline, or the accent fill for the one that finishes the dialog. */ export function DialogButton({ variant = 'secondary', active = false, icon, children, type = 'button', ...rest }: DialogButtonProps) { const look = variant === 'primary' ? BUTTON_LOOK.primary : active ? BUTTON_LOOK.active : BUTTON_LOOK.secondary return ( ) } /** The square delete button of the footer, named by its tooltip. */ export function DeleteButton({ onClick, label, disabled }: { onClick: () => void; label?: string; disabled?: boolean }) { const { t } = useTranslation() const name = label ?? t('common.delete') return ( ) }