mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
ba70ad28b8
Arabic runs the app right to left, but margins, paddings, insets, text alignment, borders and corner radii were written for the left side, so icons sat against the wrong edge of their labels, indents and badges pointed the wrong way and amount columns aligned left. They now use the logical forms (ms-, ps-, start-, text-start, marginInlineStart, insetInlineEnd and their CSS equivalents), which are the very same sides in a left to right language. Geometry stays physical on purpose: maps, positions measured from the pointer or a bounding box, time axes, sliders, transforms and print output. Amounts drawn as several spans keep their order in a right to left row, the time picker keeps hours before minutes, and two switches whose thumb slid out of its track in Arabic now move the right way. lint:rtl counts what is still physical per file against a baseline that may only shrink (1512 uses in 370 files before, 319 in 113 now), and CI runs it, so new code reaches for the logical forms.
327 lines
16 KiB
TypeScript
327 lines
16 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'
|
|
import { focusDialog, trapTab } from './dialogFocus'
|
|
import { useDiscardGuard } from './useDiscardGuard'
|
|
|
|
/** 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 editor's state, for the unsaved-changes question (#2253). Snapshotted at
|
|
* the first key or pointer press inside the panel, so whatever the dialog
|
|
* loaded on open counts as the starting point. A click on the backdrop or
|
|
* Escape then asks before throwing away a state that differs from it. Must be
|
|
* JSON-serialisable; pass counts for files and arrays for sets.
|
|
*/
|
|
discardGuard?: unknown
|
|
}
|
|
|
|
const DISCARD_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-danger px-4 py-2 font-medium text-white hover:opacity-90' // theme-lint-disable: white on the danger fill, as ConfirmDialog draws it
|
|
|
|
/**
|
|
* 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, discardGuard }: Omit<DialogShellProps, 'open'>) {
|
|
const { t } = useTranslation()
|
|
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)
|
|
// The unsaved-changes question (#2253). The backdrop and Escape are the two
|
|
// ways out a hand can take by accident; both go through requestClose.
|
|
const { asking, markStart, requestClose, keepEditing, discard } = useDiscardGuard(discardGuard, onClose)
|
|
const held = blocked || asking
|
|
|
|
useEffect(() => {
|
|
if (held) return
|
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !e.defaultPrevented) requestClose() }
|
|
document.addEventListener('keydown', onKey)
|
|
return () => document.removeEventListener('keydown', onKey)
|
|
}, [held, requestClose])
|
|
|
|
useEffect(() => {
|
|
if (!asking) return
|
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); keepEditing() } }
|
|
document.addEventListener('keydown', onKey)
|
|
return () => document.removeEventListener('keydown', onKey)
|
|
}, [asking, keepEditing])
|
|
|
|
// The page stays put behind the dialog, the focus moves into it (the first text
|
|
// field on a desktop, #1302), 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) focusDialog(panel)
|
|
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 (!held && e.target === e.currentTarget && pressedOn.current === e.currentTarget) requestClose()
|
|
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}
|
|
onKeyDownCapture={markStart}
|
|
onPointerDownCapture={markStart}
|
|
onKeyDown={e => { if (panelRef.current) trapTab(e, panelRef.current) }}
|
|
className={`trek-modal-enter relative 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}
|
|
{asking && (
|
|
<div className="trek-backdrop-enter absolute inset-0 z-10 grid place-items-center p-6 backdrop-blur-[2px]" style={{ background: 'color-mix(in srgb, var(--bg-card) 72%, transparent)' }}>
|
|
<div role="alertdialog" aria-labelledby={`${labelledBy}-discard`} className="trek-modal-enter w-full max-w-[340px] rounded-[18px] border border-edge-faint bg-surface-card p-5 shadow-xl">
|
|
<div id={`${labelledBy}-discard`} className="font-semibold text-content" style={fs(15, 'subtitle')}>{t('common.unsavedTitle')}</div>
|
|
<p className="m-0 mt-1.5 text-content-muted" style={fs(12.5, 'body')}>{t('common.unsavedMessage')}</p>
|
|
<div className="mt-4 flex justify-end gap-2">
|
|
<DialogButton autoFocus onClick={keepEditing}>{t('common.keepEditing')}</DialogButton>
|
|
<button type="button" onClick={discard} className={DISCARD_BUTTON} style={fs(13, 'body')}>
|
|
{t('common.discard')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</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-start 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="ms-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>
|
|
)
|
|
}
|