diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 1a2f7764fc..b5809f464b 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -33,6 +33,7 @@ import { normalizeRememberedInstanceSettingsPath, } from "../lib/instance-settings"; import { queryKeys } from "../lib/queryKeys"; +import { scheduleMainContentFocus } from "../lib/main-content-focus"; import { cn } from "../lib/utils"; import { NotFoundPage } from "../pages/NotFound"; import { Button } from "@/components/ui/button"; @@ -268,6 +269,12 @@ export function Layout() { } }, [location.hash, location.pathname, location.search]); + useEffect(() => { + if (typeof document === "undefined") return; + const mainContent = document.getElementById("main-content"); + return scheduleMainContentFocus(mainContent); + }, [location.pathname]); + return (
{ + let originalRequestAnimationFrame: typeof window.requestAnimationFrame; + let originalCancelAnimationFrame: typeof window.cancelAnimationFrame; + + beforeEach(() => { + document.body.innerHTML = ""; + originalRequestAnimationFrame = window.requestAnimationFrame; + originalCancelAnimationFrame = window.cancelAnimationFrame; + window.requestAnimationFrame = ((callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0)) as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = ((handle: number) => window.clearTimeout(handle)) as typeof window.cancelAnimationFrame; + }); + + afterEach(() => { + window.requestAnimationFrame = originalRequestAnimationFrame; + window.cancelAnimationFrame = originalCancelAnimationFrame; + document.body.innerHTML = ""; + vi.restoreAllMocks(); + }); + + it("prefers the main content when navigation leaves focus outside it", async () => { + const sidebarButton = document.createElement("button"); + const main = document.createElement("main"); + main.tabIndex = -1; + document.body.append(sidebarButton, main); + sidebarButton.focus(); + + scheduleMainContentFocus(main); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + expect(document.activeElement).toBe(main); + }); + + it("does not steal focus from an active element already inside main content", async () => { + const main = document.createElement("main"); + const input = document.createElement("input"); + main.tabIndex = -1; + main.appendChild(input); + document.body.append(main); + input.focus(); + + scheduleMainContentFocus(main); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + expect(document.activeElement).toBe(input); + }); + + it("treats disconnected elements as needing main-content focus", () => { + const main = document.createElement("main"); + main.tabIndex = -1; + document.body.append(main); + + const staleButton = document.createElement("button"); + staleButton.focus(); + + expect(shouldFocusMainContentAfterNavigation(main, staleButton)).toBe(true); + }); +}); diff --git a/ui/src/lib/main-content-focus.ts b/ui/src/lib/main-content-focus.ts new file mode 100644 index 0000000000..d96fafc8a0 --- /dev/null +++ b/ui/src/lib/main-content-focus.ts @@ -0,0 +1,21 @@ +export function shouldFocusMainContentAfterNavigation( + mainElement: HTMLElement | null, + activeElement: Element | null, +): boolean { + if (!(mainElement instanceof HTMLElement)) return false; + if (!(activeElement instanceof HTMLElement)) return true; + if (!document.contains(activeElement)) return true; + if (activeElement === document.body || activeElement === document.documentElement) return true; + return !mainElement.contains(activeElement); +} + +export function scheduleMainContentFocus(mainElement: HTMLElement | null): () => void { + if (!(mainElement instanceof HTMLElement)) return () => {}; + + const frame = window.requestAnimationFrame(() => { + if (!shouldFocusMainContentAfterNavigation(mainElement, document.activeElement)) return; + mainElement.focus({ preventScroll: true }); + }); + + return () => window.cancelAnimationFrame(frame); +}