mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
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.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import React, { useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { AlertTriangle, HelpCircle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { DialogTile, NEUTRAL_TINT, fs } from './DialogShell'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean
|
||||
@@ -38,6 +40,7 @@ export default function ConfirmDialog({
|
||||
children,
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const isPhone = useIsPhone()
|
||||
|
||||
// Captured and stopped here: the question often opens over another dialog
|
||||
// (a Modal) that closes on Escape too, and one key press should only take
|
||||
@@ -65,48 +68,86 @@ export default function ConfirmDialog({
|
||||
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Capped at the viewport: a long list of what goes along scrolls on its
|
||||
own, and the two buttons below it always stay in reach. */}
|
||||
<div
|
||||
role="presentation"
|
||||
className={`trek-modal-enter flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${children ? 'max-w-md' : 'max-w-sm'} p-6 bg-surface-card`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex min-h-0 items-start gap-4 overflow-y-auto overscroll-contain">
|
||||
{danger && (
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-danger-soft flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-danger" />
|
||||
{isPhone ? (
|
||||
<>
|
||||
{/* Capped at the viewport: a long list of what goes along scrolls on its
|
||||
own, and the two buttons below it always stay in reach. */}
|
||||
<div
|
||||
role="presentation"
|
||||
className={`trek-modal-enter flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${children ? 'max-w-md' : 'max-w-sm'} p-6 bg-surface-card`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex min-h-0 items-start gap-4 overflow-y-auto overscroll-contain">
|
||||
{danger && (
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-danger-soft flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-danger" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-content">
|
||||
{title || t('common.confirm')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-content-secondary">
|
||||
{message}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-content">
|
||||
{title || t('common.confirm')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-content-secondary">
|
||||
{message}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none justify-end gap-3 mt-6">
|
||||
<button type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-content-secondary border border-edge-secondary"
|
||||
>
|
||||
{cancelLabel || t('common.cancel')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => { runConfirm(onConfirm); onClose() }}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-opacity hover:opacity-90 ${
|
||||
danger ? 'bg-danger text-white' : 'bg-accent text-accent-text'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel || t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none justify-end gap-3 mt-6">
|
||||
<button type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-content-secondary border border-edge-secondary"
|
||||
>
|
||||
{cancelLabel || t('common.cancel')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => { runConfirm(onConfirm); onClose() }}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-opacity hover:opacity-90 ${
|
||||
danger ? 'bg-danger text-white' : 'bg-accent text-accent-text'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel || t('common.delete')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// The desktop draws the question like the planner's dialogs: a head band
|
||||
// with its tile, the message below, the answers in a bar at the foot.
|
||||
<div
|
||||
role="presentation"
|
||||
className={`trek-modal-enter flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] w-full flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl ${children ? 'max-w-md' : 'max-w-sm'}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className={`flex flex-none items-center gap-3.5 px-6 pb-4 pt-5 ${danger ? 'bg-danger-soft' : ''}`} style={danger ? undefined : { background: NEUTRAL_TINT }}>
|
||||
<DialogTile>
|
||||
{danger
|
||||
? <AlertTriangle size={20} strokeWidth={1.9} className="text-danger" />
|
||||
: <HelpCircle size={20} strokeWidth={1.9} className="text-content-muted" />}
|
||||
</DialogTile>
|
||||
<h3 className="m-0 min-w-0 flex-1 font-bold tracking-[-0.01em] text-content" style={fs(18, 'subtitle')}>
|
||||
{title || t('common.confirm')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto overscroll-contain px-6 py-5">
|
||||
<p className="m-0 text-content-secondary" style={fs(13.5, 'body')}>{message}</p>
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex flex-none items-center justify-end gap-2 border-t border-edge-faint px-6 py-3.5" style={fs(13, 'body')}>
|
||||
<button type="button" onClick={onClose}
|
||||
className="inline-flex items-center rounded-[10px] bg-surface-tertiary px-3.5 py-2 font-medium text-content hover:bg-surface-hover">
|
||||
{cancelLabel || t('common.cancel')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => { runConfirm(onConfirm); onClose() }}
|
||||
className={`inline-flex items-center rounded-[10px] px-4 py-2 font-medium hover:opacity-90 ${danger ? 'bg-danger text-white' : 'bg-accent text-accent-text'}`}
|
||||
>
|
||||
{confirmLabel || t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
|
||||
@@ -34,6 +34,8 @@ interface CustomSelectProps {
|
||||
* offer a list of identical prefixes.
|
||||
*/
|
||||
menuFit?: 'anchor' | 'content'
|
||||
/** The trigger's id, so a label outside can point at it. */
|
||||
id?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,6 +79,7 @@ export default function CustomSelect({
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
menuFit = 'anchor',
|
||||
id,
|
||||
}: CustomSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -138,6 +141,7 @@ export default function CustomSelect({
|
||||
<div ref={ref} style={{ position: 'relative', ...style }}>
|
||||
{/* Trigger */}
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => { if (!disabled) { setOpen(o => !o); setSearch('') } }}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// FE-W4GB-001 to FE-W4GB-003
|
||||
// FE-W4GB-001 to FE-W4GB-004
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import GuestBadge from './GuestBadge'
|
||||
|
||||
@@ -21,4 +22,12 @@ describe('GuestBadge', () => {
|
||||
render(<GuestBadge size="xs" />)
|
||||
expect(screen.getByTitle(/without an account/i)).toHaveStyle({ fontSize: 'calc(9px * var(--fs-scale-caption, 1))' })
|
||||
})
|
||||
|
||||
it('FE-W4GB-004: with customTooltip the hint moves from the native title into the app tooltip', async () => {
|
||||
render(<GuestBadge customTooltip />)
|
||||
expect(screen.queryByTitle(/without an account/i)).toBeNull()
|
||||
|
||||
fireEvent.mouseEnter(screen.getByText('Guest'))
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(/without an account/i)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { UserRound } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Tooltip } from './Tooltip'
|
||||
|
||||
/**
|
||||
* Small "Guest" pill (#1362) shown next to a member's name in assignment pickers
|
||||
* so it's clear the person is an accountless guest. Purely presentational.
|
||||
*/
|
||||
export default function GuestBadge({ size = 'sm' }: { size?: 'sm' | 'xs' }) {
|
||||
export default function GuestBadge({ size = 'sm', customTooltip = false }: { size?: 'sm' | 'xs'; customTooltip?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const fs = size === 'xs' ? 9 : 10
|
||||
return (
|
||||
const badge = (
|
||||
<span
|
||||
title={t('members.guestsHint')}
|
||||
title={customTooltip ? undefined : t('members.guestsHint')}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0,
|
||||
fontSize: `calc(${fs}px * var(--fs-scale-caption, 1))`, fontWeight: 600,
|
||||
@@ -21,4 +22,5 @@ export default function GuestBadge({ size = 'sm' }: { size?: 'sm' | 'xs' }) {
|
||||
<UserRound size={fs - 1} /> {t('members.guest')}
|
||||
</span>
|
||||
)
|
||||
return customTooltip ? <Tooltip label={t('members.guestsHint')}>{badge}</Tooltip> : badge
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Modal from './Modal'
|
||||
import { useEffect, useId } from 'react'
|
||||
import { PencilLine, type LucideIcon } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from './DialogShell'
|
||||
|
||||
interface NameDialogProps {
|
||||
open: boolean
|
||||
@@ -10,44 +12,62 @@ interface NameDialogProps {
|
||||
onChange: (value: string) => void
|
||||
onConfirm: () => void
|
||||
onClose: () => void
|
||||
/** The icon on the head band's tile; a pencil unless the caller has a better one. */
|
||||
icon?: LucideIcon
|
||||
}
|
||||
|
||||
/**
|
||||
* The small dialog that asks for one name: a new list, a template to save.
|
||||
* It takes the name in the middle of the screen instead of pushing a field
|
||||
* into the page; Enter confirms and Esc (from the modal) cancels.
|
||||
* The small dialog that asks for one name: a new list, a template to save, a
|
||||
* booking's title. The name is typed into the head band itself; Enter confirms
|
||||
* and Escape cancels this dialog alone, never one it was opened from.
|
||||
*/
|
||||
export default function NameDialog({ open, title, placeholder, confirmLabel, value, onChange, onConfirm, onClose }: NameDialogProps) {
|
||||
export default function NameDialog({ open, title, placeholder, confirmLabel, value, onChange, onConfirm, onClose, icon: Icon = PencilLine }: NameDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const labelId = useId()
|
||||
const ready = value.trim().length > 0
|
||||
|
||||
// Caught before any dialog underneath hears it, so only this one closes.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' || e.defaultPrevented) return
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => document.removeEventListener('keydown', onKey, true)
|
||||
}, [open, onClose])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={open}
|
||||
<DialogShell
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="sm"
|
||||
footer={(
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} className="rounded-lg border border-edge px-4 py-2 text-body text-content-muted hover:bg-surface-hover">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={onConfirm} disabled={!ready}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-body font-medium text-accent-text hover:bg-accent-hover disabled:cursor-default disabled:opacity-40">
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
labelledBy={labelId}
|
||||
width="narrow"
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><Icon size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={onClose}
|
||||
eyebrow={title}
|
||||
titleInput={{
|
||||
value,
|
||||
onChange,
|
||||
label: title,
|
||||
placeholder,
|
||||
autoFocus: true,
|
||||
onKeyDown: e => { if (e.key === 'Enter' && ready) onConfirm() },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && ready) onConfirm() }}
|
||||
placeholder={placeholder}
|
||||
aria-label={title}
|
||||
className="w-full rounded-lg border border-edge bg-surface-input px-3 py-2.5 text-body text-content outline-none focus:border-content-muted"
|
||||
/>
|
||||
</Modal>
|
||||
footer={(
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={onConfirm} disabled={!ready}>{confirmLabel}</DialogButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,12 +10,12 @@ import NoteFormatToolbar from './NoteFormatToolbar'
|
||||
* through a real textarea rather than asserting on a callback in isolation —
|
||||
* the selection handling is the part that breaks, and it only exists in the DOM.
|
||||
*/
|
||||
function Harness({ initial = '', onValue }: { initial?: string; onValue?: (v: string) => void }) {
|
||||
function Harness({ initial = '', onValue, customTooltips }: { initial?: string; onValue?: (v: string) => void; customTooltips?: boolean }) {
|
||||
const ref = useRef<HTMLTextAreaElement | null>(null)
|
||||
const [value, setValue] = useState(initial)
|
||||
return (
|
||||
<>
|
||||
<NoteFormatToolbar textareaRef={ref} onChange={v => { setValue(v); onValue?.(v) }} />
|
||||
<NoteFormatToolbar textareaRef={ref} onChange={v => { setValue(v); onValue?.(v) }} customTooltips={customTooltips} />
|
||||
<textarea ref={ref} aria-label="body" value={value} onChange={e => setValue(e.target.value)} />
|
||||
</>
|
||||
)
|
||||
@@ -110,4 +110,25 @@ describe('NoteFormatToolbar', () => {
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOTEBAR-008: keeps the native tooltip by default', () => {
|
||||
render(<Harness />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /bold/i })).toHaveAttribute('title', 'Bold')
|
||||
})
|
||||
|
||||
it('FE-NOTEBAR-009: with customTooltips the name shows in the shared tooltip, and the button still keeps the focus off itself', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Harness initial="text" customTooltips />)
|
||||
const bold = screen.getByRole('button', { name: /bold/i })
|
||||
expect(bold).not.toHaveAttribute('title')
|
||||
|
||||
await user.hover(bold)
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Bold')
|
||||
|
||||
select(0, 4)
|
||||
await user.click(bold)
|
||||
expect(field().value).toBe('**text**')
|
||||
await waitFor(() => expect(document.activeElement).toBe(field()))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef } from 'react'
|
||||
import { Bold, Italic, Strikethrough, List, ListOrdered, Link2, Quote, Code } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Tooltip } from './Tooltip'
|
||||
|
||||
type FormatAction =
|
||||
| { type: 'wrap'; before: string; after: string }
|
||||
@@ -15,6 +16,8 @@ interface Props {
|
||||
compact?: boolean
|
||||
/** Mobile sheets run on their own token family, scoped to `.m-root`. */
|
||||
variant?: 'desktop' | 'mobile'
|
||||
/** Shows each button's name in the shared Tooltip instead of the browser's native one. */
|
||||
customTooltips?: boolean
|
||||
}
|
||||
|
||||
const ACTIONS: Array<{ icon: typeof Bold; labelKey: string; action: FormatAction }> = [
|
||||
@@ -43,7 +46,7 @@ const ACTIONS: Array<{ icon: typeof Bold; labelKey: string; action: FormatAction
|
||||
* `--journal-*` variables and hardcodes English labels; this one runs on theme
|
||||
* tokens and goes through i18n, so it can sit in the planner.
|
||||
*/
|
||||
export default function NoteFormatToolbar({ textareaRef, onChange, compact, variant = 'desktop' }: Props) {
|
||||
export default function NoteFormatToolbar({ textareaRef, onChange, compact, variant = 'desktop', customTooltips = false }: Props) {
|
||||
const { t } = useTranslation()
|
||||
// Applied after the re-render, when the field holds the new text.
|
||||
const pendingSelection = useRef<[number, number] | null>(null)
|
||||
@@ -94,22 +97,25 @@ export default function NoteFormatToolbar({ textareaRef, onChange, compact, vari
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-[2px]" role="toolbar" aria-label={t('notes.format.toolbar')}>
|
||||
{ACTIONS.map(({ icon: Icon, labelKey, action }) => (
|
||||
<button
|
||||
key={labelKey}
|
||||
type="button"
|
||||
title={t(labelKey)}
|
||||
aria-label={t(labelKey)}
|
||||
// Buttons in a toolbar above a field must not steal focus, or the
|
||||
// selection they are about to format is gone before the click lands.
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => apply(action)}
|
||||
className={buttonCls}
|
||||
style={{ width: size, height: size, cursor: 'pointer', padding: 0, ...(variant === 'mobile' ? {} : { border: 0, background: 'none' }) }}
|
||||
>
|
||||
<Icon size={compact ? 13 : 15} strokeWidth={1.9} />
|
||||
</button>
|
||||
))}
|
||||
{ACTIONS.map(({ icon: Icon, labelKey, action }) => {
|
||||
const button = (
|
||||
<button
|
||||
key={labelKey}
|
||||
type="button"
|
||||
title={customTooltips ? undefined : t(labelKey)}
|
||||
aria-label={t(labelKey)}
|
||||
// Buttons in a toolbar above a field must not steal focus, or the
|
||||
// selection they are about to format is gone before the click lands.
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => apply(action)}
|
||||
className={buttonCls}
|
||||
style={{ width: size, height: size, cursor: 'pointer', padding: 0, ...(variant === 'mobile' ? {} : { border: 0, background: 'none' }) }}
|
||||
>
|
||||
<Icon size={compact ? 13 : 15} strokeWidth={1.9} />
|
||||
</button>
|
||||
)
|
||||
return customTooltips ? <Tooltip key={labelKey} label={t(labelKey)}>{button}</Tooltip> : button
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, ch
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
{open && createPortal(
|
||||
{open && !disabled && createPortal(
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
role="tooltip"
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// FE-PLANNER-EDITORPARTS-001 to FE-PLANNER-EDITORPARTS-011
|
||||
import { useState } from 'react';
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { PillSelect, Segmented, EditorField, AddRowButton } from './dialogParts';
|
||||
|
||||
type Kind = 'flight' | 'train' | 'bus' | 'transit';
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'flight' as const, label: 'Flight', icon: <span data-testid="icon-flight" /> },
|
||||
{ value: 'train' as const, label: 'Train' },
|
||||
{ value: 'bus' as const, label: 'Bus' },
|
||||
];
|
||||
|
||||
function Picker({ initial = 'train', onChange = vi.fn() }: { initial?: Kind; onChange?: (v: Kind) => void }) {
|
||||
const [value, setValue] = useState<Kind>(initial);
|
||||
return (
|
||||
<PillSelect<Kind>
|
||||
label="Booking Type"
|
||||
value={value}
|
||||
options={OPTIONS}
|
||||
onChange={v => { setValue(v); onChange(v); }}
|
||||
fallback={{ label: 'Transit', icon: <span data-testid="icon-fallback" /> }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const pill = () => screen.getByRole('button', { name: /^Booking Type:/ });
|
||||
const list = () => screen.queryByRole('group', { name: 'Booking Type' });
|
||||
const option = (name: string) => screen.getByRole('button', { name: new RegExp(`^${name}$`) });
|
||||
|
||||
describe('PillSelect', () => {
|
||||
it('FE-PLANNER-EDITORPARTS-001: the pill names what is picked and the value, and claims no menu', () => {
|
||||
render(<Picker />);
|
||||
expect(pill()).toHaveAccessibleName(/^Booking Type: ?Train$/);
|
||||
expect(pill()).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(pill()).not.toHaveAttribute('aria-haspopup');
|
||||
expect(list()).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-EDITORPARTS-002: opening shows the list as a named group and puts the focus on the current choice', async () => {
|
||||
render(<Picker />);
|
||||
await userEvent.click(pill());
|
||||
expect(list()).toBeInTheDocument();
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
expect(pill()).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(pill()).toHaveAttribute('aria-controls', list()!.id);
|
||||
expect(option('Train')).toHaveFocus();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-EDITORPARTS-003: picking reports the value, closes the list and returns to the pill', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Picker onChange={onChange} />);
|
||||
await userEvent.click(pill());
|
||||
await userEvent.click(option('Bus'));
|
||||
expect(onChange).toHaveBeenCalledWith('bus');
|
||||
expect(list()).toBeNull();
|
||||
expect(pill()).toHaveAccessibleName(/^Booking Type: ?Bus$/);
|
||||
expect(pill()).toHaveFocus();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-EDITORPARTS-004: Escape closes only the list, a dialog listening on the document never hears it', async () => {
|
||||
const dialogEscape = vi.fn();
|
||||
document.addEventListener('keydown', dialogEscape);
|
||||
try {
|
||||
render(<Picker />);
|
||||
await userEvent.click(pill());
|
||||
await userEvent.keyboard('{Escape}');
|
||||
expect(list()).toBeNull();
|
||||
expect(dialogEscape).not.toHaveBeenCalled();
|
||||
expect(pill()).toHaveFocus();
|
||||
|
||||
// With the list closed the next Escape is the dialog's again.
|
||||
await userEvent.keyboard('{Escape}');
|
||||
expect(dialogEscape).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
document.removeEventListener('keydown', dialogEscape);
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-PLANNER-EDITORPARTS-005: a press outside closes the list, a press inside does not', async () => {
|
||||
render(<><Picker /><p>elsewhere</p></>);
|
||||
await userEvent.click(pill());
|
||||
fireEvent.mouseDown(option('Bus'));
|
||||
expect(list()).toBeInTheDocument();
|
||||
fireEvent.mouseDown(screen.getByText('elsewhere'));
|
||||
expect(list()).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-EDITORPARTS-006: a second click on the pill closes the list', async () => {
|
||||
render(<Picker />);
|
||||
await userEvent.click(pill());
|
||||
await userEvent.click(pill());
|
||||
expect(list()).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-EDITORPARTS-007: the arrow keys, Home and End walk the list and wrap around', async () => {
|
||||
render(<Picker initial="flight" />);
|
||||
await userEvent.click(pill());
|
||||
expect(option('Flight')).toHaveFocus();
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
expect(option('Train')).toHaveFocus();
|
||||
await userEvent.keyboard('{End}');
|
||||
expect(option('Bus')).toHaveFocus();
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
expect(option('Flight')).toHaveFocus();
|
||||
await userEvent.keyboard('{ArrowUp}');
|
||||
expect(option('Bus')).toHaveFocus();
|
||||
await userEvent.keyboard('{Home}');
|
||||
expect(option('Flight')).toHaveFocus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
expect(list()).toBeNull();
|
||||
expect(pill()).toHaveAccessibleName(/^Booking Type: ?Flight$/);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-EDITORPARTS-008: Tab closes the list and goes on from the pill', async () => {
|
||||
render(<Picker />);
|
||||
await userEvent.click(pill());
|
||||
fireEvent.keyDown(option('Train'), { key: 'Tab' });
|
||||
expect(list()).toBeNull();
|
||||
expect(pill()).toHaveFocus();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-EDITORPARTS-009: a value none of the options holds shows the fallback, and the list opens on the first option', async () => {
|
||||
render(<Picker initial="transit" />);
|
||||
expect(pill()).toHaveAccessibleName(/^Booking Type: ?Transit$/);
|
||||
expect(screen.getByTestId('icon-fallback')).toBeInTheDocument();
|
||||
await userEvent.click(pill());
|
||||
expect(option('Flight')).toHaveFocus();
|
||||
expect(screen.getByTestId('icon-flight')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor fields', () => {
|
||||
it('FE-PLANNER-EDITORPARTS-010: Segmented marks the chosen option and reports a new one', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Segmented label="Mode" value="manual" onChange={onChange} options={[{ value: 'manual', label: 'Manual' }, { value: 'automated', label: 'Automated' }]} />);
|
||||
expect(screen.getByRole('group', { name: 'Mode' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Manual' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Automated' }));
|
||||
expect(onChange).toHaveBeenCalledWith('automated');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-EDITORPARTS-011: EditorField shows an error in place of its hint, and AddRowButton adds', async () => {
|
||||
const onAdd = vi.fn();
|
||||
const { rerender } = render(<EditorField label="Seat" htmlFor="seat" hint="Optional"><input id="seat" /></EditorField>);
|
||||
expect(screen.getByLabelText('Seat')).toBeInTheDocument();
|
||||
expect(screen.getByText('Optional')).toBeInTheDocument();
|
||||
rerender(<EditorField label="Seat" htmlFor="seat" hint="Optional" error="Taken"><input id="seat" /></EditorField>);
|
||||
expect(screen.getByText('Taken')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Optional')).toBeNull();
|
||||
|
||||
render(<AddRowButton onClick={onAdd}>Add stop</AddRowButton>);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Add stop' }));
|
||||
expect(onAdd).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useEffect, useId, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ChevronDown, Plus } from 'lucide-react'
|
||||
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
|
||||
import { POPOVER } from '../Packing/packingPopoverStyles'
|
||||
import { PopoverItem } from '../Packing/PackingPopover'
|
||||
import { PILL, fs } from './DialogShell'
|
||||
|
||||
// The editors' fields in the cards' language: framed white boxes under small
|
||||
// Geist eyebrows. The sizes are classes, not fs() styles, so components that
|
||||
// only take a className (BookingLinkAndFiles, BookingCodeInput, AddressInput)
|
||||
// get them too; both scale with the user's text size the same way.
|
||||
|
||||
/** A text input in the box look. Same height and inset as CustomSelect and CustomTimePicker, so they line up in a row. */
|
||||
export const INPUT = 'block w-full min-w-0 rounded-[10px] border border-edge bg-surface-input px-3 py-2 text-[length:calc(13px*var(--fs-scale-body,1))] text-content outline-none placeholder:text-content-faint focus:ring-2 focus:ring-[color:var(--text-primary)] disabled:cursor-default disabled:opacity-50'
|
||||
export const TEXTAREA = 'block w-full min-w-0 resize-none rounded-[10px] border border-edge bg-surface-input px-3 py-2 text-[length:calc(13px*var(--fs-scale-body,1))] leading-normal text-content outline-none placeholder:text-content-faint focus:ring-2 focus:ring-[color:var(--text-primary)] disabled:cursor-default disabled:opacity-50'
|
||||
/** A value shown in the box look that cannot be typed into, such as an airport's timezone. */
|
||||
export const READONLY_BOX = 'block w-full min-w-0 truncate rounded-[10px] border border-edge-faint bg-surface-tertiary px-3 py-2 text-[length:calc(12.5px*var(--fs-scale-body,1))] text-content-muted'
|
||||
/** The eyebrow over a field, as a class for components that take a labelClass. */
|
||||
export const LABEL = 'mb-[5px] block font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
/** Fields side by side; a narrow window stacks them. */
|
||||
export const GRID_2 = 'grid grid-cols-2 items-start gap-3 max-sm:grid-cols-1'
|
||||
export const GRID_3 = 'grid grid-cols-3 items-start gap-3 max-sm:grid-cols-1'
|
||||
/** A framed group inside the body, such as one stop of a route: the card surface with white fields on it. */
|
||||
export const PANEL = 'flex flex-col gap-3 rounded-[14px] border border-edge-faint bg-surface-secondary p-3'
|
||||
/**
|
||||
* Wraps an AirportSelect or LocationSelect on a PANEL. Their search box keeps
|
||||
* the grey fill it has everywhere else; here it gets the white of the fields
|
||||
* beside it, set from outside because the phone shares the component.
|
||||
*/
|
||||
export const SEARCH_ON_PANEL = 'min-w-0 [&>div>div:first-child]:bg-surface-input'
|
||||
|
||||
/** An eyebrow label over one control, with an optional hint or error line under it. */
|
||||
export function EditorField({ label, htmlFor, hint, error, className = '', children }: {
|
||||
label: ReactNode
|
||||
htmlFor?: string
|
||||
hint?: ReactNode
|
||||
/** Shown in the danger colour in place of the hint. */
|
||||
error?: ReactNode
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={`min-w-0 ${className}`}>
|
||||
<label htmlFor={htmlFor} className={LABEL}>{label}</label>
|
||||
{children}
|
||||
{error
|
||||
? <p className="m-0 mt-1 text-danger" style={fs(11)}>{error}</p>
|
||||
: hint ? <p className="m-0 mt-1 text-content-faint" style={fs(11)}>{hint}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SegmentedOption<T extends string> {
|
||||
value: T
|
||||
label: ReactNode
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
/** A row of choices on a grey track, the chosen one raised like the status filter (or filled like the view switcher). */
|
||||
export function Segmented<T extends string>({ value, options, onChange, label, accent = false, fill = false }: {
|
||||
value: T
|
||||
options: readonly SegmentedOption<T>[]
|
||||
onChange: (value: T) => void
|
||||
/** The group's accessible name. */
|
||||
label: string
|
||||
/** The chosen option in the accent fill instead of the raised card. */
|
||||
accent?: boolean
|
||||
/** Stretch across the row, each option taking an equal share. */
|
||||
fill?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div role="group" aria-label={label} className={`${fill ? 'flex w-full' : 'inline-flex'} flex-none gap-0.5 rounded-[10px] bg-surface-tertiary p-[3px]`}>
|
||||
{options.map(o => {
|
||||
const on = o.value === value
|
||||
const look = on ? (accent ? 'bg-accent text-accent-text' : 'bg-surface-card text-content shadow-sm') : 'text-content-muted hover:text-content'
|
||||
return (
|
||||
<button key={o.value} type="button" onClick={() => onChange(o.value)} aria-pressed={on}
|
||||
className={`${fill ? 'flex-1' : ''} inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-[8px] px-3 py-1.5 font-medium ${look}`}
|
||||
style={fs(12.5, 'body')}>
|
||||
{o.icon}{o.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface PillOption<T extends string> {
|
||||
value: T
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
/** A quieter second part after the label, such as a day's date. */
|
||||
hint?: string
|
||||
}
|
||||
|
||||
const itemsOf = (list: HTMLElement | null) => Array.from(list?.querySelectorAll('button') ?? [])
|
||||
|
||||
/**
|
||||
* A head band pill that opens a list to pick from, such as the booking type.
|
||||
* Opening moves the focus onto the current choice; the arrow keys walk the
|
||||
* list, Escape closes it and leaves the dialog open, Tab closes it and goes on
|
||||
* from the pill.
|
||||
*/
|
||||
/** A PillSelect sitting in the body rather than on a head band: grey, flat and small. */
|
||||
const QUIET_PILL = 'inline-flex flex-none items-center gap-1.5 rounded-full bg-surface-tertiary px-2.5 py-1 font-semibold text-content'
|
||||
|
||||
export function PillSelect<T extends string>({ value, options, onChange, label, fallback, quiet = false }: {
|
||||
value: T
|
||||
options: readonly PillOption<T>[]
|
||||
onChange: (value: T) => void
|
||||
/** What is being picked; read before the value by screen readers, and the list's name. */
|
||||
label: string
|
||||
/** What the pill shows while the value is none of the options, such as a type the list does not offer. */
|
||||
fallback?: { label: string; icon?: ReactNode; hint?: string }
|
||||
/** In the body of a dialog instead of on its head band. */
|
||||
quiet?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const startAt = useRef(0)
|
||||
const listId = useId()
|
||||
const box = useAnchoredPosition(triggerRef, open, { matchWidth: false, estimatedHeight: 320 })
|
||||
const current = options.find(o => o.value === value)
|
||||
const shown = current ?? fallback
|
||||
const listShown = open && !!box
|
||||
|
||||
const toggle = () => {
|
||||
if (!open) startAt.current = Math.max(0, options.findIndex(o => o.value === value))
|
||||
setOpen(o => !o)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (listShown) itemsOf(menuRef.current)[startAt.current]?.focus({ preventScroll: true })
|
||||
}, [listShown])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
// Captured and stopped, so the key takes back the list and not the dialog under it.
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const pick = (next: T) => {
|
||||
setOpen(false)
|
||||
onChange(next)
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
|
||||
const onListKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Tab') {
|
||||
// Back on the pill before the browser moves on, so Tab carries on from there.
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
return
|
||||
}
|
||||
const items = itemsOf(menuRef.current)
|
||||
const at = items.indexOf(document.activeElement as HTMLButtonElement)
|
||||
const last = items.length - 1
|
||||
let next: number
|
||||
if (e.key === 'ArrowDown') next = at < 0 || at === last ? 0 : at + 1
|
||||
else if (e.key === 'ArrowUp') next = at <= 0 ? last : at - 1
|
||||
else if (e.key === 'Home') next = 0
|
||||
else if (e.key === 'End') next = last
|
||||
else return
|
||||
e.preventDefault()
|
||||
items[next]?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={triggerRef} type="button" onClick={toggle} aria-expanded={open} aria-controls={open ? listId : undefined}
|
||||
className={`${quiet ? QUIET_PILL : PILL} hover:opacity-80`} style={quiet ? fs(11.5, 'body') : undefined}>
|
||||
<span className="sr-only">{label}: </span>
|
||||
{shown?.icon}
|
||||
<span>{shown?.label}</span>
|
||||
{shown?.hint && <span className="font-medium text-content-muted">{shown.hint}</span>}
|
||||
<ChevronDown size={12} strokeWidth={2.2} className={`-mr-0.5 flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && box && createPortal(
|
||||
<div ref={menuRef} id={listId} role="group" aria-label={label} onKeyDown={onListKey}
|
||||
className="fixed z-[var(--z-toast)] min-w-[200px] overflow-y-auto"
|
||||
style={{ ...POPOVER, top: box.top, bottom: box.bottom, left: box.left, maxHeight: box.maxHeight }}>
|
||||
{options.map(o => (
|
||||
<PopoverItem key={o.value} icon={o.icon ?? null} label={o.label} active={o.value === value} onClick={() => pick(o.value)}
|
||||
trailing={o.hint ? <span className="font-geist text-content-faint" style={fs(11)}>{o.hint}</span> : undefined} />
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The dashed full-width button that adds a row: a stop, a leg. */
|
||||
export function AddRowButton({ onClick, children }: { onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-[10px] border border-dashed border-edge px-3 py-2 font-semibold text-content-muted hover:border-content-faint hover:text-content"
|
||||
style={fs(12, 'body')}>
|
||||
<Plus size={13} strokeWidth={2.2} />{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user