/** * Modul: Shared Modal-System * Zweck: Einheitliches Modal mit Focus-Trap, Escape-Handler, Overlay-Click, * Focus-Restore, Scroll-Lock und aria-modal. * Auf Mobile: Bottom Sheet mit Swipe-to-Close und Slide-out-Animation. * Abhängigkeiten: CSS-Klassen aus layout.css (.modal-overlay, .modal-panel, etc.) * i18n.js (t) * * API: * openModal({ title, content, onSave, onDelete, size }) → void * closeModal() → void */ import { t } from '/i18n.js'; let activeOverlay = null; let previouslyFocused = null; let focusTrapHandler = null; let _initialFormSnapshot = null; let _isClosing = false; // Overlay-Dimming: theme-color abdunkeln im Standalone-Modus const OVERLAY_THEME_COLOR = '#1A1A1A'; const FOCUSABLE = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', ].join(', '); // -------------------------------------------------------- // Focus-Trap (Spec §5.2) // -------------------------------------------------------- function trapFocus(container) { focusTrapHandler = (e) => { // Tab-Trap: Fokus innerhalb des Modals halten if (e.key === 'Tab') { const focusable = container.querySelectorAll(FOCUSABLE); if (!focusable.length) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } return; } // Enter in einzeiligen Inputs/Selects → zum nächsten Feld springen if (e.key === 'Enter') { const active = document.activeElement; const isInput = active.tagName === 'INPUT' && active.type !== 'submit' && active.type !== 'button'; const isSelect = active.tagName === 'SELECT'; if (isInput || isSelect) { const focusable = Array.from(container.querySelectorAll(FOCUSABLE)); const idx = focusable.indexOf(active); const next = focusable[idx + 1]; if (next && next.tagName !== 'BUTTON') { e.preventDefault(); next.focus(); } // Beim letzten Feld oder wenn Next ein Button ist: Submit auslösen if (!next || next.tagName === 'BUTTON') { const submitBtn = container.querySelector('button[type="submit"], .btn--primary'); if (submitBtn && !submitBtn.disabled) { e.preventDefault(); submitBtn.click(); } } } } }; container.addEventListener('keydown', focusTrapHandler); // Virtual Keyboard: Focused Input in sichtbaren Bereich scrollen function onInputFocus(e) { const tag = e.target.tagName; if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return; setTimeout(() => { e.target.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 300); } container.addEventListener('focusin', onInputFocus); container._onInputFocus = onInputFocus; // Focus first focusable element const first = container.querySelector(FOCUSABLE); if (first) { setTimeout(() => first.focus(), 50); } } // -------------------------------------------------------- // Dirty-Check Helpers // -------------------------------------------------------- function serializeForm(container) { const inputs = container.querySelectorAll('input:not([type="file"]), 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 // -------------------------------------------------------- function onEscape(e) { if (e.key === 'Escape') closeModal(); } // -------------------------------------------------------- // Swipe-to-Close (Mobile) // -------------------------------------------------------- function _wireSheetSwipe(panel) { let startY = 0; let dragging = false; // Scroll position is now on the body, not the panel itself const scrollBody = panel.querySelector('.modal-panel__body'); panel.addEventListener('touchstart', (e) => { // Nur von der Handle-Zone (obere 48px) oder wenn Panel ganz oben → Swipe erlauben const touchY = e.touches[0].clientY; const rect = panel.getBoundingClientRect(); const isHandleZone = touchY - rect.top < 48; const isScrolledToTop = (scrollBody ? scrollBody.scrollTop : panel.scrollTop) <= 0; if (!isHandleZone && !isScrolledToTop) return; startY = touchY; dragging = true; }, { passive: true }); panel.addEventListener('touchmove', (e) => { if (!dragging) return; const dy = e.touches[0].clientY - startY; if (dy < 0) { panel.style.transform = 'translateY(0)'; return; } // Aufwärts: Panel zurücksetzen, dragging bleibt aktiv // 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 }); panel.addEventListener('touchend', (e) => { if (!dragging) return; dragging = false; const dy = e.changedTouches[0].clientY - startY; if (dy > 80) { panel.style.transform = ''; 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 = ''; }); } }); } // -------------------------------------------------------- // _doClose - gemeinsame Cleanup-Logik // -------------------------------------------------------- function _doClose(overlayEl) { const target = overlayEl ?? activeOverlay; if (!target) return; target.remove(); // Globalen State nur zurücksetzen wenn kein neues Modal zwischenzeitlich geöffnet wurde. // (activeOverlay !== target bedeutet: openModal hat bereits ein neues Modal registriert) if (activeOverlay === target) { activeOverlay = null; // Scroll-Lock aufheben document.body.style.overflow = ''; // 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(); } } } // -------------------------------------------------------- // openModal // -------------------------------------------------------- /** * Öffnet ein Modal mit dem Shared-System. * * @param {Object} opts * @param {string} opts.title - Titel im Modal-Header * @param {string} opts.content - HTML-String für den Modal-Body * @param {Function} [opts.onSave] - Callback, wird nach Einfügen in DOM aufgerufen * (zum Binden von Form-Events) * @param {Function} [opts.onDelete] - Falls vorhanden, wird ein Löschen-Button eingebaut * @param {string} [opts.size='md'] - 'sm' | 'md' | 'lg' */ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}) { // Vorheriges Modal schließen (kein Stacking). // 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({ force: true }); } // Focus-Restore vorbereiten previouslyFocused = document.activeElement; // Scroll-Lock document.body.style.overflow = 'hidden'; const sizeClass = size !== 'md' ? ` modal-panel--${size}` : ''; const html = `
`; document.body.insertAdjacentHTML('beforeend', html); activeOverlay = document.getElementById('shared-modal-overlay'); // Lucide-Icons rendern if (window.lucide) window.lucide.createIcons(); // Focus-Trap 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); } // Overlay-Click schließt Modal activeOverlay.addEventListener('click', (e) => { if (e.target === activeOverlay) closeModal(); }); // iOS PWA: click-Events auf non-interactive divs sind unzuverlässig → // touchend als Fallback (passive, damit Scroll nicht blockiert wird) activeOverlay.addEventListener('touchend', (e) => { if (e.target === activeOverlay) closeModal(); }, { passive: true }); // Close-Button activeOverlay.querySelector('[data-action="close-modal"]') ?.addEventListener('click', () => closeModal()); // Escape document.addEventListener('keydown', onEscape); // Callback für Aufrufer (Form-Events binden etc.) if (typeof onSave === 'function') onSave(panel); // Loading-State: btn--loading auf Submit-Button während async-Save. // rAF-Check: Validierung schlägt fehl → btn bleibt enabled → Loading sofort entfernen. // MutationObserver: Error-Pfad → btn wird re-enabled → Loading entfernen. panel.addEventListener('submit', (e) => { const btn = e.target.querySelector('[type="submit"], .btn--primary'); if (!btn || btn.disabled) return; btn.classList.add('btn--loading'); requestAnimationFrame(() => { if (!btn.disabled) { btn.classList.remove('btn--loading'); return; } const mo = new MutationObserver(() => { if (!btn.disabled) { btn.classList.remove('btn--loading'); mo.disconnect(); } }); mo.observe(btn, { attributes: true, attributeFilter: ['disabled'] }); }); }, { capture: true }); // Standalone: Statusbar abdunkeln (Overlay-Effekt) if (window.oikos?.setThemeColor) { window.oikos.setThemeColor(OVERLAY_THEME_COLOR, OVERLAY_THEME_COLOR); } } // -------------------------------------------------------- // closeModal // -------------------------------------------------------- export async function closeModal({ force = false } = {}) { if (!activeOverlay || _isClosing) return; _isClosing = true; if (!force) { const panel = activeOverlay.querySelector('.modal-panel'); if (panel && isFormDirty(panel)) { const dirtyOverlay = activeOverlay; const dirtySnapshot = _initialFormSnapshot; let confirmed; try { activeOverlay = null; _isClosing = false; confirmed = await confirmModal(t('modal.unsavedChanges'), { danger: false, confirmLabel: t('modal.discardChanges'), }); } catch (err) { activeOverlay = dirtyOverlay; _initialFormSnapshot = dirtySnapshot; _isClosing = false; throw err; } activeOverlay = dirtyOverlay; _initialFormSnapshot = dirtySnapshot; if (!confirmed) { document.body.style.overflow = 'hidden'; if (window.oikos?.setThemeColor) { window.oikos.setThemeColor(OVERLAY_THEME_COLOR, OVERLAY_THEME_COLOR); } _isClosing = false; return; } _isClosing = true; } } _initialFormSnapshot = null; document.removeEventListener('keydown', onEscape); // 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 if (focusTrapHandler) { if (panel) panel.removeEventListener('keydown', focusTrapHandler); focusTrapHandler = null; } if (panel?._onInputFocus) { panel.removeEventListener('focusin', panel._onInputFocus); } // Sheet-Out-Animation auf Mobile, danach _doClose const isMobile = window.innerWidth < 768; if (isMobile && panel) { panel.classList.add('modal-panel--closing'); // Fallback-Timer falls animationend nicht feuert (prefers-reduced-motion, Tab-Wechsel etc.) const fallback = setTimeout(() => { _isClosing = false; _doClose(capturedOverlay); }, 300); panel.addEventListener('animationend', () => { clearTimeout(fallback); _isClosing = false; _doClose(capturedOverlay); }, { once: true }); return; } _isClosing = false; _doClose(capturedOverlay); } // -------------------------------------------------------- // promptModal - Ersatz für native prompt() // -------------------------------------------------------- /** * Öffnet ein Modal mit Textfeld als Ersatz für native prompt(). * Gibt ein Promise zurück: string bei OK, null bei Cancel/Escape. * * @param {string} label - Beschriftung / Frage * @param {string} [defaultValue=''] - Vorausgefüllter Wert * @returns {Promise