mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Fix issue detail main-pane focus on navigation
This commit is contained in:
parent
e21e442033
commit
59d913d04b
3 changed files with 94 additions and 0 deletions
|
|
@ -33,6 +33,7 @@ import {
|
||||||
normalizeRememberedInstanceSettingsPath,
|
normalizeRememberedInstanceSettingsPath,
|
||||||
} from "../lib/instance-settings";
|
} from "../lib/instance-settings";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { scheduleMainContentFocus } from "../lib/main-content-focus";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { NotFoundPage } from "../pages/NotFound";
|
import { NotFoundPage } from "../pages/NotFound";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -268,6 +269,12 @@ export function Layout() {
|
||||||
}
|
}
|
||||||
}, [location.hash, location.pathname, location.search]);
|
}, [location.hash, location.pathname, location.search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const mainContent = document.getElementById("main-content");
|
||||||
|
return scheduleMainContentFocus(mainContent);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
|
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
66
ui/src/lib/main-content-focus.test.ts
Normal file
66
ui/src/lib/main-content-focus.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
21
ui/src/lib/main-content-focus.ts
Normal file
21
ui/src/lib/main-content-focus.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue