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
+5
View File
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.25.4] - 2026-04-26
### Added
- Modal: closing a modal (via Escape, swipe, overlay click, or X button) now shows a "Discard changes?" confirmation dialog when the form has been modified since it was opened; saving or deleting bypasses the prompt
## [0.25.3] - 2026-04-26 ## [0.25.3] - 2026-04-26
### Changed ### Changed
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "oikos", "name": "oikos",
"version": "0.25.3", "version": "0.25.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "oikos", "name": "oikos",
"version": "0.25.3", "version": "0.25.4",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "oikos", "name": "oikos",
"version": "0.25.3", "version": "0.25.4",
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
"main": "server/index.js", "main": "server/index.js",
"type": "module", "type": "module",
+39 -2
View File
@@ -16,6 +16,7 @@ import { t } from '/i18n.js';
let activeOverlay = null; let activeOverlay = null;
let previouslyFocused = null; let previouslyFocused = null;
let focusTrapHandler = null; let focusTrapHandler = null;
let _initialFormSnapshot = null;
// Overlay-Dimming: theme-color abdunkeln im Standalone-Modus // Overlay-Dimming: theme-color abdunkeln im Standalone-Modus
const OVERLAY_THEME_COLOR = '#1A1A1A'; 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 // 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 // ID sofort entfernen damit getElementById() nach dem Einfügen des neuen Modals
// nicht die noch animierende alte Instanz zurückgibt sonst landen alle // nicht die noch animierende alte Instanz zurückgibt sonst landen alle
// Event-Listener am falschen Element und Buttons reagieren nicht. // Event-Listener am falschen Element und Buttons reagieren nicht.
// force=true: kein Dirty-Check beim programmatischen Ersetzen (z.B. confirmModal öffnet sich).
if (activeOverlay) { if (activeOverlay) {
activeOverlay.removeAttribute('id'); activeOverlay.removeAttribute('id');
closeModal(); closeModal({ force: true });
} }
// Focus-Restore vorbereiten // Focus-Restore vorbereiten
@@ -243,6 +259,14 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
const panel = activeOverlay.querySelector('.modal-panel'); const panel = activeOverlay.querySelector('.modal-panel');
trapFocus(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 // Swipe-to-Close auf Mobile
if (window.innerWidth < 768) { if (window.innerWidth < 768) {
_wireSheetSwipe(panel); _wireSheetSwipe(panel);
@@ -279,9 +303,22 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
// closeModal // closeModal
// -------------------------------------------------------- // --------------------------------------------------------
export function closeModal() { export async function closeModal({ force = false } = {}) {
if (!activeOverlay) return; 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); document.removeEventListener('keydown', onEscape);
// Overlay sofort sichern: Bei Mobile-Animation öffnet openModal() ein neues Modal // Overlay sofort sichern: Bei Mobile-Animation öffnet openModal() ein neues Modal
+3 -1
View File
@@ -703,7 +703,9 @@
}, },
"modal": { "modal": {
"closeLabel": "إغلاق", "closeLabel": "إغلاق",
"overlayLabel": "خلفية مربع الحوار" "overlayLabel": "خلفية مربع الحوار",
"unsavedChanges": "تجاهل التغييرات؟",
"discardChanges": "تجاهل"
}, },
"rrule": { "rrule": {
"freqNone": "بدون تكرار", "freqNone": "بدون تكرار",
+3 -1
View File
@@ -709,7 +709,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Schließen", "closeLabel": "Schließen",
"overlayLabel": "Modaler Dialog-Hintergrund" "overlayLabel": "Modaler Dialog-Hintergrund",
"unsavedChanges": "Änderungen verwerfen?",
"discardChanges": "Verwerfen"
}, },
"rrule": { "rrule": {
"freqNone": "Keine Wiederholung", "freqNone": "Keine Wiederholung",
+3 -1
View File
@@ -703,7 +703,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Κλείσιμο", "closeLabel": "Κλείσιμο",
"overlayLabel": "Φόντο αναδυόμενου παραθύρου" "overlayLabel": "Φόντο αναδυόμενου παραθύρου",
"unsavedChanges": "Απόρριψη αλλαγών;",
"discardChanges": "Απόρριψη"
}, },
"rrule": { "rrule": {
"freqNone": "Χωρίς επανάληψη", "freqNone": "Χωρίς επανάληψη",
+3 -1
View File
@@ -703,7 +703,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Close", "closeLabel": "Close",
"overlayLabel": "Modal dialog background" "overlayLabel": "Modal dialog background",
"unsavedChanges": "Discard changes?",
"discardChanges": "Discard"
}, },
"rrule": { "rrule": {
"freqNone": "No recurrence", "freqNone": "No recurrence",
+3 -1
View File
@@ -703,7 +703,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Cerrar", "closeLabel": "Cerrar",
"overlayLabel": "Fondo del cuadro de diálogo modal" "overlayLabel": "Fondo del cuadro de diálogo modal",
"unsavedChanges": "¿Descartar cambios?",
"discardChanges": "Descartar"
}, },
"rrule": { "rrule": {
"freqNone": "Sin repetición", "freqNone": "Sin repetición",
+3 -1
View File
@@ -703,7 +703,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Fermer", "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": { "rrule": {
"freqNone": "Pas de répétition", "freqNone": "Pas de répétition",
+3 -1
View File
@@ -703,7 +703,9 @@
}, },
"modal": { "modal": {
"closeLabel": "बंद करें", "closeLabel": "बंद करें",
"overlayLabel": "मोडल डायलॉग पृष्ठभूमि" "overlayLabel": "मोडल डायलॉग पृष्ठभूमि",
"unsavedChanges": "बदलाव छोड़ें?",
"discardChanges": "छोड़ें"
}, },
"rrule": { "rrule": {
"freqNone": "कोई दोहराव नहीं", "freqNone": "कोई दोहराव नहीं",
+3 -1
View File
@@ -703,7 +703,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Chiudi", "closeLabel": "Chiudi",
"overlayLabel": "Sfondo del dialogo modale" "overlayLabel": "Sfondo del dialogo modale",
"unsavedChanges": "Annullare le modifiche?",
"discardChanges": "Annulla"
}, },
"rrule": { "rrule": {
"freqNone": "Nessuna ripetizione", "freqNone": "Nessuna ripetizione",
+3 -1
View File
@@ -703,7 +703,9 @@
}, },
"modal": { "modal": {
"closeLabel": "閉じる", "closeLabel": "閉じる",
"overlayLabel": "モーダルダイアログの背景" "overlayLabel": "モーダルダイアログの背景",
"unsavedChanges": "変更を破棄しますか?",
"discardChanges": "破棄"
}, },
"rrule": { "rrule": {
"freqNone": "繰り返しなし", "freqNone": "繰り返しなし",
+3 -1
View File
@@ -703,7 +703,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Fechar", "closeLabel": "Fechar",
"overlayLabel": "Fundo do diálogo modal" "overlayLabel": "Fundo do diálogo modal",
"unsavedChanges": "Descartar alterações?",
"discardChanges": "Descartar"
}, },
"rrule": { "rrule": {
"freqNone": "Sem repetição", "freqNone": "Sem repetição",
+3 -1
View File
@@ -703,7 +703,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Закрыть", "closeLabel": "Закрыть",
"overlayLabel": "Фон модального диалога" "overlayLabel": "Фон модального диалога",
"unsavedChanges": "Отменить изменения?",
"discardChanges": "Отменить"
}, },
"rrule": { "rrule": {
"freqNone": "Без повтора", "freqNone": "Без повтора",
+3 -1
View File
@@ -703,7 +703,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Stäng", "closeLabel": "Stäng",
"overlayLabel": "Bakgrund för modal dialog" "overlayLabel": "Bakgrund för modal dialog",
"unsavedChanges": "Ignorera ändringar?",
"discardChanges": "Ignorera"
}, },
"rrule": { "rrule": {
"freqNone": "Ingen upprepning", "freqNone": "Ingen upprepning",
+3 -1
View File
@@ -703,7 +703,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Kapat", "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": { "rrule": {
"freqNone": "Tekrar yok", "freqNone": "Tekrar yok",
+3 -1
View File
@@ -703,7 +703,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Закрити", "closeLabel": "Закрити",
"overlayLabel": "Фон модального вікна" "overlayLabel": "Фон модального вікна",
"unsavedChanges": "Скасувати зміни?",
"discardChanges": "Скасувати"
}, },
"rrule": { "rrule": {
"freqNone": "Без повторення", "freqNone": "Без повторення",
+3 -1
View File
@@ -703,7 +703,9 @@
}, },
"modal": { "modal": {
"closeLabel": "关闭", "closeLabel": "关闭",
"overlayLabel": "模态对话框背景" "overlayLabel": "模态对话框背景",
"unsavedChanges": "放弃更改?",
"discardChanges": "放弃"
}, },
"rrule": { "rrule": {
"freqNone": "不重复", "freqNone": "不重复",
+2 -2
View File
@@ -607,7 +607,7 @@ function openBudgetModal({ mode, entry = null }) {
panel.querySelector('#bm-cancel').addEventListener('click', closeModal); panel.querySelector('#bm-cancel').addEventListener('click', closeModal);
panel.querySelector('#bm-delete')?.addEventListener('click', async () => { panel.querySelector('#bm-delete')?.addEventListener('click', async () => {
closeModal(); closeModal({ force: true });
await deleteEntry(entry.id); await deleteEntry(entry.id);
}); });
@@ -642,7 +642,7 @@ function openBudgetModal({ mode, entry = null }) {
const sumRes = await api.get(`/budget/summary?month=${state.month}`); const sumRes = await api.get(`/budget/summary?month=${state.month}`);
state.summary = sumRes.data; state.summary = sumRes.data;
closeModal(); closeModal({ force: true });
renderBody(); renderBody();
window.oikos?.showToast(mode === 'create' ? t('budget.addedToast') : t('budget.savedToast'), 'success'); window.oikos?.showToast(mode === 'create' ? t('budget.addedToast') : t('budget.savedToast'), 'success');
} catch (err) { } 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-cancel').addEventListener('click', closeModal);
panel.querySelector('#modal-delete')?.addEventListener('click', async () => { panel.querySelector('#modal-delete')?.addEventListener('click', async () => {
closeModal(); closeModal({ force: true });
await deleteEvent(event.id); await deleteEvent(event.id);
}); });
@@ -1060,7 +1060,7 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) {
} }
} }
closeModal(); closeModal({ force: true });
renderView(); renderView();
window.oikos?.showToast(mode === 'create' ? t('calendar.createdToast') : t('calendar.savedToast'), 'success'); window.oikos?.showToast(mode === 'create' ? t('calendar.createdToast') : t('calendar.savedToast'), 'success');
} catch (err) { } 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-cancel').addEventListener('click', closeModal);
panel.querySelector('#cm-delete')?.addEventListener('click', async () => { panel.querySelector('#cm-delete')?.addEventListener('click', async () => {
closeModal(); closeModal({ force: true });
await deleteContact(contact.id); await deleteContact(contact.id);
}); });
@@ -336,7 +336,7 @@ function openContactModal({ mode, contact = null }) {
const idx = state.contacts.findIndex((c) => c.id === contact.id); const idx = state.contacts.findIndex((c) => c.id === contact.id);
if (idx !== -1) state.contacts[idx] = res.data; if (idx !== -1) state.contacts[idx] = res.data;
} }
closeModal(); closeModal({ force: true });
renderList(); renderList();
window.oikos?.showToast(mode === 'create' ? t('contacts.savedToast') : t('contacts.updatedToast'), 'success'); window.oikos?.showToast(mode === 'create' ? t('contacts.savedToast') : t('contacts.updatedToast'), 'success');
} catch (err) { } catch (err) {
+3 -3
View File
@@ -637,7 +637,7 @@ function openCustomizeModal(currentConfig, onSave) {
saveBtn.disabled = true; saveBtn.disabled = true;
try { try {
await api.put('/preferences', { dashboard_widgets: draft }); await api.put('/preferences', { dashboard_widgets: draft });
closeModal(); closeModal({ force: true });
onSave(draft); onSave(draft);
window.oikos?.showToast(t('dashboard.customizeSaved'), 'success', 1500); window.oikos?.showToast(t('dashboard.customizeSaved'), 'success', 1500);
} catch { } catch {
@@ -674,7 +674,7 @@ function openTaskQuickAction(taskId, taskTitle, rerender) {
panel.querySelector('[data-action="done"]').addEventListener('click', async () => { panel.querySelector('[data-action="done"]').addEventListener('click', async () => {
try { try {
await api.patch(`/tasks/${taskId}/status`, { status: 'done' }); await api.patch(`/tasks/${taskId}/status`, { status: 'done' });
closeModal(); closeModal({ force: true });
window.oikos?.showToast(t('tasks.swipedDoneToast'), 'success'); window.oikos?.showToast(t('tasks.swipedDoneToast'), 'success');
rerender(); rerender();
} catch (err) { } catch (err) {
@@ -682,7 +682,7 @@ function openTaskQuickAction(taskId, taskTitle, rerender) {
} }
}); });
panel.querySelector('[data-action="edit"]').addEventListener('click', () => { panel.querySelector('[data-action="edit"]').addEventListener('click', () => {
closeModal(); closeModal({ force: true });
window.oikos.navigate(`/tasks?open=${taskId}`); window.oikos.navigate(`/tasks?open=${taskId}`);
}); });
}, },
+4 -4
View File
@@ -707,7 +707,7 @@ function openMealModal(opts) {
if (res.data.transferred > 0) { 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'); 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); await loadWeek(state.currentWeek);
closeModal(); closeModal({ force: true });
renderWeekGrid(); renderWeekGrid();
} else { } else {
window.oikos?.showToast(t('meals.transferAlreadyDone'), 'info'); window.oikos?.showToast(t('meals.transferAlreadyDone'), 'info');
@@ -843,8 +843,8 @@ function ingredientRowHTML(name, qty, id, category = DEFAULT_CATEGORY_NAME) {
`; `;
} }
function closeModal() { function closeModal({ force = false } = {}) {
closeSharedModal(); closeSharedModal({ force });
state.modal = null; state.modal = null;
} }
@@ -894,7 +894,7 @@ async function saveModal(overlay) {
await loadWeek(state.currentWeek); await loadWeek(state.currentWeek);
} }
closeModal(); closeModal({ force: true });
renderWeekGrid(); renderWeekGrid();
window.oikos?.showToast(mode === 'create' ? t('meals.addMealTitle') : t('meals.editMeal'), 'success'); window.oikos?.showToast(mode === 'create' ? t('meals.addMealTitle') : t('meals.editMeal'), 'success');
} catch (err) { } catch (err) {
+2 -2
View File
@@ -445,7 +445,7 @@ function openNoteModal({ mode, note = null }) {
if (idx !== -1) state.notes[idx] = res.data; if (idx !== -1) state.notes[idx] = res.data;
state.notes.sort((a, b) => b.pinned - a.pinned); state.notes.sort((a, b) => b.pinned - a.pinned);
} }
closeModal(); closeModal({ force: true });
renderGrid(); renderGrid();
window.oikos?.showToast(mode === 'create' ? t('notes.createdToast') : t('notes.savedToast'), 'success'); window.oikos?.showToast(mode === 'create' ? t('notes.createdToast') : t('notes.savedToast'), 'success');
} catch (err) { } catch (err) {
@@ -476,7 +476,7 @@ async function togglePin(id) {
} }
async function deleteNote(id) { async function deleteNote(id) {
closeModal(); closeModal({ force: true });
const note = state.notes.find((n) => n.id === id); const note = state.notes.find((n) => n.id === id);
state.notes = state.notes.filter((n) => n.id !== id); state.notes = state.notes.filter((n) => n.id !== id);
renderGrid(); renderGrid();
+3 -3
View File
@@ -326,8 +326,8 @@ function openRecipeModal(mode, recipe = null) {
}); });
} }
function closeModal() { function closeModal({ force = false } = {}) {
closeSharedModal(); closeSharedModal({ force });
} }
async function saveRecipe(panel, mode, recipe) { async function saveRecipe(panel, mode, recipe) {
@@ -361,7 +361,7 @@ async function saveRecipe(panel, mode, recipe) {
if (idx >= 0) state.recipes[idx] = res.data; if (idx >= 0) state.recipes[idx] = res.data;
} }
closeModal(); closeModal({ force: true });
renderRecipeList(); renderRecipeList();
window.oikos?.showToast(mode === 'create' ? t('recipes.created') : t('recipes.updated'), 'success'); window.oikos?.showToast(mode === 'create' ? t('recipes.created') : t('recipes.updated'), 'success');
} catch (err) { } catch (err) {
+2 -2
View File
@@ -570,7 +570,7 @@ async function handleFormSubmit(e, container) {
} }
btnSuccess(submitBtn, originalLabel); btnSuccess(submitBtn, originalLabel);
setTimeout(() => closeModal(), 700); setTimeout(() => closeModal({ force: true }), 700);
await loadTasks(container); await loadTasks(container);
} catch (err) { } catch (err) {
errorEl.textContent = err.message; errorEl.textContent = err.message;
@@ -582,7 +582,7 @@ async function handleFormSubmit(e, container) {
} }
async function handleDeleteTask(id, container) { async function handleDeleteTask(id, container) {
closeModal(); closeModal({ force: true });
const itemEl = container.querySelector(`[data-task-id="${id}"]`); const itemEl = container.querySelector(`[data-task-id="${id}"]`);
if (itemEl) itemEl.style.display = 'none'; if (itemEl) itemEl.style.display = 'none';