feat: shared modal system + migrate tasks module

- 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 <noreply@anthropic.com>
This commit is contained in:
ulsklyc
2026-03-26 12:04:57 +01:00
parent b51177b5e3
commit 7e718e2422
4 changed files with 258 additions and 215 deletions
+158
View File
@@ -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 = `
<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"></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;
}
}
+98 -132
View File
@@ -6,6 +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';
// -------------------------------------------------------- // --------------------------------------------------------
// Konstanten // Konstanten
@@ -215,9 +216,8 @@ function renderTaskGroups(tasks, groupMode) {
// Task-Modal (Erstellen / Bearbeiten) // Task-Modal (Erstellen / Bearbeiten)
// -------------------------------------------------------- // --------------------------------------------------------
function renderModal({ task = null, users = [] } = {}) { function renderModalContent({ task = null, users = [] } = {}) {
const isEdit = !!task; const isEdit = !!task;
const title = isEdit ? 'Aufgabe bearbeiten' : 'Neue Aufgabe';
const userOptions = users.map((u) => const userOptions = users.map((u) =>
`<option value="${u.id}" ${task?.assigned_to === u.id ? 'selected' : ''}>${u.display_name}</option>` `<option value="${u.id}" ${task?.assigned_to === u.id ? 'selected' : ''}>${u.display_name}</option>`
@@ -232,93 +232,82 @@ function renderModal({ task = null, users = [] } = {}) {
).join(''); ).join('');
return ` return `
<div class="modal-backdrop" id="task-modal-backdrop"> <form id="task-form" novalidate>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title"> <input type="hidden" id="task-id" value="${task?.id ?? ''}">
<div class="modal__header">
<h2 class="modal__title" id="modal-title">${title}</h2>
<button class="modal__close" data-action="close-modal" aria-label="Schließen">
<i data-lucide="x" style="width:18px;height:18px"></i>
</button>
</div>
<form id="task-form" novalidate> <div class="form-group">
<input type="hidden" id="task-id" value="${task?.id ?? ''}"> <label class="label" for="task-title">Titel *</label>
<input class="input" type="text" id="task-title" name="title"
<div class="form-group"> value="${task?.title ?? ''}" placeholder="Was muss erledigt werden?"
<label class="label" for="task-title">Titel *</label> required autocomplete="off">
<input class="input" type="text" id="task-title" name="title"
value="${task?.title ?? ''}" placeholder="Was muss erledigt werden?"
required autocomplete="off">
</div>
<div class="form-group">
<label class="label" for="task-description">Notiz</label>
<textarea class="input" id="task-description" name="description"
rows="2" placeholder="Optionale Details…"
style="resize:vertical">${task?.description ?? ''}</textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group" style="margin-bottom:0">
<label class="label" for="task-priority">Priorität</label>
<select class="input" id="task-priority" name="priority" style="min-height:44px">
${priorityOptions}
</select>
</div>
<div class="form-group" style="margin-bottom:0">
<label class="label" for="task-category">Kategorie</label>
<select class="input" id="task-category" name="category" style="min-height:44px">
${categoryOptions}
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);margin-top:var(--space-4)">
<div class="form-group" style="margin-bottom:0">
<label class="label" for="task-due-date">Fälligkeit</label>
<input class="input" type="date" id="task-due-date" name="due_date"
value="${task?.due_date ?? ''}">
</div>
<div class="form-group" style="margin-bottom:0">
<label class="label" for="task-due-time">Uhrzeit</label>
<input class="input" type="time" id="task-due-time" name="due_time"
value="${task?.due_time ?? ''}">
</div>
</div>
<div class="form-group" style="margin-top:var(--space-4)">
<label class="label" for="task-assigned">Zugewiesen an</label>
<select class="input" id="task-assigned" name="assigned_to" style="min-height:44px">
<option value="">— Niemand —</option>
${userOptions}
</select>
</div>
${isEdit ? `
<div class="form-group">
<label class="label" for="task-status">Status</label>
<select class="input" id="task-status" name="status" style="min-height:44px">
${STATUSES.map((s) =>
`<option value="${s.value}" ${task.status === s.value ? 'selected' : ''}>${s.label}</option>`
).join('')}
</select>
</div>` : ''}
${renderRRuleFields('task', task?.recurrence_rule)}
<div id="task-form-error" class="login-error" hidden></div>
<div class="modal__actions">
${isEdit ? `
<button type="button" class="btn btn--danger" data-action="delete-task"
data-id="${task.id}">Löschen</button>` : ''}
<button type="submit" class="btn btn--primary" id="task-submit-btn">
${isEdit ? 'Speichern' : 'Erstellen'}
</button>
</div>
</form>
</div> </div>
</div>`;
<div class="form-group">
<label class="label" for="task-description">Notiz</label>
<textarea class="input" id="task-description" name="description"
rows="2" placeholder="Optionale Details…"
style="resize:vertical">${task?.description ?? ''}</textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group" style="margin-bottom:0">
<label class="label" for="task-priority">Priorität</label>
<select class="input" id="task-priority" name="priority" style="min-height:44px">
${priorityOptions}
</select>
</div>
<div class="form-group" style="margin-bottom:0">
<label class="label" for="task-category">Kategorie</label>
<select class="input" id="task-category" name="category" style="min-height:44px">
${categoryOptions}
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);margin-top:var(--space-4)">
<div class="form-group" style="margin-bottom:0">
<label class="label" for="task-due-date">Fälligkeit</label>
<input class="input" type="date" id="task-due-date" name="due_date"
value="${task?.due_date ?? ''}">
</div>
<div class="form-group" style="margin-bottom:0">
<label class="label" for="task-due-time">Uhrzeit</label>
<input class="input" type="time" id="task-due-time" name="due_time"
value="${task?.due_time ?? ''}">
</div>
</div>
<div class="form-group" style="margin-top:var(--space-4)">
<label class="label" for="task-assigned">Zugewiesen an</label>
<select class="input" id="task-assigned" name="assigned_to" style="min-height:44px">
<option value="">— Niemand —</option>
${userOptions}
</select>
</div>
${isEdit ? `
<div class="form-group">
<label class="label" for="task-status">Status</label>
<select class="input" id="task-status" name="status" style="min-height:44px">
${STATUSES.map((s) =>
`<option value="${s.value}" ${task.status === s.value ? 'selected' : ''}>${s.label}</option>`
).join('')}
</select>
</div>` : ''}
${renderRRuleFields('task', task?.recurrence_rule)}
<div id="task-form-error" class="login-error" hidden></div>
<div class="modal-panel__footer" style="padding:0;border:none;margin-top:var(--space-6)">
${isEdit ? `
<button type="button" class="btn btn--danger" data-action="delete-task"
data-id="${task.id}">Löschen</button>` : ''}
<button type="submit" class="btn btn--primary" id="task-submit-btn">
${isEdit ? 'Speichern' : 'Erstellen'}
</button>
</div>
</form>`;
} }
// -------------------------------------------------------- // --------------------------------------------------------
@@ -367,36 +356,27 @@ async function loadTaskForEdit(id) {
} }
// -------------------------------------------------------- // --------------------------------------------------------
// Modal-Verwaltung // Modal-Verwaltung (delegiert an Shared Modal-System)
// -------------------------------------------------------- // --------------------------------------------------------
function openModal(html) { function openTaskModal({ task = null, users = [] } = {}, container) {
document.body.insertAdjacentHTML('beforeend', html); const isEdit = !!task;
if (window.lucide) window.lucide.createIcons(); openSharedModal({
title: isEdit ? 'Aufgabe bearbeiten' : 'Neue Aufgabe',
content: renderModalContent({ task, users }),
size: 'lg',
onSave(panel) {
// RRULE-Events binden
bindRRuleEvents(document, 'task');
// RRULE-Events binden // Form-Events
bindRRuleEvents(document, 'task'); panel.querySelector('#task-form')
?.addEventListener('submit', (e) => handleFormSubmit(e, container));
// Fokus auf erstes Eingabefeld panel.querySelector('[data-action="delete-task"]')
setTimeout(() => document.getElementById('task-title')?.focus(), 50); ?.addEventListener('click', (e) => handleDeleteTask(e.currentTarget.dataset.id, container));
},
// Backdrop-Klick schließt Modal });
document.getElementById('task-modal-backdrop')
.addEventListener('click', (e) => {
if (e.target.id === 'task-modal-backdrop') closeModal();
});
// Escape schließt Modal
document.addEventListener('keydown', onEscape);
}
function closeModal() {
document.getElementById('task-modal-backdrop')?.remove();
document.removeEventListener('keydown', onEscape);
}
function onEscape(e) {
if (e.key === 'Escape') closeModal();
} }
// -------------------------------------------------------- // --------------------------------------------------------
@@ -604,8 +584,7 @@ function wireKanbanDrag(container) {
if (!card) return; if (!card) return;
try { try {
const task = await loadTaskForEdit(card.dataset.taskId); const task = await loadTaskForEdit(card.dataset.taskId);
openModal(renderModal({ task, users: state.users })); openTaskModal({ task, users: state.users }, container);
wireModalEvents(container);
} catch (err) { } catch (err) {
window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger'); window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger');
} }
@@ -720,7 +699,7 @@ function wireSwipeGestures(container) {
row.addEventListener('touchstart', (e) => { row.addEventListener('touchstart', (e) => {
// Geste ignorieren wenn Modal offen // Geste ignorieren wenn Modal offen
if (document.getElementById('task-modal-backdrop')) return; if (document.getElementById('shared-modal-overlay')) return;
startX = e.touches[0].clientX; startX = e.touches[0].clientX;
startY = e.touches[0].clientY; startY = e.touches[0].clientY;
dx = 0; dx = 0;
@@ -800,8 +779,7 @@ function wireSwipeGestures(container) {
if (navigator.vibrate) navigator.vibrate(20); if (navigator.vibrate) navigator.vibrate(20);
try { try {
const task = await loadTaskForEdit(taskId); const task = await loadTaskForEdit(taskId);
openModal(renderModal({ task, users: state.users })); openTaskModal({ task, users: state.users }, container);
wireModalEvents(container);
} catch (err) { } catch (err) {
window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger'); window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger');
} }
@@ -865,23 +843,12 @@ function wireGroupToggle(container) {
function wireNewTaskBtn(container) { function wireNewTaskBtn(container) {
const handler = () => { const handler = () => {
openModal(renderModal({ users: state.users })); openTaskModal({ users: state.users }, container);
wireModalEvents(container);
}; };
container.querySelector('#btn-new-task')?.addEventListener('click', handler); container.querySelector('#btn-new-task')?.addEventListener('click', handler);
container.querySelector('#fab-new-task')?.addEventListener('click', handler); container.querySelector('#fab-new-task')?.addEventListener('click', handler);
} }
function wireModalEvents(container) {
document.getElementById('task-form')?.addEventListener('submit', (e) => handleFormSubmit(e, container));
document.querySelector('[data-action="close-modal"]')
?.addEventListener('click', closeModal);
document.querySelector('[data-action="delete-task"]')
?.addEventListener('click', (e) => handleDeleteTask(e.currentTarget.dataset.id, container));
}
function wireTaskList(container) { function wireTaskList(container) {
const listEl = container.querySelector('#task-list'); const listEl = container.querySelector('#task-list');
if (!listEl) return; if (!listEl) return;
@@ -922,8 +889,7 @@ function wireTaskList(container) {
if (action === 'edit-task' || action === 'open-task') { if (action === 'edit-task' || action === 'open-task') {
try { try {
const task = await loadTaskForEdit(id); const task = await loadTaskForEdit(id);
openModal(renderModal({ task, users: state.users })); openTaskModal({ task, users: state.users }, container);
wireModalEvents(container);
} catch (err) { } catch (err) {
window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger'); window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger');
} }
+2
View File
@@ -599,6 +599,8 @@
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
animation: modal-scale-in 0.2s var(--ease-out) forwards; animation: modal-scale-in 0.2s var(--ease-out) forwards;
} }
.modal-panel--sm { max-width: 400px; }
.modal-panel--lg { max-width: 680px; }
} }
.modal-panel__header { .modal-panel__header {
-83
View File
@@ -472,89 +472,6 @@
color: var(--color-accent); color: var(--color-accent);
} }
/* --------------------------------------------------------
* Task-Modal (Erstellen / Bearbeiten)
* -------------------------------------------------------- */
.modal-backdrop {
position: fixed;
inset: 0;
background-color: var(--color-overlay);
z-index: var(--z-modal);
display: flex;
align-items: flex-end;
justify-content: center;
animation: backdrop-in 0.2s ease;
}
@media (min-width: 640px) {
.modal-backdrop {
align-items: center;
}
}
@keyframes backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
background-color: var(--color-surface);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
width: 100%;
max-height: 92dvh;
overflow-y: auto;
padding: var(--space-6);
padding-bottom: calc(var(--space-6) + var(--safe-area-inset-bottom));
animation: modal-in 0.25s ease;
}
@media (min-width: 640px) {
.modal {
border-radius: var(--radius-lg);
max-width: 540px;
max-height: 85dvh;
padding-bottom: var(--space-6);
}
}
@keyframes modal-in {
from { transform: translateY(24px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-5);
}
.modal__title {
font-size: var(--text-xl);
font-weight: var(--font-weight-bold);
}
.modal__close {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background-color: var(--color-surface-2);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
min-height: unset;
color: var(--color-text-secondary);
}
.modal__actions {
display: flex;
gap: var(--space-3);
margin-top: var(--space-6);
}
.modal__actions .btn { flex: 1; }
/* -------------------------------------------------------- /* --------------------------------------------------------
* Overdue-Badge (Navigation) * Overdue-Badge (Navigation)
* -------------------------------------------------------- */ * -------------------------------------------------------- */