Files
TREK/client/src/components/shared/ConfirmDialog.tsx
T
Maurice b7951baf23 fix(ci): green the dev gates after the native and popup rounds
- RTL: the measured pills and centred controls use logical forms or a marked line
- i18n: the two native widget numbers are positions, not counts (NOT_PLURAL)
- Types: styleOverride on the lazy GL map, the location and shared-file payloads,
  the capabilities fallback, the transport sheet's stay metadata
- ESLint: latest-value refs move to layout effects, usePresence keeps the last
  open state in state, the booking import opens clean with a shared document
  kept in the list (the reset effect used to clear it again)
- Layers: the phone import sheet lives under mobile/ and reads through tripRepo;
  the offline host check moves to utils/tileUrl
- Sizes: plugin layer geometry, hardNavigate and the exit animations get
  modules of their own
- Tests follow the status radios, the stay chip overview, the dock + button
  for the first list and the field token; store registry lists the new stores
2026-11-06 18:24:25 +01:00

171 lines
7.0 KiB
TypeScript

import React, { useEffect, useCallback, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { AlertTriangle, HelpCircle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useIsPhone } from '../../mobile/useIsPhone'
import { DialogTile, NEUTRAL_TINT, fs } from './DialogShell'
import { useLastOpen, usePresence } from './usePresence'
interface ConfirmDialogProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
title?: string
message?: string
confirmLabel?: string
cancelLabel?: string
danger?: boolean
/** Extra content under the message, such as a list of what the action takes with it. */
children?: ReactNode
}
// Callers commonly pass an async handler that reports its own failure and then
// rethrows. The dialog has already closed at that point, so the rejection would
// escape as an unhandled promise — absorb it here.
function runConfirm(onConfirm: () => void): void {
const result = onConfirm() as unknown
if (result && typeof (result as PromiseLike<unknown>).then === 'function') {
void Promise.resolve(result).catch(() => {})
}
}
export default function ConfirmDialog(props: ConfirmDialogProps) {
const { mounted, closing } = usePresence(props.isOpen)
// The question stays readable while it fades, even when its owner clears the subject on close.
const shown = useLastOpen(props, props.isOpen)
return mounted ? <ConfirmDialogFrame {...shown} isOpen={props.isOpen} closing={closing} /> : null
}
function ConfirmDialogFrame({
isOpen,
onClose,
onConfirm,
title,
message,
confirmLabel,
cancelLabel,
danger = true,
children,
closing = false,
}: ConfirmDialogProps & { closing?: boolean }) {
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
// back the question, not the dialog it was asked from.
const handleEsc = useCallback((e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.stopPropagation()
onClose()
}, [onClose])
useEffect(() => {
if (!isOpen) return
document.addEventListener('keydown', handleEsc, true)
return () => document.removeEventListener('keydown', handleEsc, true)
}, [isOpen, handleEsc])
if (!isOpen && !closing) return null
// Each used by both layouts, worked out once.
const heading = title || t('common.confirm')
const cancelText = cancelLabel || t('common.cancel')
const confirmText = confirmLabel || t('common.delete')
const modalExit = closing ? 'trek-modal-exit' : ''
const width = children ? 'max-w-md' : 'max-w-sm'
return createPortal(
<div
// Backdrop only — Escape (above) and the Cancel button below are the
// keyboard routes out of the dialog.
role="presentation"
className={`fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)] ${closing ? 'trek-backdrop-exit pointer-events-none' : ''}`}
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
>
{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 ${modalExit} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${width} 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">
{heading}
</h3>
<p className="mt-1 text-sm text-content-secondary">
{message}
</p>
{children}
</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"
>
{cancelText}
</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'
}`}
>
{confirmText}
</button>
</div>
</div>
</>
) : (
// 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 ${modalExit} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] w-full flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl ${width}`}
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')}>
{heading}
</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">
{cancelText}
</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'}`}
>
{confirmText}
</button>
</div>
</div>
)}
</div>,
document.body
)
}