mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
0dd07befa7
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.
285 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|