Files
oikos/public/components/modal.js
T
ulsklyc d5a0e701b0 a11y: aria-hidden auf allen Icons + aria-label auf icon-only Buttons (Redesign Phase F)
- Alle data-lucide Icons: aria-hidden="true" (dekorativ)
- Icon-only Buttons/Links: title→aria-label (Screenreader-zugänglich)
- Betrifft: modal.js, contacts, meals, budget, calendar, notes, tasks,
  shopping, dashboard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 13:48:23 +01:00

159 lines
4.8 KiB
JavaScript

/**
* Modul: Shared Modal-System
* Zweck: Einheitliches Modal mit Focus-Trap, Escape-Handler, Overlay-Click,
* Focus-Restore, Scroll-Lock und aria-modal.
* Abhängigkeiten: CSS-Klassen aus layout.css (.modal-overlay, .modal-panel, etc.)
*
* API:
* openModal({ title, content, onSave, onDelete, size }) → void
* closeModal() → void
*/
let activeOverlay = null;
let previouslyFocused = null;
let focusTrapHandler = null;
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) => {
if (e.key !== 'Tab') return;
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();
}
};
container.addEventListener('keydown', focusTrapHandler);
// Focus first focusable element
const first = container.querySelector(FOCUSABLE);
if (first) {
setTimeout(() => first.focus(), 50);
}
}
// --------------------------------------------------------
// Escape-Handler
// --------------------------------------------------------
function onEscape(e) {
if (e.key === 'Escape') closeModal();
}
// --------------------------------------------------------
// 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)
if (activeOverlay) closeModal();
// Focus-Restore vorbereiten
previouslyFocused = document.activeElement;
// Scroll-Lock
document.body.style.overflow = 'hidden';
const sizeClass = size !== 'md' ? ` modal-panel--${size}` : '';
const html = `
<div class="modal-overlay" id="shared-modal-overlay">
<div class="modal-panel${sizeClass}" role="dialog" aria-modal="true"
aria-labelledby="shared-modal-title">
<div class="modal-panel__header">
<h2 class="modal-panel__title" id="shared-modal-title">${title}</h2>
<button class="modal-panel__close" data-action="close-modal" aria-label="Schließen">
<i data-lucide="x" style="width:18px;height:18px" aria-hidden="true"></i>
</button>
</div>
<div class="modal-panel__body">
${content}
</div>
</div>
</div>`;
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);
// Overlay-Click schließt Modal
activeOverlay.addEventListener('click', (e) => {
if (e.target === activeOverlay) closeModal();
});
// 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);
}
// --------------------------------------------------------
// closeModal
// --------------------------------------------------------
export function closeModal() {
if (!activeOverlay) return;
document.removeEventListener('keydown', onEscape);
// Focus-Trap-Handler entfernen
if (focusTrapHandler) {
const panel = activeOverlay.querySelector('.modal-panel');
if (panel) panel.removeEventListener('keydown', focusTrapHandler);
focusTrapHandler = null;
}
activeOverlay.remove();
activeOverlay = null;
// Scroll-Lock aufheben
document.body.style.overflow = '';
// Focus-Restore
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
previouslyFocused = null;
}
}