Files
tasteby/frontend/src/components/BottomSheet.tsx
joungmin 43fd931824 fix(a11y): #301+#302 모달 접근성 + race condition + 필터 상태 동기화
CRITICAL — 모달 접근성:
- frontend/src/lib/hooks/useModalA11y.ts 신규 (useEscapeKey, useFocusTrap, useBodyScrollLock)
- BottomSheet: role='dialog' / aria-modal / aria-label / ESC 닫기 / focus trap
- FilterSheet: role='dialog' / aria-modal / aria-labelledby / ESC 닫기 / focus trap, 닫기 버튼 aria-label

MAJOR — race condition (#301):
- RestaurantDetail useEffect에 cancelled 플래그 추가 → restaurant.id 변경 시 이전 fetch 결과 폐기

MAJOR — 필터 상태 동기화 (#302):
- page.tsx에 exitSearchMode 헬퍼 추가
- 검색 모드(isSearchResult=true)에서 cuisine/price/country/city/district 변경 시 자동으로 검색 모드 해제 + 원본 restaurants 재로드

후속 분리: #319(BottomSheet 매직넘버/UX), #320(필터 정밀도/접근성/테스트)

Refs: #301 #302
2026-06-15 12:23:15 +09:00

126 lines
3.9 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEscapeKey, useFocusTrap } from "@/lib/hooks/useModalA11y";
interface BottomSheetProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
ariaLabel?: string;
}
const SNAP_POINTS = { PEEK: 0.4, HALF: 0.55, FULL: 0.92 };
const VELOCITY_THRESHOLD = 0.5;
export default function BottomSheet({ open, onClose, children, ariaLabel = "상세 정보" }: 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 });
useEscapeKey(open, onClose);
useFocusTrap(open, sheetRef);
// 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}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
tabIndex={-1}
className="fixed bottom-0 left-0 right-0 z-50 md:hidden flex flex-col bg-surface/85 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>
</>
);
}