Files
tasteby/frontend/src/lib/hooks/useModalA11y.ts
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

75 lines
2.1 KiB
TypeScript

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<T extends HTMLElement>(active: boolean, containerRef: React.RefObject<T | null>) {
useEffect(() => {
if (!active) return;
const node = containerRef.current;
if (!node) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const focusables = () => Array.from(node.querySelectorAll<HTMLElement>(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]);
}