feat(modal): warn before closing with unsaved changes

This commit is contained in:
Ulas Kalayci
2026-04-26 19:03:38 +02:00
parent 798f8ca87a
commit ed0f8b2d57
27 changed files with 112 additions and 40 deletions
+39 -2
View File
@@ -16,6 +16,7 @@ import { t } from '/i18n.js';
let activeOverlay = null;
let previouslyFocused = null;
let focusTrapHandler = null;
let _initialFormSnapshot = null;
// Overlay-Dimming: theme-color abdunkeln im Standalone-Modus
const OVERLAY_THEME_COLOR = '#1A1A1A';
@@ -98,6 +99,20 @@ function trapFocus(container) {
}
}
// --------------------------------------------------------
// Dirty-Check Helpers
// --------------------------------------------------------
function serializeForm(container) {
const inputs = container.querySelectorAll('input, select, textarea');
return Array.from(inputs).map((el) => `${el.name || el.id}=${el.value}`).join('&');
}
function isFormDirty(container) {
if (!_initialFormSnapshot) return false;
return serializeForm(container) !== _initialFormSnapshot;
}
// --------------------------------------------------------
// Escape-Handler
// --------------------------------------------------------
@@ -204,9 +219,10 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
// ID sofort entfernen damit getElementById() nach dem Einfügen des neuen Modals
// nicht die noch animierende alte Instanz zurückgibt sonst landen alle
// Event-Listener am falschen Element und Buttons reagieren nicht.
// force=true: kein Dirty-Check beim programmatischen Ersetzen (z.B. confirmModal öffnet sich).
if (activeOverlay) {
activeOverlay.removeAttribute('id');
closeModal();
closeModal({ force: true });
}
// Focus-Restore vorbereiten
@@ -243,6 +259,14 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
const panel = activeOverlay.querySelector('.modal-panel');
trapFocus(panel);
// Snapshot für Dirty-Check (kurzer Delay: Felder könnten noch per JS befüllt werden)
_initialFormSnapshot = null;
setTimeout(() => {
if (activeOverlay) {
_initialFormSnapshot = serializeForm(activeOverlay.querySelector('.modal-panel') ?? activeOverlay);
}
}, 150);
// Swipe-to-Close auf Mobile
if (window.innerWidth < 768) {
_wireSheetSwipe(panel);
@@ -279,9 +303,22 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
// closeModal
// --------------------------------------------------------
export function closeModal() {
export async function closeModal({ force = false } = {}) {
if (!activeOverlay) return;
if (!force) {
const panel = activeOverlay.querySelector('.modal-panel');
if (panel && isFormDirty(panel)) {
const confirmed = await confirmModal(t('modal.unsavedChanges'), {
danger: false,
confirmLabel: t('modal.discardChanges'),
});
if (!confirmed) return;
}
}
_initialFormSnapshot = null;
document.removeEventListener('keydown', onEscape);
// Overlay sofort sichern: Bei Mobile-Animation öffnet openModal() ein neues Modal
+3 -1
View File
@@ -703,7 +703,9 @@
},
"modal": {
"closeLabel": "إغلاق",
"overlayLabel": "خلفية مربع الحوار"
"overlayLabel": "خلفية مربع الحوار",
"unsavedChanges": "تجاهل التغييرات؟",
"discardChanges": "تجاهل"
},
"rrule": {
"freqNone": "بدون تكرار",
+3 -1
View File
@@ -709,7 +709,9 @@
},
"modal": {
"closeLabel": "Schließen",
"overlayLabel": "Modaler Dialog-Hintergrund"
"overlayLabel": "Modaler Dialog-Hintergrund",
"unsavedChanges": "Änderungen verwerfen?",
"discardChanges": "Verwerfen"
},
"rrule": {
"freqNone": "Keine Wiederholung",
+3 -1
View File
@@ -703,7 +703,9 @@
},
"modal": {
"closeLabel": "Κλείσιμο",
"overlayLabel": "Φόντο αναδυόμενου παραθύρου"
"overlayLabel": "Φόντο αναδυόμενου παραθύρου",
"unsavedChanges": "Απόρριψη αλλαγών;",
"discardChanges": "Απόρριψη"
},
"rrule": {
"freqNone": "Χωρίς επανάληψη",
+3 -1
View File
@@ -703,7 +703,9 @@
},
"modal": {
"closeLabel": "Close",
"overlayLabel": "Modal dialog background"
"overlayLabel": "Modal dialog background",
"unsavedChanges": "Discard changes?",
"discardChanges": "Discard"
},
"rrule": {
"freqNone": "No recurrence",
+3 -1
View File
@@ -703,7 +703,9 @@
},
"modal": {
"closeLabel": "Cerrar",
"overlayLabel": "Fondo del cuadro de diálogo modal"
"overlayLabel": "Fondo del cuadro de diálogo modal",
"unsavedChanges": "¿Descartar cambios?",
"discardChanges": "Descartar"
},
"rrule": {
"freqNone": "Sin repetición",
+3 -1
View File
@@ -703,7 +703,9 @@
},
"modal": {
"closeLabel": "Fermer",
"overlayLabel": "Arrière-plan de la boîte de dialogue modale"
"overlayLabel": "Arrière-plan de la boîte de dialogue modale",
"unsavedChanges": "Abandonner les modifications ?",
"discardChanges": "Abandonner"
},
"rrule": {
"freqNone": "Pas de répétition",
+3 -1
View File
@@ -703,7 +703,9 @@
},
"modal": {
"closeLabel": "बंद करें",
"overlayLabel": "मोडल डायलॉग पृष्ठभूमि"
"overlayLabel": "मोडल डायलॉग पृष्ठभूमि",
"unsavedChanges": "बदलाव छोड़ें?",
"discardChanges": "छोड़ें"
},
"rrule": {
"freqNone": "कोई दोहराव नहीं",
+3 -1
View File
@@ -703,7 +703,9 @@
},
"modal": {
"closeLabel": "Chiudi",
"overlayLabel": "Sfondo del dialogo modale"
"overlayLabel": "Sfondo del dialogo modale",
"unsavedChanges": "Annullare le modifiche?",
"discardChanges": "Annulla"
},
"rrule": {
"freqNone": "Nessuna ripetizione",
+3 -1
View File
@@ -703,7 +703,9 @@
},
"modal": {
"closeLabel": "閉じる",
"overlayLabel": "モーダルダイアログの背景"
"overlayLabel": "モーダルダイアログの背景",
"unsavedChanges": "変更を破棄しますか?",
"discardChanges": "破棄"
},
"rrule": {
"freqNone": "繰り返しなし",
+3 -1
View File
@@ -703,7 +703,9 @@
},
"modal": {
"closeLabel": "Fechar",
"overlayLabel": "Fundo do diálogo modal"
"overlayLabel": "Fundo do diálogo modal",
"unsavedChanges": "Descartar alterações?",
"discardChanges": "Descartar"
},
"rrule": {
"freqNone": "Sem repetição",
+3 -1
View File
@@ -703,7 +703,9 @@
},
"modal": {
"closeLabel": "Закрыть",
"overlayLabel": "Фон модального диалога"
"overlayLabel": "Фон модального диалога",
"unsavedChanges": "Отменить изменения?",
"discardChanges": "Отменить"
},
"rrule": {
"freqNone": "Без повтора",
+3 -1
View File
@@ -703,7 +703,9 @@
},
"modal": {
"closeLabel": "Stäng",
"overlayLabel": "Bakgrund för modal dialog"
"overlayLabel": "Bakgrund för modal dialog",
"unsavedChanges": "Ignorera ändringar?",
"discardChanges": "Ignorera"
},
"rrule": {
"freqNone": "Ingen upprepning",
+3 -1
View File
@@ -703,7 +703,9 @@
},
"modal": {
"closeLabel": "Kapat",
"overlayLabel": "Modal iletişim kutusu arka planı"
"overlayLabel": "Modal iletişim kutusu arka planı",
"unsavedChanges": "Değişiklikler iptal edilsin mi?",
"discardChanges": "İptal et"
},
"rrule": {
"freqNone": "Tekrar yok",
+3 -1
View File
@@ -703,7 +703,9 @@
},
"modal": {
"closeLabel": "Закрити",
"overlayLabel": "Фон модального вікна"
"overlayLabel": "Фон модального вікна",
"unsavedChanges": "Скасувати зміни?",
"discardChanges": "Скасувати"
},
"rrule": {
"freqNone": "Без повторення",
+3 -1
View File
@@ -703,7 +703,9 @@
},
"modal": {
"closeLabel": "关闭",
"overlayLabel": "模态对话框背景"
"overlayLabel": "模态对话框背景",
"unsavedChanges": "放弃更改?",
"discardChanges": "放弃"
},
"rrule": {
"freqNone": "不重复",
+2 -2
View File
@@ -607,7 +607,7 @@ function openBudgetModal({ mode, entry = null }) {
panel.querySelector('#bm-cancel').addEventListener('click', closeModal);
panel.querySelector('#bm-delete')?.addEventListener('click', async () => {
closeModal();
closeModal({ force: true });
await deleteEntry(entry.id);
});
@@ -642,7 +642,7 @@ function openBudgetModal({ mode, entry = null }) {
const sumRes = await api.get(`/budget/summary?month=${state.month}`);
state.summary = sumRes.data;
closeModal();
closeModal({ force: true });
renderBody();
window.oikos?.showToast(mode === 'create' ? t('budget.addedToast') : t('budget.savedToast'), 'success');
} catch (err) {
+2 -2
View File
@@ -867,7 +867,7 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) {
panel.querySelector('#modal-cancel').addEventListener('click', closeModal);
panel.querySelector('#modal-delete')?.addEventListener('click', async () => {
closeModal();
closeModal({ force: true });
await deleteEvent(event.id);
});
@@ -1060,7 +1060,7 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) {
}
}
closeModal();
closeModal({ force: true });
renderView();
window.oikos?.showToast(mode === 'create' ? t('calendar.createdToast') : t('calendar.savedToast'), 'success');
} catch (err) {
+2 -2
View File
@@ -304,7 +304,7 @@ function openContactModal({ mode, contact = null }) {
panel.querySelector('#cm-cancel').addEventListener('click', closeModal);
panel.querySelector('#cm-delete')?.addEventListener('click', async () => {
closeModal();
closeModal({ force: true });
await deleteContact(contact.id);
});
@@ -336,7 +336,7 @@ function openContactModal({ mode, contact = null }) {
const idx = state.contacts.findIndex((c) => c.id === contact.id);
if (idx !== -1) state.contacts[idx] = res.data;
}
closeModal();
closeModal({ force: true });
renderList();
window.oikos?.showToast(mode === 'create' ? t('contacts.savedToast') : t('contacts.updatedToast'), 'success');
} catch (err) {
+3 -3
View File
@@ -637,7 +637,7 @@ function openCustomizeModal(currentConfig, onSave) {
saveBtn.disabled = true;
try {
await api.put('/preferences', { dashboard_widgets: draft });
closeModal();
closeModal({ force: true });
onSave(draft);
window.oikos?.showToast(t('dashboard.customizeSaved'), 'success', 1500);
} catch {
@@ -674,7 +674,7 @@ function openTaskQuickAction(taskId, taskTitle, rerender) {
panel.querySelector('[data-action="done"]').addEventListener('click', async () => {
try {
await api.patch(`/tasks/${taskId}/status`, { status: 'done' });
closeModal();
closeModal({ force: true });
window.oikos?.showToast(t('tasks.swipedDoneToast'), 'success');
rerender();
} catch (err) {
@@ -682,7 +682,7 @@ function openTaskQuickAction(taskId, taskTitle, rerender) {
}
});
panel.querySelector('[data-action="edit"]').addEventListener('click', () => {
closeModal();
closeModal({ force: true });
window.oikos.navigate(`/tasks?open=${taskId}`);
});
},
+4 -4
View File
@@ -707,7 +707,7 @@ function openMealModal(opts) {
if (res.data.transferred > 0) {
window.oikos?.showToast(res.data.transferred !== 1 ? t('meals.transferSuccessPlural', { count: res.data.transferred }) : t('meals.transferSuccess', { count: res.data.transferred }), 'success');
await loadWeek(state.currentWeek);
closeModal();
closeModal({ force: true });
renderWeekGrid();
} else {
window.oikos?.showToast(t('meals.transferAlreadyDone'), 'info');
@@ -843,8 +843,8 @@ function ingredientRowHTML(name, qty, id, category = DEFAULT_CATEGORY_NAME) {
`;
}
function closeModal() {
closeSharedModal();
function closeModal({ force = false } = {}) {
closeSharedModal({ force });
state.modal = null;
}
@@ -894,7 +894,7 @@ async function saveModal(overlay) {
await loadWeek(state.currentWeek);
}
closeModal();
closeModal({ force: true });
renderWeekGrid();
window.oikos?.showToast(mode === 'create' ? t('meals.addMealTitle') : t('meals.editMeal'), 'success');
} catch (err) {
+2 -2
View File
@@ -445,7 +445,7 @@ function openNoteModal({ mode, note = null }) {
if (idx !== -1) state.notes[idx] = res.data;
state.notes.sort((a, b) => b.pinned - a.pinned);
}
closeModal();
closeModal({ force: true });
renderGrid();
window.oikos?.showToast(mode === 'create' ? t('notes.createdToast') : t('notes.savedToast'), 'success');
} catch (err) {
@@ -476,7 +476,7 @@ async function togglePin(id) {
}
async function deleteNote(id) {
closeModal();
closeModal({ force: true });
const note = state.notes.find((n) => n.id === id);
state.notes = state.notes.filter((n) => n.id !== id);
renderGrid();
+3 -3
View File
@@ -326,8 +326,8 @@ function openRecipeModal(mode, recipe = null) {
});
}
function closeModal() {
closeSharedModal();
function closeModal({ force = false } = {}) {
closeSharedModal({ force });
}
async function saveRecipe(panel, mode, recipe) {
@@ -361,7 +361,7 @@ async function saveRecipe(panel, mode, recipe) {
if (idx >= 0) state.recipes[idx] = res.data;
}
closeModal();
closeModal({ force: true });
renderRecipeList();
window.oikos?.showToast(mode === 'create' ? t('recipes.created') : t('recipes.updated'), 'success');
} catch (err) {
+2 -2
View File
@@ -570,7 +570,7 @@ async function handleFormSubmit(e, container) {
}
btnSuccess(submitBtn, originalLabel);
setTimeout(() => closeModal(), 700);
setTimeout(() => closeModal({ force: true }), 700);
await loadTasks(container);
} catch (err) {
errorEl.textContent = err.message;
@@ -582,7 +582,7 @@ async function handleFormSubmit(e, container) {
}
async function handleDeleteTask(id, container) {
closeModal();
closeModal({ force: true });
const itemEl = container.querySelector(`[data-task-id="${id}"]`);
if (itemEl) itemEl.style.display = 'none';