feat(modal): warn before closing with unsaved changes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -703,7 +703,9 @@
|
||||
},
|
||||
"modal": {
|
||||
"closeLabel": "إغلاق",
|
||||
"overlayLabel": "خلفية مربع الحوار"
|
||||
"overlayLabel": "خلفية مربع الحوار",
|
||||
"unsavedChanges": "تجاهل التغييرات؟",
|
||||
"discardChanges": "تجاهل"
|
||||
},
|
||||
"rrule": {
|
||||
"freqNone": "بدون تكرار",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -703,7 +703,9 @@
|
||||
},
|
||||
"modal": {
|
||||
"closeLabel": "Κλείσιμο",
|
||||
"overlayLabel": "Φόντο αναδυόμενου παραθύρου"
|
||||
"overlayLabel": "Φόντο αναδυόμενου παραθύρου",
|
||||
"unsavedChanges": "Απόρριψη αλλαγών;",
|
||||
"discardChanges": "Απόρριψη"
|
||||
},
|
||||
"rrule": {
|
||||
"freqNone": "Χωρίς επανάληψη",
|
||||
|
||||
@@ -703,7 +703,9 @@
|
||||
},
|
||||
"modal": {
|
||||
"closeLabel": "Close",
|
||||
"overlayLabel": "Modal dialog background"
|
||||
"overlayLabel": "Modal dialog background",
|
||||
"unsavedChanges": "Discard changes?",
|
||||
"discardChanges": "Discard"
|
||||
},
|
||||
"rrule": {
|
||||
"freqNone": "No recurrence",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -703,7 +703,9 @@
|
||||
},
|
||||
"modal": {
|
||||
"closeLabel": "बंद करें",
|
||||
"overlayLabel": "मोडल डायलॉग पृष्ठभूमि"
|
||||
"overlayLabel": "मोडल डायलॉग पृष्ठभूमि",
|
||||
"unsavedChanges": "बदलाव छोड़ें?",
|
||||
"discardChanges": "छोड़ें"
|
||||
},
|
||||
"rrule": {
|
||||
"freqNone": "कोई दोहराव नहीं",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -703,7 +703,9 @@
|
||||
},
|
||||
"modal": {
|
||||
"closeLabel": "閉じる",
|
||||
"overlayLabel": "モーダルダイアログの背景"
|
||||
"overlayLabel": "モーダルダイアログの背景",
|
||||
"unsavedChanges": "変更を破棄しますか?",
|
||||
"discardChanges": "破棄"
|
||||
},
|
||||
"rrule": {
|
||||
"freqNone": "繰り返しなし",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -703,7 +703,9 @@
|
||||
},
|
||||
"modal": {
|
||||
"closeLabel": "Закрыть",
|
||||
"overlayLabel": "Фон модального диалога"
|
||||
"overlayLabel": "Фон модального диалога",
|
||||
"unsavedChanges": "Отменить изменения?",
|
||||
"discardChanges": "Отменить"
|
||||
},
|
||||
"rrule": {
|
||||
"freqNone": "Без повтора",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -703,7 +703,9 @@
|
||||
},
|
||||
"modal": {
|
||||
"closeLabel": "Закрити",
|
||||
"overlayLabel": "Фон модального вікна"
|
||||
"overlayLabel": "Фон модального вікна",
|
||||
"unsavedChanges": "Скасувати зміни?",
|
||||
"discardChanges": "Скасувати"
|
||||
},
|
||||
"rrule": {
|
||||
"freqNone": "Без повторення",
|
||||
|
||||
@@ -703,7 +703,9 @@
|
||||
},
|
||||
"modal": {
|
||||
"closeLabel": "关闭",
|
||||
"overlayLabel": "模态对话框背景"
|
||||
"overlayLabel": "模态对话框背景",
|
||||
"unsavedChanges": "放弃更改?",
|
||||
"discardChanges": "放弃"
|
||||
},
|
||||
"rrule": {
|
||||
"freqNone": "不重复",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user