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:
74
frontend/src/lib/hooks/useModalA11y.ts
Normal file
74
frontend/src/lib/hooks/useModalA11y.ts
Normal file
@@ -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<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]);
|
||||
}
|
||||
Reference in New Issue
Block a user