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:
+103
-20
@@ -2,6 +2,7 @@
|
||||
* Modul: Shared Modal-System
|
||||
* Zweck: Einheitliches Modal mit Focus-Trap, Escape-Handler, Overlay-Click,
|
||||
* 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.)
|
||||
*
|
||||
* API:
|
||||
@@ -31,7 +32,8 @@ const FOCUSABLE = [
|
||||
|
||||
function trapFocus(container) {
|
||||
focusTrapHandler = (e) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
// Tab-Trap: Fokus innerhalb des Modals halten
|
||||
if (e.key === 'Tab') {
|
||||
const focusable = container.querySelectorAll(FOCUSABLE);
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
@@ -44,6 +46,34 @@ function trapFocus(container) {
|
||||
e.preventDefault();
|
||||
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);
|
||||
|
||||
@@ -73,6 +103,61 @@ function onEscape(e) {
|
||||
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
|
||||
// --------------------------------------------------------
|
||||
@@ -126,6 +211,11 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
|
||||
const panel = activeOverlay.querySelector('.modal-panel');
|
||||
trapFocus(panel);
|
||||
|
||||
// Swipe-to-Close auf Mobile
|
||||
if (window.innerWidth < 768) {
|
||||
_wireSheetSwipe(panel);
|
||||
}
|
||||
|
||||
// Overlay-Click schließt Modal
|
||||
activeOverlay.addEventListener('click', (e) => {
|
||||
if (e.target === activeOverlay) closeModal();
|
||||
@@ -156,33 +246,26 @@ export function closeModal() {
|
||||
|
||||
document.removeEventListener('keydown', onEscape);
|
||||
|
||||
// Focus-Trap-Handler entfernen
|
||||
if (focusTrapHandler) {
|
||||
const panel = activeOverlay.querySelector('.modal-panel');
|
||||
|
||||
// Focus-Trap-Handler und Virtual-Keyboard-Listener entfernen
|
||||
if (focusTrapHandler) {
|
||||
if (panel) panel.removeEventListener('keydown', focusTrapHandler);
|
||||
focusTrapHandler = null;
|
||||
}
|
||||
|
||||
// Virtual-Keyboard-Listener entfernen
|
||||
const panel = activeOverlay.querySelector('.modal-panel');
|
||||
if (panel?._onInputFocus) {
|
||||
panel.removeEventListener('focusin', panel._onInputFocus);
|
||||
}
|
||||
|
||||
activeOverlay.remove();
|
||||
activeOverlay = null;
|
||||
|
||||
// Scroll-Lock aufheben
|
||||
document.body.style.overflow = '';
|
||||
|
||||
// Focus-Restore
|
||||
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
||||
previouslyFocused.focus();
|
||||
previouslyFocused = null;
|
||||
// Sheet-Out-Animation auf Mobile, danach _doClose
|
||||
const isMobile = window.innerWidth < 768;
|
||||
if (isMobile && panel) {
|
||||
panel.classList.add('modal-panel--closing');
|
||||
panel.addEventListener('animationend', () => {
|
||||
_doClose();
|
||||
}, { once: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Standalone: Statusbar-Farbe zur aktuellen Route wiederherstellen
|
||||
if (window.oikos?.restoreThemeColor) {
|
||||
window.oikos.restoreThemeColor();
|
||||
}
|
||||
_doClose();
|
||||
}
|
||||
|
||||
@@ -703,6 +703,45 @@
|
||||
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
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
Reference in New Issue
Block a user