All user-facing components now support dark mode via prefers-color-scheme. - Dark backgrounds: gray-950/900/800 - Dark text: gray-100/200/300/400 - Orange brand colors adapt with darker tints - Glass effects work in both modes - Skeletons, cards, filters, bottom sheet all themed - Google Maps InfoWindow stays light (maps don't support dark) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
117 lines
3.7 KiB
TypeScript
117 lines
3.7 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
interface BottomSheetProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
const SNAP_POINTS = { PEEK: 0.4, HALF: 0.55, FULL: 0.92 };
|
|
const VELOCITY_THRESHOLD = 0.5;
|
|
|
|
export default function BottomSheet({ open, onClose, children }: BottomSheetProps) {
|
|
const sheetRef = useRef<HTMLDivElement>(null);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
const [height, setHeight] = useState(SNAP_POINTS.PEEK);
|
|
const [dragging, setDragging] = useState(false);
|
|
const dragState = useRef({ startY: 0, startH: 0, lastY: 0, lastTime: 0 });
|
|
|
|
// Reset to peek when opened
|
|
useEffect(() => {
|
|
if (open) setHeight(SNAP_POINTS.PEEK);
|
|
}, [open]);
|
|
|
|
const snapTo = useCallback((h: number, velocity: number) => {
|
|
// If fast downward swipe, close
|
|
if (velocity > VELOCITY_THRESHOLD && h < SNAP_POINTS.HALF) {
|
|
onClose();
|
|
return;
|
|
}
|
|
// Snap to nearest point
|
|
const points = [SNAP_POINTS.PEEK, SNAP_POINTS.HALF, SNAP_POINTS.FULL];
|
|
let best = points[0];
|
|
let bestDist = Math.abs(h - best);
|
|
for (const p of points) {
|
|
const d = Math.abs(h - p);
|
|
if (d < bestDist) { best = p; bestDist = d; }
|
|
}
|
|
// If dragged below peek, close
|
|
if (h < SNAP_POINTS.PEEK * 0.6) {
|
|
onClose();
|
|
return;
|
|
}
|
|
setHeight(best);
|
|
}, [onClose]);
|
|
|
|
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
|
// Don't intercept if scrolling inside content that has scrollable area
|
|
const content = contentRef.current;
|
|
if (content && content.scrollTop > 0 && height >= SNAP_POINTS.FULL - 0.05) return;
|
|
|
|
const y = e.touches[0].clientY;
|
|
dragState.current = { startY: y, startH: height, lastY: y, lastTime: Date.now() };
|
|
setDragging(true);
|
|
}, [height]);
|
|
|
|
const onTouchMove = useCallback((e: React.TouchEvent) => {
|
|
if (!dragging) return;
|
|
const y = e.touches[0].clientY;
|
|
const vh = window.innerHeight;
|
|
const deltaRatio = (dragState.current.startY - y) / vh;
|
|
const newH = Math.max(0.1, Math.min(SNAP_POINTS.FULL, dragState.current.startH + deltaRatio));
|
|
setHeight(newH);
|
|
dragState.current.lastY = y;
|
|
dragState.current.lastTime = Date.now();
|
|
}, [dragging]);
|
|
|
|
const onTouchEnd = useCallback(() => {
|
|
if (!dragging) return;
|
|
setDragging(false);
|
|
const dt = (Date.now() - dragState.current.lastTime) / 1000 || 0.1;
|
|
const dy = (dragState.current.startY - dragState.current.lastY) / window.innerHeight;
|
|
const velocity = -dy / dt; // positive = downward
|
|
snapTo(height, velocity);
|
|
}, [dragging, height, snapTo]);
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<>
|
|
{/* Backdrop */}
|
|
<div
|
|
className="fixed inset-0 z-40 bg-black/20 md:hidden"
|
|
style={{ opacity: Math.min(1, (height - 0.2) * 2) }}
|
|
onClick={onClose}
|
|
/>
|
|
|
|
{/* Sheet */}
|
|
<div
|
|
ref={sheetRef}
|
|
className="fixed bottom-0 left-0 right-0 z-50 md:hidden flex flex-col bg-white/85 dark:bg-gray-900/90 backdrop-blur-xl rounded-t-2xl shadow-2xl"
|
|
style={{
|
|
height: `${height * 100}vh`,
|
|
transition: dragging ? "none" : "height 0.3s cubic-bezier(0.2, 0, 0, 1)",
|
|
}}
|
|
onTouchStart={onTouchStart}
|
|
onTouchMove={onTouchMove}
|
|
onTouchEnd={onTouchEnd}
|
|
>
|
|
{/* Handle bar */}
|
|
<div className="flex justify-center pt-2 pb-1 shrink-0 cursor-grab">
|
|
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full" />
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div
|
|
ref={contentRef}
|
|
className="flex-1 overflow-y-auto overscroll-contain"
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|