diff --git a/apps/web/components/Drawer.tsx b/apps/web/components/Drawer.tsx index 6969aba..71d9473 100644 --- a/apps/web/components/Drawer.tsx +++ b/apps/web/components/Drawer.tsx @@ -5,6 +5,7 @@ import { X } from "lucide-react"; import { Button } from "@/components/ui/Button"; import { handleTabTrap } from "@/lib/a11y/focusTrap"; +import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus"; import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock"; import { overlayScrim } from "@/lib/ui/variants"; @@ -31,19 +32,12 @@ export function Drawer({ }: DrawerProps) { const panelRef = useRef(null); const closeRef = useRef(null); - const wasOpenRef = useRef(false); useBodyScrollLock(open); + useRestoreFocus(open, triggerRef); useEffect(() => { - if (!open) { - if (wasOpenRef.current) { - wasOpenRef.current = false; - triggerRef?.current?.focus(); - } - return; - } - wasOpenRef.current = true; + if (!open) return; const onKey = (e: KeyboardEvent): void => { if (e.key === "Escape") { e.preventDefault(); @@ -54,7 +48,7 @@ export function Drawer({ // 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。 closeRef.current?.focus(); return () => window.removeEventListener("keydown", onKey); - }, [open, onClose, triggerRef]); + }, [open, onClose]); if (!open) return null; diff --git a/apps/web/components/workbench/ContextDrawer.tsx b/apps/web/components/workbench/ContextDrawer.tsx index 218571f..d258c96 100644 --- a/apps/web/components/workbench/ContextDrawer.tsx +++ b/apps/web/components/workbench/ContextDrawer.tsx @@ -8,6 +8,7 @@ import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { Button } from "@/components/ui/Button"; import { StatusNote } from "@/components/ui/StatusNote"; import { handleTabTrap } from "@/lib/a11y/focusTrap"; +import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus"; import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock"; import { badgeClass, cn, overlayScrim } from "@/lib/ui/variants"; import { @@ -45,20 +46,13 @@ export function ContextDrawer({ const [activeTab, setActiveTab] = useState("codex"); const panelRef = useRef(null); const closeRef = useRef(null); - const wasOpenRef = useRef(false); const data = useContextDrawerData(projectId, activeTab, open); useBodyScrollLock(open); + useRestoreFocus(open, triggerRef); useEffect(() => { - if (!open) { - if (wasOpenRef.current) { - wasOpenRef.current = false; - triggerRef?.current?.focus(); - } - return; - } - wasOpenRef.current = true; + if (!open) return; const onKey = (e: KeyboardEvent): void => { if (e.key === "Escape") { e.preventDefault(); @@ -68,7 +62,7 @@ export function ContextDrawer({ window.addEventListener("keydown", onKey); closeRef.current?.focus(); return () => window.removeEventListener("keydown", onKey); - }, [open, onClose, triggerRef]); + }, [open, onClose]); if (!open) return null; diff --git a/apps/web/lib/a11y/useRestoreFocus.test.ts b/apps/web/lib/a11y/useRestoreFocus.test.ts new file mode 100644 index 0000000..00e6bcd --- /dev/null +++ b/apps/web/lib/a11y/useRestoreFocus.test.ts @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import type { RefObject } from "react"; + +import { useRestoreFocus } from "./useRestoreFocus"; + +const appended: HTMLElement[] = []; + +function mountEl(el: T): T { + document.body.appendChild(el); + appended.push(el); + return el; +} + +afterEach(() => { + for (const el of appended.splice(0)) el.remove(); +}); + +describe("useRestoreFocus(CR-H11)", () => { + it("关闭时把焦点还给 triggerRef", () => { + const trigger = mountEl(document.createElement("button")); + const other = mountEl(document.createElement("input")); + const ref: RefObject = { current: trigger }; + + const { rerender } = renderHook( + ({ open }) => useRestoreFocus(open, ref), + { initialProps: { open: true } }, + ); + + other.focus(); + expect(document.activeElement).toBe(other); + + rerender({ open: false }); + expect(document.activeElement).toBe(trigger); + }); + + it("从未打开过:关闭态不抢焦点(wasOpen 门闩)", () => { + const trigger = mountEl(document.createElement("button")); + const other = mountEl(document.createElement("input")); + const ref: RefObject = { current: trigger }; + + renderHook(({ open }) => useRestoreFocus(open, ref), { + initialProps: { open: false }, + }); + + other.focus(); + expect(document.activeElement).toBe(other); + }); + + it("triggerRef 为 null / undefined 时关闭不抛错", () => { + const nullRef: RefObject = { current: null }; + const nullCase = renderHook( + ({ open }) => useRestoreFocus(open, nullRef), + { initialProps: { open: true } }, + ); + expect(() => nullCase.rerender({ open: false })).not.toThrow(); + + const undefCase = renderHook( + ({ open }) => useRestoreFocus(open, undefined), + { initialProps: { open: true } }, + ); + expect(() => undefCase.rerender({ open: false })).not.toThrow(); + }); +}); diff --git a/apps/web/lib/a11y/useRestoreFocus.ts b/apps/web/lib/a11y/useRestoreFocus.ts new file mode 100644 index 0000000..223cad4 --- /dev/null +++ b/apps/web/lib/a11y/useRestoreFocus.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useEffect, useRef, type RefObject } from "react"; + +// 弹层(Drawer / ContextDrawer)关闭后把焦点还给触发元素(a11y 焦点管理,CR-H11)。 +// wasOpenRef 门闩:仅在「曾打开过」后的关闭才还原,避免从未打开时误抢焦点。 +// null-safe:triggerRef 或其 current 为空时静默跳过。 +export function useRestoreFocus( + open: boolean, + triggerRef?: RefObject, +): void { + const wasOpenRef = useRef(false); + + useEffect(() => { + if (!open) { + if (wasOpenRef.current) { + wasOpenRef.current = false; + triggerRef?.current?.focus(); + } + return; + } + wasOpenRef.current = true; + }, [open, triggerRef]); +}