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
75 lines
2.1 KiB
TypeScript
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]);
|
|
}
|