From 7e718e2422e99cac70c90b0331e953285ddd75da Mon Sep 17 00:00:00 2001 From: ulsklyc Date: Thu, 26 Mar 2026 12:04:57 +0100 Subject: [PATCH] feat: shared modal system + migrate tasks module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add public/components/modal.js with focus-trap, escape-handler, overlay-click, focus-restore, scroll-lock, aria-modal (Spec §5.1/§5.2) - Migrate tasks.js from custom modal to shared openModal/closeModal API - Remove .modal-backdrop/.modal/.modal__* styles from tasks.css - Add .modal-panel--sm/--lg sizing variants to layout.css Co-Authored-By: Claude Opus 4.6 --- public/components/modal.js | 158 +++++++++++++++++++++++++ public/pages/tasks.js | 230 ++++++++++++++++--------------------- public/styles/layout.css | 2 + public/styles/tasks.css | 83 ------------- 4 files changed, 258 insertions(+), 215 deletions(-) create mode 100644 public/components/modal.js diff --git a/public/components/modal.js b/public/components/modal.js new file mode 100644 index 0000000..39ef258 --- /dev/null +++ b/public/components/modal.js @@ -0,0 +1,158 @@ +/** + * 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 = ` + `; + + 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; + } +} diff --git a/public/pages/tasks.js b/public/pages/tasks.js index 479f135..02238b7 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -6,6 +6,7 @@ import { api } from '/api.js'; import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js'; +import { openModal as openSharedModal, closeModal } from '/components/modal.js'; // -------------------------------------------------------- // Konstanten @@ -215,9 +216,8 @@ function renderTaskGroups(tasks, groupMode) { // Task-Modal (Erstellen / Bearbeiten) // -------------------------------------------------------- -function renderModal({ task = null, users = [] } = {}) { +function renderModalContent({ task = null, users = [] } = {}) { const isEdit = !!task; - const title = isEdit ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'; const userOptions = users.map((u) => `` @@ -232,93 +232,82 @@ function renderModal({ task = null, users = [] } = {}) { ).join(''); return ` -