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:
+113
-30
@@ -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,18 +32,47 @@ const FOCUSABLE = [
|
|||||||
|
|
||||||
function trapFocus(container) {
|
function trapFocus(container) {
|
||||||
focusTrapHandler = (e) => {
|
focusTrapHandler = (e) => {
|
||||||
if (e.key !== 'Tab') return;
|
// Tab-Trap: Fokus innerhalb des Modals halten
|
||||||
const focusable = container.querySelectorAll(FOCUSABLE);
|
if (e.key === 'Tab') {
|
||||||
if (!focusable.length) return;
|
const focusable = container.querySelectorAll(FOCUSABLE);
|
||||||
const first = focusable[0];
|
if (!focusable.length) return;
|
||||||
const last = focusable[focusable.length - 1];
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
|
||||||
if (e.shiftKey && document.activeElement === first) {
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
last.focus();
|
last.focus();
|
||||||
} else if (!e.shiftKey && document.activeElement === last) {
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
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
|
const panel = activeOverlay.querySelector('.modal-panel');
|
||||||
|
|
||||||
|
// Focus-Trap-Handler und Virtual-Keyboard-Listener entfernen
|
||||||
if (focusTrapHandler) {
|
if (focusTrapHandler) {
|
||||||
const panel = activeOverlay.querySelector('.modal-panel');
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|||||||
Reference in New Issue
Block a user