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
+80 -6
View File
@@ -422,6 +422,64 @@ export function selectModal(label, options) {
}); });
} }
// --------------------------------------------------------
// confirmModal - Ersatz für native confirm()
// --------------------------------------------------------
/**
* Zeigt ein Bestätigungs-Modal als Ersatz für native confirm().
* Gibt ein Promise zurück: true bei OK, false bei Cancel/Escape/Overlay-Klick.
*
* @param {string} message - Frage / Meldung im Titel
* @param {Object} [opts]
* @param {string} [opts.confirmLabel] - Text des Bestätigungs-Buttons (default: t('common.confirm'))
* @param {boolean} [opts.danger=false] - Roten Danger-Button statt Primary verwenden
* @returns {Promise<boolean>}
*/
export function confirmModal(message, { confirmLabel, danger = false } = {}) {
return new Promise((resolve) => {
let resolved = false;
function finish(value) {
if (resolved) return;
resolved = true;
closeModal();
resolve(value);
}
openModal({
title: message,
size: 'sm',
content: `
<div class="modal-actions">
<button type="button" class="btn btn--ghost" id="confirm-modal-cancel">${t('common.cancel')}</button>
<button type="button" class="btn ${danger ? 'btn--danger' : 'btn--primary'}" id="confirm-modal-ok">
${confirmLabel ?? t('common.confirm')}
</button>
</div>`,
onSave(panel) {
panel.querySelector('#confirm-modal-ok')?.addEventListener('click', () => finish(true));
panel.querySelector('#confirm-modal-cancel')?.addEventListener('click', () => finish(false));
const escHandler = (e) => {
if (e.key === 'Escape') {
document.removeEventListener('keydown', escHandler);
finish(false);
}
};
document.addEventListener('keydown', escHandler);
const overlay = panel.closest('.modal-overlay');
if (overlay) {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) finish(false);
});
}
},
});
});
}
// -------------------------------------------------------- // --------------------------------------------------------
// Inline Blur-Validierung // Inline Blur-Validierung
// -------------------------------------------------------- // --------------------------------------------------------
@@ -447,18 +505,28 @@ export function wireBlurValidation(formContainer) {
/** /**
* Zeigt Erfolgs-Feedback auf einem Button (Checkmark für 700ms). * Zeigt Erfolgs-Feedback auf einem Button (Checkmark für 700ms).
* Respektiert prefers-reduced-motion: zeigt nur Farb-Feedback ohne Icon-Wechsel.
* @param {HTMLButtonElement} btn * @param {HTMLButtonElement} btn
* @param {string} [originalLabel] * @param {string} [originalLabel]
*/ */
export function btnSuccess(btn, originalLabel) { export function btnSuccess(btn, originalLabel) {
const label = originalLabel ?? btn.textContent; const label = originalLabel ?? btn.textContent;
btn.classList.add('btn--success'); btn.classList.add('btn--success');
btn.innerHTML = ` const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" if (!reducedMotion) {
stroke-width="2.5" aria-hidden="true"> const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
<polyline points="20 6 9 17 4 12"/> svg.setAttribute('width', '16');
</svg> svg.setAttribute('height', '16');
`; svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2.5');
svg.setAttribute('aria-hidden', 'true');
const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
poly.setAttribute('points', '20 6 9 17 4 12');
svg.appendChild(poly);
btn.replaceChildren(svg);
}
setTimeout(() => { setTimeout(() => {
btn.classList.remove('btn--success'); btn.classList.remove('btn--success');
btn.textContent = label; btn.textContent = label;
@@ -483,9 +551,15 @@ export function btnLoading(btn) {
/** /**
* Zeigt Fehler-Feedback auf einem Button (Shake-Animation). * Zeigt Fehler-Feedback auf einem Button (Shake-Animation).
* Respektiert prefers-reduced-motion: kein visuelles Schütteln, nur Farb-Feedback.
* @param {HTMLButtonElement} btn * @param {HTMLButtonElement} btn
*/ */
export function btnError(btn) { export function btnError(btn) {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
btn.classList.add('btn--error-static');
setTimeout(() => btn.classList.remove('btn--error-static'), 700);
return;
}
btn.classList.remove('btn--shaking'); btn.classList.remove('btn--shaking');
void btn.offsetWidth; // Reflow für Animation-Restart void btn.offsetWidth; // Reflow für Animation-Restart
btn.classList.add('btn--shaking'); btn.classList.add('btn--shaking');
+4 -1
View File
@@ -26,7 +26,9 @@
"nameRequired": "Name ist erforderlich", "nameRequired": "Name ist erforderlich",
"contentRequired": "Inhalt ist erforderlich", "contentRequired": "Inhalt ist erforderlich",
"all": "Alle", "all": "Alle",
"unknownError": "Unbekannter Fehler" "unknownError": "Unbekannter Fehler",
"confirm": "Bestätigen",
"undo": "Rückgängig"
}, },
"nav": { "nav": {
@@ -155,6 +157,7 @@
"renameListPrompt": "Neuer Listen-Name:", "renameListPrompt": "Neuer Listen-Name:",
"deleteListConfirm": "Liste \"{{name}}\" und alle Artikel löschen?", "deleteListConfirm": "Liste \"{{name}}\" und alle Artikel löschen?",
"deletedListToast": "Liste gelöscht.", "deletedListToast": "Liste gelöscht.",
"itemDeletedToast": "\"{{name}}\" entfernt.",
"itemsRemovedToast": "{{count}} Artikel entfernt.", "itemsRemovedToast": "{{count}} Artikel entfernt.",
"clearChecked": "Abgehakt löschen ({{count}})", "clearChecked": "Abgehakt löschen ({{count}})",
"itemNamePlaceholder": "Artikel hinzufügen…", "itemNamePlaceholder": "Artikel hinzufügen…",
+4 -1
View File
@@ -26,7 +26,9 @@
"nameRequired": "Name is required", "nameRequired": "Name is required",
"contentRequired": "Content is required", "contentRequired": "Content is required",
"all": "All", "all": "All",
"unknownError": "Unknown error" "unknownError": "Unknown error",
"confirm": "Confirm",
"undo": "Undo"
}, },
"nav": { "nav": {
@@ -155,6 +157,7 @@
"renameListPrompt": "New list name:", "renameListPrompt": "New list name:",
"deleteListConfirm": "Delete list \"{{name}}\" and all items?", "deleteListConfirm": "Delete list \"{{name}}\" and all items?",
"deletedListToast": "List deleted.", "deletedListToast": "List deleted.",
"itemDeletedToast": "\"{{name}}\" removed.",
"itemsRemovedToast": "{{count}} items removed.", "itemsRemovedToast": "{{count}} items removed.",
"clearChecked": "Remove checked ({{count}})", "clearChecked": "Remove checked ({{count}})",
"itemNamePlaceholder": "Add item…", "itemNamePlaceholder": "Add item…",
+4 -1
View File
@@ -26,7 +26,9 @@
"nameRequired": "Il nome è obbligatorio", "nameRequired": "Il nome è obbligatorio",
"contentRequired": "Il contenuto è obbligatorio", "contentRequired": "Il contenuto è obbligatorio",
"all": "Tutto", "all": "Tutto",
"unknownError": "Errore sconosciuto" "unknownError": "Errore sconosciuto",
"confirm": "Conferma",
"undo": "Annulla"
}, },
"nav": { "nav": {
@@ -155,6 +157,7 @@
"renameListPrompt": "Nuovo nome lista:", "renameListPrompt": "Nuovo nome lista:",
"deleteListConfirm": "Eliminare la lista \"{{name}}\" e tutti gli articoli?", "deleteListConfirm": "Eliminare la lista \"{{name}}\" e tutti gli articoli?",
"deletedListToast": "Lista eliminata.", "deletedListToast": "Lista eliminata.",
"itemDeletedToast": "\"{{name}}\" rimosso.",
"itemsRemovedToast": "{{count}} articoli rimossi.", "itemsRemovedToast": "{{count}} articoli rimossi.",
"clearChecked": "Rimuovi selezionati ({{count}})", "clearChecked": "Rimuovi selezionati ({{count}})",
"itemNamePlaceholder": "Aggiungi articolo…", "itemNamePlaceholder": "Aggiungi articolo…",
+4 -1
View File
@@ -26,7 +26,9 @@
"nameRequired": "Namn krävs", "nameRequired": "Namn krävs",
"contentRequired": "Innehåll krävs", "contentRequired": "Innehåll krävs",
"all": "Alla", "all": "Alla",
"unknownError": "Okänt fel" "unknownError": "Okänt fel",
"confirm": "Bekräfta",
"undo": "Ångra"
}, },
"nav": { "nav": {
@@ -155,6 +157,7 @@
"renameListPrompt": "Nytt listnamn:", "renameListPrompt": "Nytt listnamn:",
"deleteListConfirm": "Ta bort listan \"{{name}}\" och alla objekt?", "deleteListConfirm": "Ta bort listan \"{{name}}\" och alla objekt?",
"deletedListToast": "Lista raderad.", "deletedListToast": "Lista raderad.",
"itemDeletedToast": "\"{{name}}\" borttaget.",
"itemsRemovedToast": "{{count}} objekt har tagits bort.", "itemsRemovedToast": "{{count}} objekt har tagits bort.",
"clearChecked": "Ta bort markerad ({{count}})", "clearChecked": "Ta bort markerad ({{count}})",
"itemNamePlaceholder": "Lägg till objekt...", "itemNamePlaceholder": "Lägg till objekt...",
+2 -3
View File
@@ -6,7 +6,7 @@
*/ */
import { api } from '/api.js'; 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 { stagger, vibrate } from '/utils/ux.js';
import { t, formatDate, getLocale } from '/i18n.js'; import { t, formatDate, getLocale } from '/i18n.js';
import { esc } from '/utils/html.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-cancel').addEventListener('click', closeModal);
panel.querySelector('#bm-delete')?.addEventListener('click', async () => { panel.querySelector('#bm-delete')?.addEventListener('click', async () => {
if (!confirm(t('budget.deletePersonConfirm', { title: entry.title }))) return;
closeModal(); closeModal();
await deleteEntry(entry.id); await deleteEntry(entry.id);
}); });
@@ -474,7 +473,7 @@ function openBudgetModal({ mode, entry = null }) {
// -------------------------------------------------------- // --------------------------------------------------------
async function deleteEntry(id) { async function deleteEntry(id) {
if (!confirm(t('budget.deleteConfirm'))) return; if (!await confirmModal(t('budget.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return;
try { try {
await api.delete(`/budget/${id}`); await api.delete(`/budget/${id}`);
state.entries = state.entries.filter((e) => e.id !== id); state.entries = state.entries.filter((e) => e.id !== id);
+3 -3
View File
@@ -6,7 +6,7 @@
import { api } from '/api.js'; import { api } from '/api.js';
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.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 { stagger } from '/utils/ux.js';
import { t, formatTime } from '/i18n.js'; import { t, formatTime } from '/i18n.js';
import { esc } from '/utils/html.js'; import { esc } from '/utils/html.js';
@@ -701,7 +701,7 @@ function showEventPopup(ev, anchor) {
}); });
popup.querySelector('#popup-delete').addEventListener('click', async () => { 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(); popup.remove();
await deleteEvent(ev.id); 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-cancel').addEventListener('click', closeModal);
panel.querySelector('#modal-delete')?.addEventListener('click', async () => { 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(); closeModal();
await deleteEvent(event.id); await deleteEvent(event.id);
}); });
+2 -3
View File
@@ -5,7 +5,7 @@
*/ */
import { api } from '/api.js'; 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 { stagger, vibrate } from '/utils/ux.js';
import { t } from '/i18n.js'; import { t } from '/i18n.js';
import { esc } from '/utils/html.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-cancel').addEventListener('click', closeModal);
panel.querySelector('#cm-delete')?.addEventListener('click', async () => { panel.querySelector('#cm-delete')?.addEventListener('click', async () => {
if (!confirm(t('contacts.deletePersonConfirm', { name: contact.name }))) return;
closeModal(); closeModal();
await deleteContact(contact.id); await deleteContact(contact.id);
}); });
@@ -351,7 +350,7 @@ function openContactModal({ mode, contact = null }) {
} }
async function deleteContact(id) { async function deleteContact(id) {
if (!confirm(t('contacts.deleteConfirm'))) return; if (!await confirmModal(t('contacts.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return;
try { try {
await api.delete(`/contacts/${id}`); await api.delete(`/contacts/${id}`);
state.contacts = state.contacts.filter((c) => c.id !== id); state.contacts = state.contacts.filter((c) => c.id !== id);
+2 -2
View File
@@ -5,7 +5,7 @@
*/ */
import { api } from '/api.js'; 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 { stagger } from '/utils/ux.js';
import { t, formatDate } from '/i18n.js'; import { t, formatDate } from '/i18n.js';
import { esc } from '/utils/html.js'; import { esc } from '/utils/html.js';
@@ -681,7 +681,7 @@ async function saveModal(overlay) {
// -------------------------------------------------------- // --------------------------------------------------------
async function deleteMeal(mealId) { async function deleteMeal(mealId) {
if (!confirm(t('meals.deleteMeal') + '?')) return; if (!await confirmModal(t('meals.deleteMeal') + '?', { danger: true, confirmLabel: t('common.delete') })) return;
try { try {
await api.delete(`/meals/${mealId}`); await api.delete(`/meals/${mealId}`);
state.meals = state.meals.filter((m) => m.id !== mealId); state.meals = state.meals.filter((m) => m.id !== mealId);
+2 -2
View File
@@ -5,7 +5,7 @@
*/ */
import { api } from '/api.js'; 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 { stagger, vibrate } from '/utils/ux.js';
import { t } from '/i18n.js'; import { t } from '/i18n.js';
import { esc } from '/utils/html.js'; import { esc } from '/utils/html.js';
@@ -476,7 +476,7 @@ async function togglePin(id) {
} }
async function deleteNote(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 { try {
await api.delete(`/notes/${id}`); await api.delete(`/notes/${id}`);
state.notes = state.notes.filter((n) => n.id !== 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 { api, auth } from '/api.js';
import { confirmModal } from '/components/modal.js';
import { t, formatDate, formatTime } from '/i18n.js'; import { t, formatDate, formatTime } from '/i18n.js';
import { esc } from '/utils/html.js'; import { esc } from '/utils/html.js';
import '/components/oikos-locale-picker.js'; import '/components/oikos-locale-picker.js';
@@ -423,7 +424,7 @@ function bindEvents(container, user) {
const googleDisconnectBtn = container.querySelector('#google-disconnect-btn'); const googleDisconnectBtn = container.querySelector('#google-disconnect-btn');
if (googleDisconnectBtn) { if (googleDisconnectBtn) {
googleDisconnectBtn.addEventListener('click', async () => { googleDisconnectBtn.addEventListener('click', async () => {
if (!confirm(t('settings.googleDisconnectConfirm'))) return; if (!await confirmModal(t('settings.googleDisconnectConfirm'), { danger: true })) return;
try { try {
await api.delete('/calendar/google/disconnect'); await api.delete('/calendar/google/disconnect');
window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Google Calendar' }), 'default'); 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'); const appleDisconnectBtn = container.querySelector('#apple-disconnect-btn');
if (appleDisconnectBtn) { if (appleDisconnectBtn) {
appleDisconnectBtn.addEventListener('click', async () => { appleDisconnectBtn.addEventListener('click', async () => {
if (!confirm(t('settings.appleDisconnectConfirm'))) return; if (!await confirmModal(t('settings.appleDisconnectConfirm'), { danger: true })) return;
try { try {
await api.delete('/calendar/apple/disconnect'); await api.delete('/calendar/apple/disconnect');
window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Apple Calendar' }), 'default'); window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Apple Calendar' }), 'default');
@@ -571,7 +572,7 @@ function bindDeleteButtons(container, user) {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.deleteUser, 10); const id = parseInt(btn.dataset.deleteUser, 10);
const name = btn.dataset.name; 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 { try {
await auth.deleteUser(id); await auth.deleteUser(id);
btn.closest('.settings-member').remove(); 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 { stagger, vibrate } from '/utils/ux.js';
import { t } from '/i18n.js'; import { t } from '/i18n.js';
import { esc } from '/utils/html.js'; import { esc } from '/utils/html.js';
import { promptModal } from '/components/modal.js'; import { promptModal, confirmModal } from '/components/modal.js';
// -------------------------------------------------------- // --------------------------------------------------------
// Konstanten // Konstanten
@@ -677,35 +677,97 @@ function wireListContentEvents(container) {
} }
} }
// ---- Artikel löschen ---- // ---- Artikel löschen (mit Undo, 4s Fenster) ----
if (action === 'delete-item') { if (action === 'delete-item') {
const id = Number(target.dataset.id); const id = Number(target.dataset.id);
const item = state.items.find((i) => i.id === id); const item = state.items.find((i) => i.id === id);
try { const snapshot = item ? { ...item } : null;
await api.delete(`/shopping/items/${id}`);
state.items = state.items.filter((i) => i.id !== id); // Optimistisch entfernen
updateItemsList(container); state.items = state.items.filter((i) => i.id !== id);
updateListCounter(state.activeListId, -1, item?.is_checked ? -1 : 0); updateItemsList(container);
renderTabs(container); updateListCounter(state.activeListId, -1, snapshot?.is_checked ? -1 : 0);
} catch (err) { renderTabs(container);
window.oikos.showToast(err.message, 'danger');
} 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') { 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; if (!count) return;
try {
await api.delete(`/shopping/${state.activeListId}/items/checked`); const snapshot = checked.map((i) => ({ ...i }));
state.items = state.items.filter((i) => !i.is_checked);
updateItemsList(container); // Optimistisch entfernen
updateListCounter(state.activeListId, -count, -count); state.items = state.items.filter((i) => !i.is_checked);
renderTabs(container); updateItemsList(container);
window.oikos.showToast(t('shopping.itemsRemovedToast', { count })); updateListCounter(state.activeListId, -count, -count);
} catch (err) { renderTabs(container);
window.oikos.showToast(err.message, 'danger');
} 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 ---- // ---- Liste umbenennen ----
@@ -727,7 +789,7 @@ function wireListContentEvents(container) {
// ---- Liste löschen ---- // ---- Liste löschen ----
if (action === 'delete-list') { 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 { try {
await api.delete(`/shopping/${state.activeListId}`); await api.delete(`/shopping/${state.activeListId}`);
state.lists = state.lists.filter((l) => l.id !== 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 { api } from '/api.js';
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.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 { stagger, vibrate } from '/utils/ux.js';
import { t, formatDate } from '/i18n.js'; import { t, formatDate } from '/i18n.js';
import { esc } from '/utils/html.js'; import { esc } from '/utils/html.js';
@@ -471,7 +471,7 @@ async function handleFormSubmit(e, container) {
} }
async function handleDeleteTask(id, 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 { try {
await api.delete(`/tasks/${id}`); await api.delete(`/tasks/${id}`);
closeModal(); closeModal();
+18 -2
View File
@@ -397,10 +397,14 @@ const TOAST_ICONS = {
warning: '<svg class="toast__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>', warning: '<svg class="toast__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
}; };
function showToast(message, type = 'default', duration = 3000) { function showToast(message, type = 'default', duration = 3000, onUndo = null) {
const container = document.getElementById('toast-container'); const container = document.getElementById('toast-container');
if (!container) return; if (!container) return;
// Max. 3 gleichzeitige Toasts: ältesten entfernen falls Limit erreicht
const existing = container.querySelectorAll('.toast');
if (existing.length >= 3) existing[0].remove();
const toast = document.createElement('div'); const toast = document.createElement('div');
toast.className = `toast ${type !== 'default' ? `toast--${type}` : ''}`; toast.className = `toast ${type !== 'default' ? `toast--${type}` : ''}`;
toast.setAttribute('role', 'alert'); toast.setAttribute('role', 'alert');
@@ -412,8 +416,20 @@ function showToast(message, type = 'default', duration = 3000) {
toast.innerHTML = icon; // eslint-disable-line no-unsanitized/property -- static SVG only toast.innerHTML = icon; // eslint-disable-line no-unsanitized/property -- static SVG only
toast.appendChild(span); toast.appendChild(span);
if (typeof onUndo === 'function') {
const undoBtn = document.createElement('button');
undoBtn.className = 'toast__undo';
undoBtn.textContent = t('common.undo');
undoBtn.addEventListener('click', () => {
clearTimeout(dismissTimer);
toast.remove();
onUndo();
});
toast.appendChild(undoBtn);
}
container.appendChild(toast); container.appendChild(toast);
setTimeout(() => { const dismissTimer = setTimeout(() => {
toast.classList.add('toast--out'); toast.classList.add('toast--out');
toast.addEventListener('animationend', () => toast.remove(), { once: true }); toast.addEventListener('animationend', () => toast.remove(), { once: true });
}, duration); }, duration);
+26
View File
@@ -1366,6 +1366,25 @@
height: 16px; height: 16px;
} }
.toast__undo {
margin-left: auto;
flex-shrink: 0;
padding: var(--space-1) var(--space-2);
background: transparent;
border: 1px solid currentColor;
border-radius: var(--radius-sm);
color: inherit;
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
cursor: pointer;
white-space: nowrap;
opacity: 0.85;
}
.toast__undo:hover {
opacity: 1;
}
.toast--success { background-color: var(--color-success); color: var(--color-text-on-accent); } .toast--success { background-color: var(--color-success); color: var(--color-text-on-accent); }
.toast--danger { background-color: var(--color-danger); color: var(--color-text-on-accent); } .toast--danger { background-color: var(--color-danger); color: var(--color-text-on-accent); }
.toast--warning { background-color: var(--color-warning); color: var(--color-text-on-accent); } .toast--warning { background-color: var(--color-warning); color: var(--color-text-on-accent); }
@@ -1491,6 +1510,13 @@
animation: btn-shake 0.3s ease; animation: btn-shake 0.3s ease;
} }
/* prefers-reduced-motion: kein Schütteln, nur Farb-Feedback */
.btn--error-static {
background-color: var(--color-danger) !important;
color: var(--color-text-on-accent) !important;
pointer-events: none;
}
.btn--success { .btn--success {
background-color: var(--color-success) !important; background-color: var(--color-success) !important;
color: var(--color-text-on-accent) !important; color: var(--color-text-on-accent) !important;