feat: bottom sheet modal on mobile with swipe-to-close and Enter-key form navigation

- Add sheet drag handle (::before pseudo-element) and closing animation (sheet-out keyframe) for mobile < 768px in layout.css
- Add prefers-reduced-motion support disabling all modal animations
- Refactor closeModal() to extract _doClose() and play slide-out animation on mobile before removing the overlay
- Add _wireSheetSwipe() for touch drag-to-dismiss (threshold 80px)
- Extend trapFocus() Enter handler: advances focus through inputs/selects and triggers primary button on last field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ulas
2026-03-30 21:19:25 +02:00
parent b9ec36611d
commit 1684215da8
2 changed files with 152 additions and 30 deletions
+103 -20
View File
@@ -2,6 +2,7 @@
* Modul: Shared Modal-System * Modul: Shared Modal-System
* Zweck: Einheitliches Modal mit Focus-Trap, Escape-Handler, Overlay-Click, * Zweck: Einheitliches Modal mit Focus-Trap, Escape-Handler, Overlay-Click,
* Focus-Restore, Scroll-Lock und aria-modal. * 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.) * Abhängigkeiten: CSS-Klassen aus layout.css (.modal-overlay, .modal-panel, etc.)
* *
* API: * API:
@@ -31,7 +32,8 @@ const FOCUSABLE = [
function trapFocus(container) { function trapFocus(container) {
focusTrapHandler = (e) => { focusTrapHandler = (e) => {
if (e.key !== 'Tab') return; // Tab-Trap: Fokus innerhalb des Modals halten
if (e.key === 'Tab') {
const focusable = container.querySelectorAll(FOCUSABLE); const focusable = container.querySelectorAll(FOCUSABLE);
if (!focusable.length) return; if (!focusable.length) return;
const first = focusable[0]; const first = focusable[0];
@@ -44,6 +46,34 @@ function trapFocus(container) {
e.preventDefault(); e.preventDefault();
first.focus(); 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); container.addEventListener('keydown', focusTrapHandler);
@@ -73,6 +103,61 @@ function onEscape(e) {
if (e.key === 'Escape') closeModal(); if (e.key === 'Escape') closeModal();
} }
// --------------------------------------------------------
// Swipe-to-Close (Mobile)
// --------------------------------------------------------
function _wireSheetSwipe(panel) {
let startY = 0;
let dragging = false;
panel.addEventListener('touchstart', (e) => {
startY = e.touches[0].clientY;
dragging = true;
}, { passive: true });
panel.addEventListener('touchmove', (e) => {
if (!dragging) return;
const dy = e.touches[0].clientY - startY;
if (dy < 0) return; // Kein Swipe nach oben
panel.style.transform = `translateY(${dy * 0.6}px)`;
}, { passive: true });
panel.addEventListener('touchend', (e) => {
if (!dragging) return;
dragging = false;
const dy = e.changedTouches[0].clientY - startY;
panel.style.transform = '';
if (dy > 80) {
closeModal();
}
});
}
// --------------------------------------------------------
// _doClose — gemeinsame Cleanup-Logik
// --------------------------------------------------------
function _doClose() {
if (!activeOverlay) return;
activeOverlay.remove();
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 // openModal
// -------------------------------------------------------- // --------------------------------------------------------
@@ -126,6 +211,11 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
const panel = activeOverlay.querySelector('.modal-panel'); const panel = activeOverlay.querySelector('.modal-panel');
trapFocus(panel); trapFocus(panel);
// Swipe-to-Close auf Mobile
if (window.innerWidth < 768) {
_wireSheetSwipe(panel);
}
// Overlay-Click schließt Modal // Overlay-Click schließt Modal
activeOverlay.addEventListener('click', (e) => { activeOverlay.addEventListener('click', (e) => {
if (e.target === activeOverlay) closeModal(); if (e.target === activeOverlay) closeModal();
@@ -156,33 +246,26 @@ export function closeModal() {
document.removeEventListener('keydown', onEscape); document.removeEventListener('keydown', onEscape);
// Focus-Trap-Handler entfernen
if (focusTrapHandler) {
const panel = activeOverlay.querySelector('.modal-panel'); const panel = activeOverlay.querySelector('.modal-panel');
// Focus-Trap-Handler und Virtual-Keyboard-Listener entfernen
if (focusTrapHandler) {
if (panel) panel.removeEventListener('keydown', focusTrapHandler); if (panel) panel.removeEventListener('keydown', focusTrapHandler);
focusTrapHandler = null; focusTrapHandler = null;
} }
// Virtual-Keyboard-Listener entfernen
const panel = activeOverlay.querySelector('.modal-panel');
if (panel?._onInputFocus) { if (panel?._onInputFocus) {
panel.removeEventListener('focusin', panel._onInputFocus); panel.removeEventListener('focusin', panel._onInputFocus);
} }
activeOverlay.remove(); // Sheet-Out-Animation auf Mobile, danach _doClose
activeOverlay = null; const isMobile = window.innerWidth < 768;
if (isMobile && panel) {
// Scroll-Lock aufheben panel.classList.add('modal-panel--closing');
document.body.style.overflow = ''; panel.addEventListener('animationend', () => {
_doClose();
// Focus-Restore }, { once: true });
if (previouslyFocused && typeof previouslyFocused.focus === 'function') { return;
previouslyFocused.focus();
previouslyFocused = null;
} }
// Standalone: Statusbar-Farbe zur aktuellen Route wiederherstellen _doClose();
if (window.oikos?.restoreThemeColor) {
window.oikos.restoreThemeColor();
}
} }
+39
View File
@@ -703,6 +703,45 @@
to { opacity: 1; transform: scale(1); } to { opacity: 1; transform: scale(1); }
} }
/* ── Bottom Sheet: Handle + Closing Animation (Mobile < 768px) ── */
@media (max-width: 767px) {
.modal-panel {
/* Extra top padding for the drag handle */
padding-top: calc(var(--space-4) + 20px);
position: relative;
}
.modal-panel::before {
content: '';
position: absolute;
top: var(--space-3);
left: 50%;
transform: translateX(-50%);
width: 36px;
height: 4px;
background: var(--color-border);
border-radius: var(--radius-full);
}
.modal-panel--closing {
animation: sheet-out 0.2s ease forwards;
}
}
@keyframes sheet-out {
from { transform: translateY(0); }
to { transform: translateY(100%); }
}
@media (prefers-reduced-motion: reduce) {
.modal-panel {
animation: none;
}
.modal-panel--closing {
animation: none;
}
}
/* -------------------------------------------------------- /* --------------------------------------------------------
* Buttons * Buttons
* -------------------------------------------------------- */ * -------------------------------------------------------- */