fix(ux): replace native confirm() dialogs, add undo-toast, fix prefers-reduced-motion

- Replace all 13 native confirm() calls with confirmModal() across 7 page modules
- Add confirmModal() to modal.js (Promise-based, danger variant, focus management)
- Fix double-confirm bug in contacts.js and budget.js (modal + deleteContact/deleteEntry)
- Extend showToast() with onUndo callback and max-3-toast limit
- Implement optimistic undo-toast (4s window) for shopping item and bulk-checked delete
- Add prefers-reduced-motion guard to btnSuccess() and btnError() in modal.js
- Add btn--error-static CSS class as motion-reduced fallback for btnError()
- Add toast__undo button styles to layout.css
- Add common.confirm and common.undo i18n keys (de, en, it, sv)
- Add shopping.itemDeletedToast i18n key (de, en, it, sv)
This commit is contained in:
Ulas
2026-04-05 12:31:16 +02:00
parent 3a7d6d0e0a
commit 44e5a879b9
15 changed files with 245 additions and 56 deletions
+2 -3
View File
@@ -6,7 +6,7 @@
*/
import { api } from '/api.js';
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js';
import { stagger, vibrate } from '/utils/ux.js';
import { t, formatDate, getLocale } from '/i18n.js';
import { esc } from '/utils/html.js';
@@ -421,7 +421,6 @@ function openBudgetModal({ mode, entry = null }) {
panel.querySelector('#bm-cancel').addEventListener('click', closeModal);
panel.querySelector('#bm-delete')?.addEventListener('click', async () => {
if (!confirm(t('budget.deletePersonConfirm', { title: entry.title }))) return;
closeModal();
await deleteEntry(entry.id);
});
@@ -474,7 +473,7 @@ function openBudgetModal({ mode, entry = null }) {
// --------------------------------------------------------
async function deleteEntry(id) {
if (!confirm(t('budget.deleteConfirm'))) return;
if (!await confirmModal(t('budget.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return;
try {
await api.delete(`/budget/${id}`);
state.entries = state.entries.filter((e) => e.id !== id);
+3 -3
View File
@@ -6,7 +6,7 @@
import { api } from '/api.js';
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js';
import { stagger } from '/utils/ux.js';
import { t, formatTime } from '/i18n.js';
import { esc } from '/utils/html.js';
@@ -701,7 +701,7 @@ function showEventPopup(ev, anchor) {
});
popup.querySelector('#popup-delete').addEventListener('click', async () => {
if (!confirm(t('calendar.deleteConfirm', { title: ev.title }))) return;
if (!await confirmModal(t('calendar.deleteConfirm', { title: ev.title }), { danger: true, confirmLabel: t('common.delete') })) return;
popup.remove();
await deleteEvent(ev.id);
});
@@ -759,7 +759,7 @@ function openEventModal({ mode, event = null, date = null }) {
panel.querySelector('#modal-cancel').addEventListener('click', closeModal);
panel.querySelector('#modal-delete')?.addEventListener('click', async () => {
if (!confirm(t('calendar.deleteConfirm', { title: event.title }))) return;
if (!await confirmModal(t('calendar.deleteConfirm', { title: event.title }), { danger: true, confirmLabel: t('common.delete') })) return;
closeModal();
await deleteEvent(event.id);
});
+2 -3
View File
@@ -5,7 +5,7 @@
*/
import { api } from '/api.js';
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js';
import { stagger, vibrate } from '/utils/ux.js';
import { t } from '/i18n.js';
import { esc } from '/utils/html.js';
@@ -304,7 +304,6 @@ function openContactModal({ mode, contact = null }) {
panel.querySelector('#cm-cancel').addEventListener('click', closeModal);
panel.querySelector('#cm-delete')?.addEventListener('click', async () => {
if (!confirm(t('contacts.deletePersonConfirm', { name: contact.name }))) return;
closeModal();
await deleteContact(contact.id);
});
@@ -351,7 +350,7 @@ function openContactModal({ mode, contact = null }) {
}
async function deleteContact(id) {
if (!confirm(t('contacts.deleteConfirm'))) return;
if (!await confirmModal(t('contacts.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return;
try {
await api.delete(`/contacts/${id}`);
state.contacts = state.contacts.filter((c) => c.id !== id);
+2 -2
View File
@@ -5,7 +5,7 @@
*/
import { api } from '/api.js';
import { openModal as openSharedModal, closeModal as closeSharedModal, selectModal } from '/components/modal.js';
import { openModal as openSharedModal, closeModal as closeSharedModal, selectModal, confirmModal } from '/components/modal.js';
import { stagger } from '/utils/ux.js';
import { t, formatDate } from '/i18n.js';
import { esc } from '/utils/html.js';
@@ -681,7 +681,7 @@ async function saveModal(overlay) {
// --------------------------------------------------------
async function deleteMeal(mealId) {
if (!confirm(t('meals.deleteMeal') + '?')) return;
if (!await confirmModal(t('meals.deleteMeal') + '?', { danger: true, confirmLabel: t('common.delete') })) return;
try {
await api.delete(`/meals/${mealId}`);
state.meals = state.meals.filter((m) => m.id !== mealId);
+2 -2
View File
@@ -5,7 +5,7 @@
*/
import { api } from '/api.js';
import { openModal as openSharedModal, closeModal, btnError } from '/components/modal.js';
import { openModal as openSharedModal, closeModal, btnError, confirmModal } from '/components/modal.js';
import { stagger, vibrate } from '/utils/ux.js';
import { t } from '/i18n.js';
import { esc } from '/utils/html.js';
@@ -476,7 +476,7 @@ async function togglePin(id) {
}
async function deleteNote(id) {
if (!confirm(t('notes.deleteConfirm'))) return;
if (!await confirmModal(t('notes.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return;
try {
await api.delete(`/notes/${id}`);
state.notes = state.notes.filter((n) => n.id !== id);
+4 -3
View File
@@ -5,6 +5,7 @@
*/
import { api, auth } from '/api.js';
import { confirmModal } from '/components/modal.js';
import { t, formatDate, formatTime } from '/i18n.js';
import { esc } from '/utils/html.js';
import '/components/oikos-locale-picker.js';
@@ -423,7 +424,7 @@ function bindEvents(container, user) {
const googleDisconnectBtn = container.querySelector('#google-disconnect-btn');
if (googleDisconnectBtn) {
googleDisconnectBtn.addEventListener('click', async () => {
if (!confirm(t('settings.googleDisconnectConfirm'))) return;
if (!await confirmModal(t('settings.googleDisconnectConfirm'), { danger: true })) return;
try {
await api.delete('/calendar/google/disconnect');
window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Google Calendar' }), 'default');
@@ -456,7 +457,7 @@ function bindEvents(container, user) {
const appleDisconnectBtn = container.querySelector('#apple-disconnect-btn');
if (appleDisconnectBtn) {
appleDisconnectBtn.addEventListener('click', async () => {
if (!confirm(t('settings.appleDisconnectConfirm'))) return;
if (!await confirmModal(t('settings.appleDisconnectConfirm'), { danger: true })) return;
try {
await api.delete('/calendar/apple/disconnect');
window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Apple Calendar' }), 'default');
@@ -571,7 +572,7 @@ function bindDeleteButtons(container, user) {
btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.deleteUser, 10);
const name = btn.dataset.name;
if (!confirm(t('settings.deleteMemberConfirm', { name }))) return;
if (!await confirmModal(t('settings.deleteMemberConfirm', { name }), { danger: true, confirmLabel: t('common.delete') })) return;
try {
await auth.deleteUser(id);
btn.closest('.settings-member').remove();
+88 -26
View File
@@ -8,7 +8,7 @@ import { api } from '/api.js';
import { stagger, vibrate } from '/utils/ux.js';
import { t } from '/i18n.js';
import { esc } from '/utils/html.js';
import { promptModal } from '/components/modal.js';
import { promptModal, confirmModal } from '/components/modal.js';
// --------------------------------------------------------
// Konstanten
@@ -677,35 +677,97 @@ function wireListContentEvents(container) {
}
}
// ---- Artikel löschen ----
// ---- Artikel löschen (mit Undo, 4s Fenster) ----
if (action === 'delete-item') {
const id = Number(target.dataset.id);
const item = state.items.find((i) => i.id === id);
try {
await api.delete(`/shopping/items/${id}`);
state.items = state.items.filter((i) => i.id !== id);
updateItemsList(container);
updateListCounter(state.activeListId, -1, item?.is_checked ? -1 : 0);
renderTabs(container);
} catch (err) {
window.oikos.showToast(err.message, 'danger');
}
const id = Number(target.dataset.id);
const item = state.items.find((i) => i.id === id);
const snapshot = item ? { ...item } : null;
// Optimistisch entfernen
state.items = state.items.filter((i) => i.id !== id);
updateItemsList(container);
updateListCounter(state.activeListId, -1, snapshot?.is_checked ? -1 : 0);
renderTabs(container);
let undone = false;
window.oikos.showToast(
t('shopping.itemDeletedToast', { name: snapshot?.name ?? '' }),
'default',
4000,
() => {
// Undo: Artikel wiederherstellen
undone = true;
if (snapshot) {
state.items.push(snapshot);
state.items.sort((a, b) => a.id - b.id);
updateItemsList(container);
updateListCounter(state.activeListId, 1, snapshot.is_checked ? 1 : 0);
renderTabs(container);
}
},
);
// Verzögert löschen — nur wenn kein Undo
setTimeout(async () => {
if (undone) return;
try {
await api.delete(`/shopping/items/${id}`);
} catch (err) {
// Rollback: Artikel war bereits aus UI entfernt, Fehler anzeigen
if (snapshot) {
state.items.push(snapshot);
state.items.sort((a, b) => a.id - b.id);
updateItemsList(container);
updateListCounter(state.activeListId, 1, snapshot.is_checked ? 1 : 0);
renderTabs(container);
}
window.oikos.showToast(err.message, 'danger');
}
}, 4100);
}
// ---- Abgehakte löschen ----
// ---- Abgehakte löschen (mit Undo, 4s Fenster) ----
if (action === 'clear-checked') {
const count = state.items.filter((i) => i.is_checked).length;
const checked = state.items.filter((i) => i.is_checked);
const count = checked.length;
if (!count) return;
try {
await api.delete(`/shopping/${state.activeListId}/items/checked`);
state.items = state.items.filter((i) => !i.is_checked);
updateItemsList(container);
updateListCounter(state.activeListId, -count, -count);
renderTabs(container);
window.oikos.showToast(t('shopping.itemsRemovedToast', { count }));
} catch (err) {
window.oikos.showToast(err.message, 'danger');
}
const snapshot = checked.map((i) => ({ ...i }));
// Optimistisch entfernen
state.items = state.items.filter((i) => !i.is_checked);
updateItemsList(container);
updateListCounter(state.activeListId, -count, -count);
renderTabs(container);
let undone = false;
window.oikos.showToast(
t('shopping.itemsRemovedToast', { count }),
'default',
4000,
() => {
undone = true;
snapshot.forEach((item) => state.items.push(item));
state.items.sort((a, b) => a.id - b.id);
updateItemsList(container);
updateListCounter(state.activeListId, count, count);
renderTabs(container);
},
);
setTimeout(async () => {
if (undone) return;
try {
await api.delete(`/shopping/${state.activeListId}/items/checked`);
} catch (err) {
snapshot.forEach((item) => state.items.push(item));
state.items.sort((a, b) => a.id - b.id);
updateItemsList(container);
updateListCounter(state.activeListId, count, count);
renderTabs(container);
window.oikos.showToast(err.message, 'danger');
}
}, 4100);
}
// ---- Liste umbenennen ----
@@ -727,7 +789,7 @@ function wireListContentEvents(container) {
// ---- Liste löschen ----
if (action === 'delete-list') {
if (!confirm(t('shopping.deleteListConfirm', { name: state.activeList?.name }))) return;
if (!await confirmModal(t('shopping.deleteListConfirm', { name: state.activeList?.name }), { danger: true, confirmLabel: t('common.delete') })) return;
try {
await api.delete(`/shopping/${state.activeListId}`);
state.lists = state.lists.filter((l) => l.id !== state.activeListId);
+2 -2
View File
@@ -6,7 +6,7 @@
import { api } from '/api.js';
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
import { openModal as openSharedModal, closeModal, wireBlurValidation, btnSuccess, btnError, promptModal } from '/components/modal.js';
import { openModal as openSharedModal, closeModal, wireBlurValidation, btnSuccess, btnError, promptModal, confirmModal } from '/components/modal.js';
import { stagger, vibrate } from '/utils/ux.js';
import { t, formatDate } from '/i18n.js';
import { esc } from '/utils/html.js';
@@ -471,7 +471,7 @@ async function handleFormSubmit(e, container) {
}
async function handleDeleteTask(id, container) {
if (!confirm(t('tasks.deleteConfirm'))) return;
if (!await confirmModal(t('tasks.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return;
try {
await api.delete(`/tasks/${id}`);
closeModal();