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:
@@ -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;
|
||||
}
|
||||
}
|
||||
+23
-57
@@ -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) =>
|
||||
`<option value="${u.id}" ${task?.assigned_to === u.id ? 'selected' : ''}>${u.display_name}</option>`
|
||||
@@ -232,15 +232,6 @@ function renderModal({ task = null, users = [] } = {}) {
|
||||
).join('');
|
||||
|
||||
return `
|
||||
<div class="modal-backdrop" id="task-modal-backdrop">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<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>
|
||||
<input type="hidden" id="task-id" value="${task?.id ?? ''}">
|
||||
|
||||
@@ -308,7 +299,7 @@ function renderModal({ task = null, users = [] } = {}) {
|
||||
|
||||
<div id="task-form-error" class="login-error" hidden></div>
|
||||
|
||||
<div class="modal__actions">
|
||||
<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>` : ''}
|
||||
@@ -316,9 +307,7 @@ function renderModal({ task = null, users = [] } = {}) {
|
||||
${isEdit ? 'Speichern' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>`;
|
||||
</form>`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
@@ -367,36 +356,27 @@ async function loadTaskForEdit(id) {
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Modal-Verwaltung
|
||||
// Modal-Verwaltung (delegiert an Shared Modal-System)
|
||||
// --------------------------------------------------------
|
||||
|
||||
function openModal(html) {
|
||||
document.body.insertAdjacentHTML('beforeend', html);
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
|
||||
function openTaskModal({ task = null, users = [] } = {}, container) {
|
||||
const isEdit = !!task;
|
||||
openSharedModal({
|
||||
title: isEdit ? 'Aufgabe bearbeiten' : 'Neue Aufgabe',
|
||||
content: renderModalContent({ task, users }),
|
||||
size: 'lg',
|
||||
onSave(panel) {
|
||||
// RRULE-Events binden
|
||||
bindRRuleEvents(document, 'task');
|
||||
|
||||
// Fokus auf erstes Eingabefeld
|
||||
setTimeout(() => document.getElementById('task-title')?.focus(), 50);
|
||||
// Form-Events
|
||||
panel.querySelector('#task-form')
|
||||
?.addEventListener('submit', (e) => handleFormSubmit(e, container));
|
||||
|
||||
// Backdrop-Klick schließt Modal
|
||||
document.getElementById('task-modal-backdrop')
|
||||
.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'task-modal-backdrop') closeModal();
|
||||
panel.querySelector('[data-action="delete-task"]')
|
||||
?.addEventListener('click', (e) => handleDeleteTask(e.currentTarget.dataset.id, container));
|
||||
},
|
||||
});
|
||||
|
||||
// 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;
|
||||
try {
|
||||
const task = await loadTaskForEdit(card.dataset.taskId);
|
||||
openModal(renderModal({ task, users: state.users }));
|
||||
wireModalEvents(container);
|
||||
openTaskModal({ task, users: state.users }, container);
|
||||
} catch (err) {
|
||||
window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger');
|
||||
}
|
||||
@@ -720,7 +699,7 @@ function wireSwipeGestures(container) {
|
||||
|
||||
row.addEventListener('touchstart', (e) => {
|
||||
// Geste ignorieren wenn Modal offen
|
||||
if (document.getElementById('task-modal-backdrop')) return;
|
||||
if (document.getElementById('shared-modal-overlay')) return;
|
||||
startX = e.touches[0].clientX;
|
||||
startY = e.touches[0].clientY;
|
||||
dx = 0;
|
||||
@@ -800,8 +779,7 @@ function wireSwipeGestures(container) {
|
||||
if (navigator.vibrate) navigator.vibrate(20);
|
||||
try {
|
||||
const task = await loadTaskForEdit(taskId);
|
||||
openModal(renderModal({ task, users: state.users }));
|
||||
wireModalEvents(container);
|
||||
openTaskModal({ task, users: state.users }, container);
|
||||
} catch (err) {
|
||||
window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger');
|
||||
}
|
||||
@@ -865,23 +843,12 @@ function wireGroupToggle(container) {
|
||||
|
||||
function wireNewTaskBtn(container) {
|
||||
const handler = () => {
|
||||
openModal(renderModal({ users: state.users }));
|
||||
wireModalEvents(container);
|
||||
openTaskModal({ users: state.users }, container);
|
||||
};
|
||||
container.querySelector('#btn-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) {
|
||||
const listEl = container.querySelector('#task-list');
|
||||
if (!listEl) return;
|
||||
@@ -922,8 +889,7 @@ function wireTaskList(container) {
|
||||
if (action === 'edit-task' || action === 'open-task') {
|
||||
try {
|
||||
const task = await loadTaskForEdit(id);
|
||||
openModal(renderModal({ task, users: state.users }));
|
||||
wireModalEvents(container);
|
||||
openTaskModal({ task, users: state.users }, container);
|
||||
} catch (err) {
|
||||
window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger');
|
||||
}
|
||||
|
||||
@@ -599,6 +599,8 @@
|
||||
border-radius: var(--radius-lg);
|
||||
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 {
|
||||
|
||||
@@ -472,89 +472,6 @@
|
||||
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)
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
Reference in New Issue
Block a user