From 43fd931824905057f7f1e1b4a606107ab3cf5914 Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 15 Jun 2026 12:23:15 +0900 Subject: [PATCH] =?UTF-8?q?fix(a11y):=20#301+#302=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20+=20race=20condition=20+=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EC=83=81=ED=83=9C=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/app/page.tsx | 76 +++++++++++++------- frontend/src/components/BottomSheet.tsx | 11 ++- frontend/src/components/FilterSheet.tsx | 20 +++--- frontend/src/components/RestaurantDetail.tsx | 19 ++--- frontend/src/lib/hooks/useModalA11y.ts | 74 +++++++++++++++++++ 5 files changed, 157 insertions(+), 43 deletions(-) create mode 100644 frontend/src/lib/hooks/useModalA11y.ts diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 39439fb..ff46e54 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -13,9 +13,10 @@ import RestaurantDetail from "@/components/RestaurantDetail"; import MyReviewsList from "@/components/MyReviewsList"; import BottomSheet from "@/components/BottomSheet"; import FilterSheet, { FilterOption } from "@/components/FilterSheet"; -import { getCuisineIcon, getTablerCuisineIcon } from "@/lib/cuisine-icons"; +import { getCuisineIcon, getPhosphorCuisineIcon } from "@/lib/cuisine-icons"; import Icon from "@/components/Icon"; -import * as TablerIcons from "@tabler/icons-react"; +import FoodIcon from "@/components/FoodIcon"; +import * as PhosphorIcons from "@phosphor-icons/react"; function useDragScroll() { const ref = useRef(null); @@ -73,16 +74,24 @@ function matchCuisineFilter(cuisineType: string | null, filter: string): boolean const PRICE_GROUPS: { label: string; test: (p: string) => boolean }[] = [ { - label: "저렴 (~1만원)", - test: (p) => /저렴|가성비|착한|만원 이하|[3-9]천원|^\d[,.]?\d*천원/.test(p) || /^[1]만원대$/.test(p) || /^[5-9],?\d{3}원/.test(p), + label: "저렴 (~5천원)", + test: (p) => /저렴|착한|[3-5]천원대?$|^\d천원$/.test(p), + }, + { + label: "가성비 (5천~1만원)", + test: (p) => /가성비|만원 이하|[6-9]천원|^1만원대$|^[5-9],?\d{3}원/.test(p), }, { label: "보통 (1~3만원)", test: (p) => /[1-2]만원대|1-[23]만|인당 [12]\d?,?\d*원|1[2-9],?\d{3}원|2[0-9],?\d{3}원/.test(p), }, { - label: "고가 (3만원~)", - test: (p) => /[3-9]만원|고가|높은|묵직|살벌|10만원|5만원|4만원|6만원/.test(p), + label: "프리미엄 (3~5만원)", + test: (p) => /[3-4]만원대?|3-[45]만|인당 [34]\d?,?\d*원|3[0-9],?\d{3}원|4[0-9],?\d{3}원/.test(p), + }, + { + label: "럭셔리 (5만원~)", + test: (p) => /[5-9]만원|고가|10만원|[1-9]\d만원|인당 [5-9]\d?,?\d*원|5[0-9],?\d{3}원|[6-9][0-9],?\d{3}원/.test(p), }, ]; @@ -304,16 +313,18 @@ export default function Home() { api.recordVisit().then(() => api.getVisits().then(setVisits)).catch(console.error); }, []); - // Load all restaurants on mount + // Load restaurants on mount and when channel filter changes useEffect(() => { setLoading(true); setIsSearchResult(false); + const params: { limit: number; channel?: string } = { limit: 500 }; + if (channelFilter) params.channel = channelFilter; api - .getRestaurants({ limit: 500 }) + .getRestaurants(params) .then(setRestaurants) .catch(console.error) .finally(() => setLoading(false)); - }, []); + }, [channelFilter]); // Auto-select region from user's geolocation (once) useEffect(() => { @@ -393,7 +404,16 @@ export default function Home() { } }, []); + // 검색결과 모드에서 필터 변경 시 검색 결과가 무시되는 결함(#302) 해결. + // 검색 모드 플래그를 풀고 원본 restaurants를 다시 로드한다. + const exitSearchMode = useCallback(() => { + setIsSearchResult(false); + setResetCount((c) => c + 1); + api.getRestaurants({ limit: 500 }).then(setRestaurants).catch(() => {}); + }, []); + const handleCountryChange = useCallback((country: string) => { + if (isSearchResult) exitSearchMode(); setCountryFilter(country); setCityFilter(""); setDistrictFilter(""); @@ -404,9 +424,10 @@ export default function Home() { return p && p.country === country; }); setRegionFlyTo(computeFlyTo(matched)); - }, [restaurants]); + }, [restaurants, isSearchResult, exitSearchMode]); const handleCityChange = useCallback((city: string) => { + if (isSearchResult) exitSearchMode(); setCityFilter(city); setDistrictFilter(""); if (!city) { @@ -423,9 +444,10 @@ export default function Home() { return p && p.country === countryFilter && p.city === city; }); setRegionFlyTo(computeFlyTo(matched)); - }, [restaurants, countryFilter]); + }, [restaurants, countryFilter, isSearchResult, exitSearchMode]); const handleDistrictChange = useCallback((district: string) => { + if (isSearchResult) exitSearchMode(); setDistrictFilter(district); if (!district) { const matched = restaurants.filter((r) => { @@ -440,7 +462,7 @@ export default function Home() { return p && p.country === countryFilter && p.city === cityFilter && p.district === district; }); setRegionFlyTo(computeFlyTo(matched)); - }, [restaurants, countryFilter, cityFilter]); + }, [restaurants, countryFilter, cityFilter, isSearchResult, exitSearchMode]); const handleReset = useCallback(() => { setLoading(true); @@ -789,10 +811,10 @@ export default function Home() { {(cuisineFilter || priceFilter) && ( )} @@ -848,10 +870,10 @@ export default function Home() { {countryFilter && ( )} @@ -867,9 +889,9 @@ export default function Home() { setDistrictFilter(""); setRegionFlyTo(null); }} - className="flex items-center gap-1 rounded-lg px-2 py-1 bg-gray-50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400 hover:text-brand-500 transition-colors" + className="flex items-center gap-1 rounded-lg px-2.5 py-1.5 bg-gray-50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400 hover:text-brand-500 transition-colors touch-manipulation" > - + 전체보기 )} @@ -999,10 +1021,10 @@ export default function Home() {
{(() => { const allCards = [ - { label: "전체", value: "", icon: "Bowl" }, + { label: "전체", value: "", icon: "ForkKnife" }, ...CUISINE_TAXONOMY.flatMap((g) => [ - { label: g.category, value: g.category, icon: getTablerCuisineIcon(g.category) }, - ...g.items.map((item) => ({ label: item, value: `${g.category}|${item}`, icon: getTablerCuisineIcon(`${g.category}|${item}`) })), + { label: g.category, value: g.category, icon: getPhosphorCuisineIcon(g.category) }, + ...g.items.map((item) => ({ label: item, value: `${g.category}|${item}`, icon: getPhosphorCuisineIcon(`${g.category}|${item}`) })), ]), ]; return allCards.map((card) => { @@ -1012,7 +1034,7 @@ export default function Home() { : isCategory ? cuisineFilter === card.value || cuisineFilter.startsWith(card.value + "|") : cuisineFilter === card.value; - const TablerIcon = (TablerIcons as unknown as Record>)[`Icon${card.icon}`] || TablerIcons.IconBowl; + const PhIcon = (PhosphorIcons as unknown as Record>)[card.icon] || PhosphorIcons.ForkKnife; return ( ); @@ -1459,7 +1485,7 @@ export default function Home() { title="음식 장르" options={cuisineOptions} value={cuisineFilter} - onChange={(v) => { setCuisineFilter(v); if (v) setBoundsFilterOn(false); }} + onChange={(v) => { if (isSearchResult) exitSearchMode(); setCuisineFilter(v); if (v) setBoundsFilterOn(false); }} /> { setPriceFilter(v); if (v) setBoundsFilterOn(false); }} + onChange={(v) => { if (isSearchResult) exitSearchMode(); setPriceFilter(v); if (v) setBoundsFilterOn(false); }} /> 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(null); const contentRef = useRef(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 */}
(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>((acc, opt) => { @@ -54,6 +54,10 @@ export default function FilterSheet({ open, onClose, title, options, value, onCh {/* Sheet */}
{/* Handle */} @@ -63,8 +67,8 @@ export default function FilterSheet({ open, onClose, title, options, value, onCh {/* Header */}
-

{title}

-
diff --git a/frontend/src/components/RestaurantDetail.tsx b/frontend/src/components/RestaurantDetail.tsx index 720a5e4..8cfcf25 100644 --- a/frontend/src/components/RestaurantDetail.tsx +++ b/frontend/src/components/RestaurantDetail.tsx @@ -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({ )} {restaurant.business_status === "CLOSED_PERMANENTLY" && ( @@ -218,7 +219,7 @@ export default function RestaurantDetail({ {v.title} - {v.foods_mentioned.length > 0 && ( + {v.foods_mentioned?.length > 0 && (
{v.foods_mentioned.map((f, i) => ( )} - {v.guests.length > 0 && ( + {v.guests?.length > 0 && (

게스트: {v.guests.join(", ")}

diff --git a/frontend/src/lib/hooks/useModalA11y.ts b/frontend/src/lib/hooks/useModalA11y.ts new file mode 100644 index 0000000..bd95caa --- /dev/null +++ b/frontend/src/lib/hooks/useModalA11y.ts @@ -0,0 +1,74 @@ +import { useEffect } from "react"; + +export function useEscapeKey(active: boolean, onEscape: () => void) { + useEffect(() => { + if (!active) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + onEscape(); + } + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [active, onEscape]); +} + +const FOCUSABLE = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[tabindex]:not([tabindex="-1"])', +].join(","); + +export function useFocusTrap(active: boolean, containerRef: React.RefObject) { + useEffect(() => { + if (!active) return; + const node = containerRef.current; + if (!node) return; + const previouslyFocused = document.activeElement as HTMLElement | null; + const focusables = () => Array.from(node.querySelectorAll(FOCUSABLE)); + const first = focusables()[0]; + first?.focus(); + const handler = (e: KeyboardEvent) => { + if (e.key !== "Tab") return; + const list = focusables(); + if (list.length === 0) { + e.preventDefault(); + return; + } + const head = list[0]; + const tail = list[list.length - 1]; + const current = document.activeElement as HTMLElement | null; + if (e.shiftKey) { + if (current === head || !node.contains(current)) { + e.preventDefault(); + tail.focus(); + } + } else { + if (current === tail) { + e.preventDefault(); + head.focus(); + } + } + }; + document.addEventListener("keydown", handler); + return () => { + document.removeEventListener("keydown", handler); + previouslyFocused?.focus(); + }; + }, [active, containerRef]); +} + +export function useBodyScrollLock(active: boolean) { + useEffect(() => { + if (!active) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [active]); +}