diff --git a/client/src/components/shared/DialogShell.tsx b/client/src/components/shared/DialogShell.tsx index b4bdf5c32..e8aee0814 100644 --- a/client/src/components/shared/DialogShell.tsx +++ b/client/src/components/shared/DialogShell.tsx @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useRef, useState, type ButtonHTMLAttributes, type ClipboardEvent, type CSSProperties, type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react' +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))` }) @@ -51,10 +52,6 @@ export interface DialogShellProps { 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 -const snapshot = (value: unknown): string | null => { - try { return JSON.stringify(value) ?? null } catch { return null } -} - /** * 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. @@ -69,19 +66,9 @@ function DialogFrame({ onClose, labelledBy, width = 'detail', align = 'center', const pressedOn = useRef(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 state at the first touch, and - // whether the question is on screen. - const baseline = useRef(null) - const guarded = discardGuard !== undefined - const guardRef = useRef(discardGuard) - useEffect(() => { guardRef.current = discardGuard }) - const [asking, setAsking] = useState(false) - const markStart = () => { if (guarded && baseline.current === null) baseline.current = snapshot(discardGuard) } - // The backdrop and Escape: the two ways out a hand can take by accident. - const requestClose = useCallback(() => { - if (guardRef.current !== undefined && baseline.current !== null && snapshot(guardRef.current) !== baseline.current) setAsking(true) - else onClose() - }, [onClose]) + // 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(() => { @@ -93,10 +80,10 @@ function DialogFrame({ onClose, labelledBy, width = 'detail', align = 'center', useEffect(() => { if (!asking) return - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); setAsking(false) } } + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); keepEditing() } } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) - }, [asking]) + }, [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. @@ -152,8 +139,8 @@ function DialogFrame({ onClose, labelledBy, width = 'detail', align = 'center',
{t('common.unsavedTitle')}

{t('common.unsavedMessage')}

- setAsking(false)}>{t('common.keepEditing')} -
diff --git a/client/src/components/shared/useDiscardGuard.ts b/client/src/components/shared/useDiscardGuard.ts new file mode 100644 index 000000000..728b7f370 --- /dev/null +++ b/client/src/components/shared/useDiscardGuard.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +const snapshot = (value: unknown): string | null => { + try { return JSON.stringify(value) ?? null } catch { return null } +} + +/** + * The unsaved-changes question (#2253) for a dialog or a sheet. `guard` is the + * editor's state, JSON-serialisable; undefined turns the question off. The + * state at the first key or pointer press (`markStart`) is the starting point, + * so whatever the editor loaded on open does not count as a change. The ways + * out a hand takes by accident go through `requestClose`, which asks before + * throwing away a state that differs from that starting point. + */ +export function useDiscardGuard(guard: unknown, onClose: () => void) { + const baseline = useRef(null) + const guardRef = useRef(guard) + useEffect(() => { guardRef.current = guard }) + const [asking, setAsking] = useState(false) + + const markStart = useCallback(() => { + if (guardRef.current !== undefined && baseline.current === null) baseline.current = snapshot(guardRef.current) + }, []) + + const requestClose = useCallback(() => { + if (guardRef.current !== undefined && baseline.current !== null && snapshot(guardRef.current) !== baseline.current) setAsking(true) + else onClose() + }, [onClose]) + + const keepEditing = useCallback(() => setAsking(false), []) + const discard = useCallback(() => { setAsking(false); onClose() }, [onClose]) + /** Forget the starting point, for a surface that stays mounted between openings. */ + const reset = useCallback(() => { baseline.current = null; setAsking(false) }, []) + + return { asking, markStart, requestClose, keepEditing, discard, reset } +} diff --git a/client/src/mobile/components/MSheet.tsx b/client/src/mobile/components/MSheet.tsx index 9249d273b..eb3596bb5 100644 --- a/client/src/mobile/components/MSheet.tsx +++ b/client/src/mobile/components/MSheet.tsx @@ -1,6 +1,8 @@ import React, { ReactNode, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { lockBodyScroll } from '../../utils/bodyScrollLock' +import { useTranslation } from '../../i18n' +import { useDiscardGuard } from '../../components/shared/useDiscardGuard' export type MSheetVariant = 'card' | 'bottom' | 'drawer' export type MSheetMaterial = 'glass' | 'bar-glass' | 'opaque' @@ -20,10 +22,18 @@ interface MSheetProps { ariaLabel?: string className?: string children?: ReactNode + /** + * The form's state, for the unsaved-changes question the desktop dialogs ask + * (#2253): a flick of the handle, a tap on the scrim or Escape asks before + * throwing away a state that differs from the one at the first touch. + */ + discardGuard?: unknown } const EXIT_MS = 280 +const DISCARD_BUTTON = 'rounded-full bg-danger px-4 py-2 text-body font-semibold text-white' // theme-lint-disable: white on the danger fill, as ConfirmDialog draws it + const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' @@ -69,7 +79,10 @@ export default function MSheet({ ariaLabel, className = '', children, + discardGuard, }: MSheetProps) { + const { t } = useTranslation() + const { asking, markStart, requestClose, keepEditing, discard, reset } = useDiscardGuard(discardGuard, onClose) const [rendered, setRendered] = useState(open) // Once the enter animation has played it is removed again: its forwards // fill would otherwise override the inline drag transform for good. @@ -86,9 +99,11 @@ export default function MSheet({ } setEntered(false) setDragY(null) + // The sheet stays mounted between openings; the next one starts afresh. + reset() const t = setTimeout(() => setRendered(false), EXIT_MS) return () => clearTimeout(t) - }, [open]) + }, [open, reset]) // Escape belongs to the sheet on top only: a question opened over another // sheet closes alone, and the sheet under it stays open with whatever was @@ -99,11 +114,13 @@ export default function MSheet({ const onKey = (e: KeyboardEvent) => { if (e.key !== 'Escape') return const openPanels = document.querySelectorAll('[data-m-sheet="open"]') - if (openPanels[openPanels.length - 1] === panelRef.current) onClose() + if (openPanels[openPanels.length - 1] !== panelRef.current) return + if (asking) keepEditing() + else requestClose() } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) - }, [open, onClose]) + }, [open, asking, keepEditing, requestClose]) useEffect(() => { if (!open) return @@ -143,7 +160,7 @@ export default function MSheet({ const dy = Math.max(0, d.lastY - d.startY) setDragY(null) // Past 30% of the panel or a decisive flick → dismiss. - if ((height > 0 && dy > height * 0.3) || d.velocity > 0.6) onClose() + if ((height > 0 && dy > height * 0.3) || d.velocity > 0.6) requestClose() } // Keep Tab inside the dialog while it is open. @@ -189,7 +206,7 @@ export default function MSheet({ className={`m-root fixed inset-0 z-[60] ${dimTransparent ? 'bg-transparent' : `bg-[color:var(--m-dim)] ${open ? 'm-fade-in' : 'm-fade-out'}`}`} role="presentation" onClick={(e) => { - if (e.target === e.currentTarget) onClose() + if (e.target === e.currentTarget) requestClose() }} >
@@ -204,6 +221,8 @@ export default function MSheet({ style={variant === 'bottom' ? dragStyle : undefined} onClick={(e) => e.stopPropagation()} onKeyDown={handleKeyDown} + onKeyDownCapture={markStart} + onPointerDownCapture={markStart} onAnimationEnd={(e) => { if (e.target === e.currentTarget && open) setEntered(true) }} @@ -220,6 +239,22 @@ export default function MSheet({ /> )} {children} + {asking && ( +
+
+
{t('common.unsavedTitle')}
+

{t('common.unsavedMessage')}

+
+ + +
+
+
+ )}
, diff --git a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx index 13d3dd279..210426031 100644 --- a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx @@ -349,7 +349,13 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh : t('common.add') return ( - + { expect(onCloseTop).toHaveBeenCalledTimes(1); expect(baseCalls).toEqual([]); }); + + describe('the unsaved-changes question', () => { + function Form({ open, onClose }: { open: boolean; onClose: () => void }) { + const [name, setName] = React.useState(''); + return ( + + setName(e.target.value)} /> + + ); + } + const backdrop = () => screen.getByRole('dialog', { name: 'Form' }).parentElement!.parentElement!; + + it('FE-MOB-SHEET-020: a tap on the scrim asks before throwing a change away, and keep editing stays', () => { + const onClose = vi.fn(); + render(
); + const input = screen.getByRole('textbox', { name: 'Name' }); + fireEvent.pointerDown(input); + fireEvent.change(input, { target: { value: 'Lisbon' } }); + + fireEvent.click(backdrop()); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /keep editing|common.keepEditing/i })); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + expect(input).toHaveValue('Lisbon'); + + fireEvent.keyDown(document, { key: 'Escape' }); + fireEvent.click(screen.getByRole('button', { name: /discard|common.discard/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-SHEET-021: without a change it closes at once, and a reopened sheet starts afresh', () => { + const onClose = vi.fn(); + const { rerender } = render(); + fireEvent.pointerDown(screen.getByRole('textbox', { name: 'Name' })); + fireEvent.click(backdrop()); + expect(onClose).toHaveBeenCalledTimes(1); + + rerender(); + rerender(); + fireEvent.click(backdrop()); + expect(onClose).toHaveBeenCalledTimes(2); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + }); }); +