Files
TREK/client/src/components/shared/DialogShell.tsx
T
Maurice 0dd07befa7 feat(planner): bring the plan tab and every trip dialog into the new look
The Plan tab was the last trip tab still in the old design. It now reads
like Bookings, Costs, Lists, Files and Collab, without losing a function:

- Day plan: each day is a card with a head band (day tile with weather,
  stay and rental pills, a "+" menu for place, stay, transport, transit
  and note), rows with "..." menus, white badges and a white route bar.
- Places column: add, search and filters in three rows, a show dropdown,
  category and rating filters, and a selection bar at the foot.
- Place inspector, day details and the stay editor on the shared dialog
  frame; the place form in the editor look with the name in the head band.
- Clicking a booking or transport anywhere on the plan opens the booking
  detail popup first; Edit leads to the editor. The popup is one host
  shared with the Bookings and Transports tabs.
- Files and Collab dialogs, notes, polls, links and chat moved to the same
  frame; shared DialogShell and dialogParts hold the building blocks.
- The map hover card shows the place photo and follows the theme.

Every new and reworded string is translated into all locales, and a few
older English leftovers (help center, cost split, import hints) with them.
Phone screens keep their own look.
2026-09-27 21:00:19 +02:00

285 lines
13 KiB
TypeScript

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<HTMLFormElement>) => 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<HTMLDivElement>) => 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 ? <DialogFrame {...frame} /> : null
}
function DialogFrame({ onClose, labelledBy, width = 'detail', align = 'center', blocked = false, header, footer, onSubmit, children, bodyClassName, onPaste }: Omit<DialogShellProps, 'open'>) {
const panelRef = useRef<HTMLDivElement>(null)
const pressedOn = useRef<EventTarget | null>(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
? <form onSubmit={onSubmit} className={bodyClass}>{children}</form>
: <div className={bodyClass}>{children}</div>
return createPortal(
<div
role="presentation"
className={`${BACKDROP} ${align === 'top' ? 'items-start' : 'items-center'}`}
style={{ paddingTop: 40, paddingBottom: 'calc(40px + var(--bottom-nav-h, 0px))' }}
// Only a press that starts and ends on the backdrop closes: a selection
// dragged out of a field must not throw the dialog away.
onMouseDown={e => { 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. */}
<div
ref={panelRef}
role="dialog"
aria-labelledby={labelledBy}
tabIndex={-1}
onPaste={onPaste}
className={`trek-modal-enter flex max-h-full w-full ${PANEL_WIDTH[width]} flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl outline-none`}
>
{header}
{body}
{footer}
</div>
</div>,
document.body,
)
}
/** The raised square on the left of a head band, holding the dialog's icon. */
export function DialogTile({ children }: { children: ReactNode }) {
return <span className="grid h-[46px] w-[46px] flex-none place-items-center rounded-[14px] bg-surface-card shadow-sm">{children}</span>
}
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<HTMLInputElement>) => 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 = (
<input
id={titleId}
type="text"
value={f.value}
onChange={e => 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 = (
<Tooltip label={p.titleTooltip ?? ''}>
<button id={titleId} type="button" onClick={p.onTitleClick}
className="block max-w-full truncate text-left font-bold tracking-[-0.01em] text-content hover:underline hover:decoration-edge" style={titleStyle}>
{p.title}
</button>
</Tooltip>
)
} else {
title = <h2 id={titleId} className="m-0 truncate font-bold tracking-[-0.01em] text-content" style={titleStyle}>{p.title}</h2>
}
return (
<header className="flex-none px-6 pb-4 pt-5" style={{ background: p.tint }}>
{/* With nothing under it the title sits level with the middle of the tile. */}
<div className={`flex gap-3.5 ${p.sub ? 'items-start' : 'items-center'}`}>
{p.tile}
<div className={`min-w-0 flex-1 ${p.sub ? 'pt-0.5' : ''}`}>
{p.eyebrow && (
<h2 id={p.labelId} className="m-0 mb-0.5 truncate font-geist font-bold uppercase tracking-[.08em] text-content-muted" style={fs(10)}>
{p.eyebrow}
</h2>
)}
{title}
{p.sub && <div className={`mt-0.5 font-geist text-content-muted ${p.subWraps ? 'break-words' : 'truncate'}`} style={fs(12.5)}>{p.sub}</div>}
</div>
<Tooltip label={t('common.close')}>
<button type="button" onClick={p.onClose} aria-label={t('common.close')}
className="grid h-9 w-9 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm hover:text-content">
<X size={16} strokeWidth={2.2} />
</button>
</Tooltip>
</div>
{p.pills && <div className="mt-4 flex flex-wrap items-center gap-2" style={fs(12, 'body')}>{p.pills}</div>}
</header>
)
}
/** 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 (
<section className={className}>
<div className="mb-2 flex items-center gap-2">
<div className={EYEBROW} style={fs(9.5)}>{label}</div>
{action && <span className="ml-auto">{action}</span>}
</div>
{children}
</section>
)
}
/** The bar under the body. Put a FooterSpacer between what sits left and what sits right. */
export function DialogFooter({ children }: { children: ReactNode }) {
return <footer className="flex flex-none items-center gap-2 border-t border-edge-faint px-6 py-3.5">{children}</footer>
}
export function FooterSpacer() {
return <span className="flex-1" />
}
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<ButtonHTMLAttributes<HTMLButtonElement>, '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 (
<button {...rest} type={type} className={look} style={fs(13, 'body')}>
{icon}{children}
</button>
)
}
/** 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 (
<Tooltip label={name} placement="top">
<button type="button" onClick={onClick} disabled={disabled} aria-label={name}
className="grid h-9 w-9 place-items-center rounded-[10px] bg-surface-card text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:cursor-default disabled:opacity-50">
<Trash2 size={15} strokeWidth={2} />
</button>
</Tooltip>
)
}