Fix issue detail main-pane focus on navigation

This commit is contained in:
dotta 2026-04-08 09:03:24 -05:00
parent e21e442033
commit 59d913d04b
3 changed files with 94 additions and 0 deletions

View file

@ -0,0 +1,66 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
scheduleMainContentFocus,
shouldFocusMainContentAfterNavigation,
} from "./main-content-focus";
describe("main-content-focus", () => {
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);
});
});

View file

@ -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);
}