feat(planner): ask before a stray click throws away an edited place or booking (#2253)

This commit is contained in:
Maurice
2026-10-01 13:11:09 +02:00
parent 477f41693d
commit a98449d573
32 changed files with 199 additions and 7 deletions
@@ -995,6 +995,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
labelledBy={titleId}
width={width}
align="top"
// A stray click beside the editor asks before the typing is lost (#2253).
discardGuard={{ form, files: S.pendingFiles.length }}
onPaste={S.handlePaste}
header={header}
footer={footer}
@@ -439,6 +439,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
labelledBy={titleId}
width="editor"
align="top"
// A stray click beside the editor asks before the typing is lost (#2253).
discardGuard={{ form, files: pendingFiles.length, travelers: [...travelerIds] }}
onSubmit={handleSubmit}
header={header}
footer={footer}
@@ -1055,6 +1055,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
width="editor"
align="top"
blocked={showDeleteConfirm}
// A stray click beside the editor asks before the typing is lost (#2253).
discardGuard={{ form, waypoints, trainWaypoints, carStops, files: pendingFiles.length, travelers: [...travelerIds] }}
onSubmit={automated ? undefined : handleSubmit}
header={header}
footer={footer}
@@ -1,5 +1,6 @@
// FE-COMP-DIALOGSHELL-001 to FE-COMP-DIALOGSHELL-013
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useState } from 'react';
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { resetBodyScrollLock } from '../../utils/bodyScrollLock';
@@ -59,6 +60,58 @@ describe('DialogShell', () => {
});
});
describe('DialogShell unsaved-changes question (#2253)', () => {
function Editor() {
const [name, setName] = useState('');
return (
<DialogShell onClose={onClose} labelledBy="e-title" discardGuard={{ name }}
header={<DialogHeader tile={null} tint={NEUTRAL_TINT} labelId="e-title" onClose={onClose} title="Place" />}>
<input aria-label="Name" value={name} onChange={e => setName(e.target.value)} />
</DialogShell>
);
}
const backdrop = () => document.querySelector('.trek-modal-backdrop') as HTMLElement;
const clickBackdrop = () => { fireEvent.mouseDown(backdrop()); fireEvent.click(backdrop()); };
it('FE-COMP-DIALOGSHELL-2253-1: an untouched editor closes on the backdrop straight away', () => {
render(<Editor />);
clickBackdrop();
expect(onClose).toHaveBeenCalledTimes(1);
});
it('FE-COMP-DIALOGSHELL-2253-2: after typing, the backdrop asks; keep editing stays, discard closes', async () => {
const user = userEvent.setup();
render(<Editor />);
await user.type(screen.getByLabelText('Name'), 'Cafe');
clickBackdrop();
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole('alertdialog')).toHaveTextContent('Discard your changes?');
await user.click(screen.getByRole('button', { name: 'Keep editing' }));
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
expect(screen.getByLabelText('Name')).toHaveValue('Cafe');
await user.keyboard('{Escape}');
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
// Escape on the question only takes the question away.
await user.keyboard('{Escape}');
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
clickBackdrop();
await user.click(screen.getByRole('button', { name: 'Discard' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('FE-COMP-DIALOGSHELL-2253-3: typing back to where it started closes without asking', async () => {
const user = userEvent.setup();
render(<Editor />);
await user.type(screen.getByLabelText('Name'), 'a');
await user.clear(screen.getByLabelText('Name'));
clickBackdrop();
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe('DialogHeader', () => {
it('FE-COMP-DIALOGSHELL-004: a plain title is the heading the dialog is named by', () => {
render(<DialogHeader tile={<DialogTile><span data-testid="tile-icon" /></DialogTile>} tint={NEUTRAL_TINT} labelId="t1" onClose={onClose} title="Export" />);
+59 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ButtonHTMLAttributes, type ClipboardEvent, type CSSProperties, type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'
import { useCallback, 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'
@@ -39,6 +39,20 @@ export interface DialogShellProps {
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
const snapshot = (value: unknown): string | null => {
try { return JSON.stringify(value) ?? null } catch { return null }
}
/**
@@ -49,18 +63,40 @@ 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'>) {
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 state at the first touch, and
// whether the question is on screen.
const baseline = useRef<string | null>(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])
const held = blocked || asking
useEffect(() => {
if (blocked) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !e.defaultPrevented) onClose() }
if (held) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !e.defaultPrevented) requestClose() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [blocked, onClose])
}, [held, requestClose])
useEffect(() => {
if (!asking) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); setAsking(false) } }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [asking])
// 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.
@@ -90,7 +126,7 @@ function DialogFrame({ onClose, labelledBy, width = 'detail', align = 'center',
// 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()
if (!held && e.target === e.currentTarget && pressedOn.current === e.currentTarget) requestClose()
pressedOn.current = null
}}
>
@@ -102,12 +138,28 @@ function DialogFrame({ onClose, labelledBy, width = 'detail', align = 'center',
aria-labelledby={labelledBy}
tabIndex={-1}
onPaste={onPaste}
onKeyDownCapture={markStart}
onPointerDownCapture={markStart}
onKeyDown={e => { if (panelRef.current) trapTab(e, panelRef.current) }}
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`}
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={() => setAsking(false)}>{t('common.keepEditing')}</DialogButton>
<button type="button" onClick={() => { setAsking(false); onClose() }} className={DISCARD_BUTTON} style={fs(13, 'body')}>
{t('common.discard')}
</button>
</div>
</div>
</div>
)}
</div>
</div>,
document.body,
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'إعادة تسمية',
'common.discardChanges': 'تجاهل التغييرات',
'common.discard': 'تجاهل',
'common.unsavedTitle': 'تجاهل تغييراتك؟',
'common.unsavedMessage': 'ما أدخلته هنا لم يُحفظ بعد.',
'common.keepEditing': 'متابعة التعديل',
'common.name': 'الاسم',
'common.email': 'البريد الإلكتروني',
'common.password': 'كلمة المرور',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'Adını dəyiş',
'common.discardChanges': 'Dəyişiklikləri ləğv et',
'common.discard': 'İmtina et',
'common.unsavedTitle': 'Dəyişikliklər ləğv edilsin?',
'common.unsavedMessage': 'Burada daxil etdikləriniz hələ yadda saxlanmayıb.',
'common.keepEditing': 'Redaktəyə davam et',
'common.name': 'Ad',
'common.email': 'E-poçt',
'common.password': 'Parol',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'Renomear',
'common.discardChanges': 'Descartar alterações',
'common.discard': 'Descartar',
'common.unsavedTitle': 'Descartar suas alterações?',
'common.unsavedMessage': 'O que você inseriu aqui ainda não foi salvo.',
'common.keepEditing': 'Continuar editando',
'common.name': 'Nome',
'common.email': 'E-mail',
'common.password': 'Senha',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'Canviar nom',
'common.discardChanges': 'Descartar canvis',
'common.discard': 'Descartar',
'common.unsavedTitle': 'Vols descartar els canvis?',
'common.unsavedMessage': "El que has introduït aquí encara no s'ha desat.",
'common.keepEditing': 'Continua editant',
'common.name': 'Nom',
'common.email': 'Correu',
'common.password': 'Contrasenya',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'Přejmenovat',
'common.discardChanges': 'Zahodit změny',
'common.discard': 'Zahodit',
'common.unsavedTitle': 'Zahodit změny?',
'common.unsavedMessage': 'To, co jste zde zadali, ještě není uložené.',
'common.keepEditing': 'Pokračovat v úpravách',
'common.name': 'Jméno',
'common.email': 'E-mail',
'common.password': 'Heslo',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'Umbenennen',
'common.discardChanges': 'Änderungen verwerfen',
'common.discard': 'Verwerfen',
'common.unsavedTitle': 'Änderungen verwerfen?',
'common.unsavedMessage': 'Was du hier eingegeben hast, ist noch nicht gespeichert.',
'common.keepEditing': 'Weiter bearbeiten',
'common.name': 'Name',
'common.email': 'E-Mail',
'common.password': 'Passwort',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'Rename',
'common.discardChanges': 'Discard Changes',
'common.discard': 'Discard',
'common.unsavedTitle': 'Discard your changes?',
'common.unsavedMessage': 'What you entered here has not been saved yet.',
'common.keepEditing': 'Keep editing',
'common.name': 'Name',
'common.email': 'Email',
'common.password': 'Password',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'Renombrar',
'common.discardChanges': 'Descartar cambios',
'common.discard': 'Descartar',
'common.unsavedTitle': '¿Descartar los cambios?',
'common.unsavedMessage': 'Lo que has introducido aquí aún no se ha guardado.',
'common.keepEditing': 'Seguir editando',
'common.name': 'Nombre',
'common.email': 'Correo',
'common.password': 'Contraseña',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'Nimeta ümber',
'common.discardChanges': 'Loobu muudatustest',
'common.discard': 'Loobu',
'common.unsavedTitle': 'Kas loobuda muudatustest?',
'common.unsavedMessage': 'Siia sisestatu pole veel salvestatud.',
'common.keepEditing': 'Jätka muutmist',
'common.name': 'Nimi',
'common.email': 'E-post',
'common.password': 'Parool',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'Renommer',
'common.discardChanges': 'Ignorer les modifications',
'common.discard': 'Ignorer',
'common.unsavedTitle': 'Abandonner vos modifications ?',
'common.unsavedMessage': "Ce que vous avez saisi ici n'est pas encore enregistré.",
'common.keepEditing': 'Continuer la modification',
'common.name': 'Nom',
'common.email': 'E-mail',
'common.password': 'Mot de passe',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'Μετονομασία',
'common.discardChanges': 'Απόρριψη αλλαγών',
'common.discard': 'Απόρριψη',
'common.unsavedTitle': 'Απόρριψη των αλλαγών;',
'common.unsavedMessage': 'Όσα καταχωρίσατε εδώ δεν έχουν αποθηκευτεί ακόμη.',
'common.keepEditing': 'Συνέχεια επεξεργασίας',
'common.name': 'Όνομα',
'common.email': 'Email',
'common.password': 'Κωδικός',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'Átnevezés',
'common.discardChanges': 'Változtatások elvetése',
'common.discard': 'Elveti',
'common.unsavedTitle': 'Elveted a módosításokat?',
'common.unsavedMessage': 'Amit itt megadtál, még nincs elmentve.',
'common.keepEditing': 'Szerkesztés folytatása',
'common.name': 'Név',
'common.email': 'E-mail',
'common.password': 'Jelszó',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'Ganti nama',
'common.discardChanges': 'Buang perubahan',
'common.discard': 'Buang',
'common.unsavedTitle': 'Buang perubahan?',
'common.unsavedMessage': 'Yang kamu masukkan di sini belum disimpan.',
'common.keepEditing': 'Lanjut mengedit',
'common.name': 'Nama',
'common.email': 'Email',
'common.password': 'Kata sandi',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'Rinomina',
'common.discardChanges': 'Scarta modifiche',
'common.discard': 'Scarta',
'common.unsavedTitle': 'Scartare le modifiche?',
'common.unsavedMessage': 'Quello che hai inserito qui non è ancora stato salvato.',
'common.keepEditing': 'Continua a modificare',
'common.name': 'Nome',
'common.email': 'Email',
'common.password': 'Password',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': '名前を変更',
'common.discardChanges': '変更をキャンセル',
'common.discard': 'キャンセル',
'common.unsavedTitle': '変更を破棄しますか?',
'common.unsavedMessage': 'ここで入力した内容はまだ保存されていません。',
'common.keepEditing': '編集を続ける',
'common.name': '名前',
'common.email': 'メールアドレス',
'common.password': 'パスワード',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': '이름 변경',
'common.discardChanges': '변경 사항 취소',
'common.discard': '취소',
'common.unsavedTitle': '변경 사항을 버릴까요?',
'common.unsavedMessage': '여기에 입력한 내용은 아직 저장되지 않았습니다.',
'common.keepEditing': '계속 편집',
'common.name': '이름',
'common.email': '이메일',
'common.password': '비밀번호',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'Hernoemen',
'common.discardChanges': 'Wijzigingen verwerpen',
'common.discard': 'Verwerpen',
'common.unsavedTitle': 'Wijzigingen weggooien?',
'common.unsavedMessage': 'Wat je hier hebt ingevuld, is nog niet opgeslagen.',
'common.keepEditing': 'Verder bewerken',
'common.name': 'Naam',
'common.email': 'E-mail',
'common.password': 'Wachtwoord',
+3
View File
@@ -31,6 +31,9 @@ const common: TranslationStrings = {
'common.rename': 'Zmień nazwę',
'common.discardChanges': 'Odrzuć zmiany',
'common.discard': 'Odrzuć',
'common.unsavedTitle': 'Odrzucić zmiany?',
'common.unsavedMessage': 'To, co tu wpisano, nie zostało jeszcze zapisane.',
'common.keepEditing': 'Kontynuuj edycję',
'common.name': 'Nazwa',
'common.email': 'E-mail',
'common.password': 'Hasło',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'Переименовать',
'common.discardChanges': 'Отменить изменения',
'common.discard': 'Отменить',
'common.unsavedTitle': 'Отменить изменения?',
'common.unsavedMessage': 'Введённое здесь ещё не сохранено.',
'common.keepEditing': 'Продолжить',
'common.name': 'Имя',
'common.email': 'Эл. почта',
'common.password': 'Пароль',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'Premenovať',
'common.discardChanges': 'Zahodiť zmeny',
'common.discard': 'Zahodiť',
'common.unsavedTitle': 'Zahodiť zmeny?',
'common.unsavedMessage': 'To, čo ste tu zadali, ešte nie je uložené.',
'common.keepEditing': 'Pokračovať v úpravách',
'common.name': 'Meno',
'common.email': 'E-mail',
'common.password': 'Heslo',
+3
View File
@@ -47,6 +47,9 @@ const common: TranslationStrings = {
'common.rename': 'Döp om',
'common.discardChanges': 'Ångra ändringar',
'common.discard': 'Ångra',
'common.unsavedTitle': 'Slänga ändringarna?',
'common.unsavedMessage': 'Det du har fyllt i här är inte sparat än.',
'common.keepEditing': 'Fortsätt redigera',
'common.name': 'Namn',
'common.email': 'E-post',
'common.password': 'Lösenord',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': "เปลี่ยนชื่อ",
'common.discardChanges': "ยกเลิกการเปลี่ยนแปลง",
'common.discard': "ทิ้ง",
'common.unsavedTitle': 'ทิ้งการเปลี่ยนแปลงไหม',
'common.unsavedMessage': 'สิ่งที่คุณกรอกไว้ที่นี่ยังไม่ได้บันทึก',
'common.keepEditing': 'แก้ไขต่อ',
'common.name': "ชื่อ",
'common.email': "อีเมล",
'common.password': "รหัสผ่าน",
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'Yeniden adlandır',
'common.discardChanges': 'Değişiklikleri iptal et',
'common.discard': 'Vazgeç',
'common.unsavedTitle': 'Değişiklikler atılsın mı?',
'common.unsavedMessage': 'Buraya girdikleriniz henüz kaydedilmedi.',
'common.keepEditing': 'Düzenlemeye devam et',
'common.name': 'Ad',
'common.email': 'E-posta',
'common.password': 'Şifre',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': 'Перейменувати',
'common.discardChanges': 'Скасувати зміни',
'common.discard': 'Скасувати',
'common.unsavedTitle': 'Скасувати зміни?',
'common.unsavedMessage': 'Введене тут ще не збережено.',
'common.keepEditing': 'Продовжити редагування',
'common.name': "Ім'я",
'common.email': 'Ел. пошта',
'common.password': 'Пароль',
+3
View File
@@ -47,6 +47,9 @@ const common: TranslationStrings = {
'common.rename': 'Đổi tên',
'common.discardChanges': 'Hủy thay đổi',
'common.discard': 'Bỏ',
'common.unsavedTitle': 'Bỏ các thay đổi?',
'common.unsavedMessage': 'Những gì bạn nhập ở đây chưa được lưu.',
'common.keepEditing': 'Tiếp tục chỉnh sửa',
'common.name': 'Tên',
'common.email': 'Email',
'common.password': 'Mật khẩu',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': '重新命名',
'common.discardChanges': '捨棄變更',
'common.discard': '捨棄',
'common.unsavedTitle': '放棄變更?',
'common.unsavedMessage': '你在這裡輸入的內容尚未儲存。',
'common.keepEditing': '繼續編輯',
'common.name': '名稱',
'common.email': '郵箱',
'common.password': '密碼',
+3
View File
@@ -35,6 +35,9 @@ const common: TranslationStrings = {
'common.rename': '重命名',
'common.discardChanges': '放弃更改',
'common.discard': '放弃',
'common.unsavedTitle': '放弃更改?',
'common.unsavedMessage': '你在这里输入的内容尚未保存。',
'common.keepEditing': '继续编辑',
'common.name': '名称',
'common.email': '邮箱',
'common.password': '密码',