fix(modal): fix sheet swipe tap-cancel and modal close race condition on iOS

Sheet swipe: add 10 px dead zone before transform, reset via rAF in touchend
so iOS WebKit does not cancel subsequent click events on child buttons.

Modal close: capture active overlay before animation to prevent race where
_doClose() removes the new modal instead of the old one when a confirm dialog
opens immediately after another modal closes (delete button not responding).
This commit is contained in:
Konrad M.
2026-04-21 21:34:53 +02:00
parent 6416bbf245
commit 1e438af5a7
+46 -22
View File
@@ -132,16 +132,23 @@ function _wireSheetSwipe(panel) {
if (!dragging) return; if (!dragging) return;
const dy = e.touches[0].clientY - startY; const dy = e.touches[0].clientY - startY;
if (dy < 0) { dragging = false; return; } // Aufwärts-Scroll: Swipe abbrechen if (dy < 0) { dragging = false; return; } // Aufwärts-Scroll: Swipe abbrechen
panel.style.transform = `translateY(${dy * 0.6}px)`; // Erst ab 10px Bewegung animieren: Verhindert winzige Transforms durch
// normale Taps, die danach zurückgesetzt werden müssten.
if (dy > 10) panel.style.transform = `translateY(${(dy - 10) * 0.6}px)`;
}, { passive: true }); }, { passive: true });
panel.addEventListener('touchend', (e) => { panel.addEventListener('touchend', (e) => {
if (!dragging) return; if (!dragging) return;
dragging = false; dragging = false;
const dy = e.changedTouches[0].clientY - startY; const dy = e.changedTouches[0].clientY - startY;
panel.style.transform = '';
if (dy > 80) { if (dy > 80) {
panel.style.transform = '';
closeModal(); closeModal();
} else {
// Transform-Reset per rAF verzögern: DOM-Mutationen direkt in touchend
// unterbrechen auf iOS WebKit die Touch→Click-Konvertierung der click-Event
// auf Child-Elementen (Buttons) wird gecancelt → Buttons reagieren nicht.
requestAnimationFrame(() => { panel.style.transform = ''; });
} }
}); });
} }
@@ -150,23 +157,30 @@ function _wireSheetSwipe(panel) {
// _doClose - gemeinsame Cleanup-Logik // _doClose - gemeinsame Cleanup-Logik
// -------------------------------------------------------- // --------------------------------------------------------
function _doClose() { function _doClose(overlayEl) {
if (!activeOverlay) return; const target = overlayEl ?? activeOverlay;
activeOverlay.remove(); if (!target) return;
activeOverlay = null;
// Scroll-Lock aufheben target.remove();
document.body.style.overflow = '';
// Focus-Restore // Globalen State nur zurücksetzen wenn kein neues Modal zwischenzeitlich geöffnet wurde.
if (previouslyFocused && typeof previouslyFocused.focus === 'function') { // (activeOverlay !== target bedeutet: openModal hat bereits ein neues Modal registriert)
previouslyFocused.focus(); if (activeOverlay === target) {
previouslyFocused = null; activeOverlay = null;
}
// Standalone: Statusbar-Farbe zur aktuellen Route wiederherstellen // Scroll-Lock aufheben
if (window.oikos?.restoreThemeColor) { document.body.style.overflow = '';
window.oikos.restoreThemeColor();
// Focus-Restore
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
previouslyFocused = null;
}
// Standalone: Statusbar-Farbe zur aktuellen Route wiederherstellen
if (window.oikos?.restoreThemeColor) {
window.oikos.restoreThemeColor();
}
} }
} }
@@ -186,8 +200,14 @@ function _doClose() {
* @param {string} [opts.size='md'] - 'sm' | 'md' | 'lg' * @param {string} [opts.size='md'] - 'sm' | 'md' | 'lg'
*/ */
export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}) { export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}) {
// Vorheriges Modal schließen (kein Stacking) // Vorheriges Modal schließen (kein Stacking).
if (activeOverlay) closeModal(); // 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.
if (activeOverlay) {
activeOverlay.removeAttribute('id');
closeModal();
}
// Focus-Restore vorbereiten // Focus-Restore vorbereiten
previouslyFocused = document.activeElement; previouslyFocused = document.activeElement;
@@ -264,7 +284,11 @@ export function closeModal() {
document.removeEventListener('keydown', onEscape); document.removeEventListener('keydown', onEscape);
const panel = activeOverlay.querySelector('.modal-panel'); // Overlay sofort sichern: Bei Mobile-Animation öffnet openModal() ein neues Modal
// bevor animationend feuert. Ohne capturedOverlay würde _doClose() das neue Modal
// statt des alten entfernen (Race Condition → Buttons im Confirm-Dialog reagieren nicht).
const capturedOverlay = activeOverlay;
const panel = capturedOverlay.querySelector('.modal-panel');
// Focus-Trap-Handler und Virtual-Keyboard-Listener entfernen // Focus-Trap-Handler und Virtual-Keyboard-Listener entfernen
if (focusTrapHandler) { if (focusTrapHandler) {
@@ -280,15 +304,15 @@ export function closeModal() {
if (isMobile && panel) { if (isMobile && panel) {
panel.classList.add('modal-panel--closing'); panel.classList.add('modal-panel--closing');
// Fallback-Timer falls animationend nicht feuert (prefers-reduced-motion, Tab-Wechsel etc.) // Fallback-Timer falls animationend nicht feuert (prefers-reduced-motion, Tab-Wechsel etc.)
const fallback = setTimeout(_doClose, 300); const fallback = setTimeout(() => _doClose(capturedOverlay), 300);
panel.addEventListener('animationend', () => { panel.addEventListener('animationend', () => {
clearTimeout(fallback); clearTimeout(fallback);
_doClose(); _doClose(capturedOverlay);
}, { once: true }); }, { once: true });
return; return;
} }
_doClose(); _doClose(capturedOverlay);
} }
// -------------------------------------------------------- // --------------------------------------------------------