diff --git a/src/components/MobileSheet.css b/src/components/MobileSheet.css new file mode 100644 index 0000000..767145a --- /dev/null +++ b/src/components/MobileSheet.css @@ -0,0 +1,125 @@ +/* MobileSheet — bottom sheet modal */ + +/* Backdrop */ +.mobile-sheet__backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 400; + opacity: 0; + pointer-events: none; + transition: opacity 0.25s var(--ease-out); +} + +.mobile-sheet__backdrop.is-open { + opacity: 1; + pointer-events: auto; +} + +/* Sheet */ +.mobile-sheet { + position: fixed; + bottom: 0; + left: 0; + right: 0; + max-height: 90vh; + background: var(--bg-secondary); + border-top: 1px solid var(--border-line); + border-radius: var(--radius-xl) var(--radius-xl) 0 0; + z-index: 401; + display: flex; + flex-direction: column; + touch-action: none; + transform: translateY(100%); + transition: transform 0.3s var(--ease-out); +} + +.mobile-sheet.is-open { + transform: translateY(0); +} + +/* Snap variants */ +.mobile-sheet.snap-half { + max-height: 50vh; +} + +/* Drag handle area */ +.mobile-sheet__handle { + display: flex; + align-items: center; + justify-content: center; + padding: 12px 0 8px; + cursor: grab; + flex-shrink: 0; +} + +.mobile-sheet__handle:active { + cursor: grabbing; +} + +.mobile-sheet__handle-bar { + display: block; + width: 36px; + height: 4px; + background: var(--text-muted); + border-radius: 2px; +} + +/* Header */ +.mobile-sheet__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px 12px; + border-bottom: 1px solid var(--border-line); + flex-shrink: 0; +} + +.mobile-sheet__title { + font-family: var(--font-display); + font-size: 16px; + font-weight: 600; + color: var(--text-bright); +} + +.mobile-sheet__close { + display: flex; + align-items: center; + justify-content: center; + min-width: 44px; + min-height: 44px; + background: none; + border: none; + color: var(--text-dim); + cursor: pointer; + border-radius: var(--radius-sm); + -webkit-tap-highlight-color: transparent; + transition: color 0.18s var(--ease-out); +} + +.mobile-sheet__close:hover { + color: var(--text-default); +} + +/* Scrollable body */ +.mobile-sheet__body { + flex: 1; + overflow-y: auto; + padding: 16px 20px; + padding-bottom: calc(16px + var(--safe-area-bottom)); + overscroll-behavior: contain; +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .mobile-sheet__backdrop, + .mobile-sheet { + transition: none; + } + + .mobile-sheet__close { + transition: none; + } +} diff --git a/src/components/MobileSheet.jsx b/src/components/MobileSheet.jsx new file mode 100644 index 0000000..59b238b --- /dev/null +++ b/src/components/MobileSheet.jsx @@ -0,0 +1,113 @@ +import { useEffect, useRef, useState } from 'react'; +import './MobileSheet.css'; + +export default function MobileSheet({ open, onClose, title, snap = 'full', children }) { + const [dragY, setDragY] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const startYRef = useRef(null); + const sheetRef = useRef(null); + + // Lock body scroll when open + useEffect(() => { + if (open) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [open]); + + // Reset drag state on close + useEffect(() => { + if (!open) { + setDragY(0); + setIsDragging(false); + } + }, [open]); + + const handleHandleTouchStart = (e) => { + startYRef.current = e.touches[0].clientY; + setIsDragging(true); + }; + + const handleHandleTouchMove = (e) => { + if (startYRef.current === null) return; + const delta = e.touches[0].clientY - startYRef.current; + if (delta < 0) return; // no drag up + setDragY(delta); + }; + + const handleHandleTouchEnd = () => { + setIsDragging(false); + if (dragY > 100) { + setDragY(0); + onClose?.(); + } else { + setDragY(0); + } + startYRef.current = null; + }; + + const sheetTransform = open + ? `translateY(${isDragging ? dragY : 0}px)` + : 'translateY(100%)'; + + return ( + <> +
+