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:
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user