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
This commit is contained in:
joungmin
2026-06-15 12:23:15 +09:00
parent 2d41f22b83
commit 43fd931824
5 changed files with 157 additions and 43 deletions

View File

@@ -1,23 +1,28 @@
"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 }: BottomSheetProps) {
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);
@@ -89,6 +94,10 @@ export default function BottomSheet({ open, onClose, children }: BottomSheetProp
{/* 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`,

View File

@@ -1,7 +1,8 @@
"use client";
import { useEffect, useRef } from "react";
import { useRef } from "react";
import Icon from "@/components/Icon";
import { useEscapeKey, useFocusTrap, useBodyScrollLock } from "@/lib/hooks/useModalA11y";
export interface FilterOption {
label: string;
@@ -20,12 +21,11 @@ interface FilterSheetProps {
export default function FilterSheet({ open, onClose, title, options, value, onChange }: FilterSheetProps) {
const sheetRef = useRef<HTMLDivElement>(null);
const titleId = "filter-sheet-title";
useEffect(() => {
if (!open) return;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, [open]);
useBodyScrollLock(open);
useEscapeKey(open, onClose);
useFocusTrap(open, sheetRef);
// Group options by group field
const grouped = options.reduce<Record<string, FilterOption[]>>((acc, opt) => {
@@ -54,6 +54,10 @@ export default function FilterSheet({ open, onClose, title, options, value, onCh
{/* Sheet */}
<div
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
className="fixed bottom-0 left-0 right-0 z-[61] md:hidden bg-surface rounded-t-2xl shadow-2xl max-h-[70vh] flex flex-col animate-slide-up"
>
{/* Handle */}
@@ -63,8 +67,8 @@ export default function FilterSheet({ open, onClose, title, options, value, onCh
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-800 shrink-0">
<h3 className="font-bold text-base text-gray-900 dark:text-gray-100">{title}</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600">
<h3 id={titleId} className="font-bold text-base text-gray-900 dark:text-gray-100">{title}</h3>
<button onClick={onClose} aria-label="필터 닫기" className="p-2 -mr-1 text-gray-400 hover:text-gray-600">
<Icon name="close" size={20} />
</button>
</div>

View File

@@ -23,19 +23,20 @@ export default function RestaurantDetail({
const [favLoading, setFavLoading] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
api
.getRestaurantVideos(restaurant.id)
.then(setVideos)
.catch(() => setVideos([]))
.finally(() => setLoading(false));
.then((v) => { if (!cancelled) setVideos(v); })
.catch(() => { if (!cancelled) setVideos([]); })
.finally(() => { if (!cancelled) setLoading(false); });
// Load favorite status if logged in
if (getToken()) {
api.getFavoriteStatus(restaurant.id)
.then((r) => setFavorited(r.favorited))
.then((r) => { if (!cancelled) setFavorited(r.favorited); })
.catch(() => {});
}
return () => { cancelled = true; };
}, [restaurant.id]);
const handleToggleFavorite = async () => {
@@ -57,12 +58,12 @@ export default function RestaurantDetail({
<button
onClick={handleToggleFavorite}
disabled={favLoading}
className={`text-xl leading-none transition-colors ${
className={`p-1.5 -m-1.5 transition-colors touch-manipulation ${
favorited ? "text-rose-500" : "text-gray-300 dark:text-gray-600 hover:text-rose-400"
}`}
title={favorited ? "찜 해제" : "찜하기"}
>
<Icon name="favorite" size={20} filled={favorited} />
<Icon name="favorite" size={22} filled={favorited} />
</button>
)}
{restaurant.business_status === "CLOSED_PERMANENTLY" && (
@@ -218,7 +219,7 @@ export default function RestaurantDetail({
<Icon name="play_circle" size={16} filled className="flex-shrink-0" />
{v.title}
</a>
{v.foods_mentioned.length > 0 && (
{v.foods_mentioned?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{v.foods_mentioned.map((f, i) => (
<span
@@ -235,7 +236,7 @@ export default function RestaurantDetail({
{v.evaluation.text}
</p>
)}
{v.guests.length > 0 && (
{v.guests?.length > 0 && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
: {v.guests.join(", ")}
</p>