feat(web): skip link + route focus management + html lang sync (#52)

This commit is contained in:
2026-06-08 09:46:17 +02:00
parent 57504c941d
commit 69d3d2be15
6 changed files with 71 additions and 4 deletions
+30
View File
@@ -21,6 +21,7 @@ function tree() {
<Routes>
<Route element={<AppShell />}>
<Route path="/objects" element={<div>objects outlet</div>} />
<Route path="/fields" element={<div>fields outlet</div>} />
</Route>
<Route path="/login" element={<div>login page</div>} />
</Routes>
@@ -36,6 +37,35 @@ test("shows active and disabled nav and renders the outlet", async () => {
expect(screen.getByRole("link", { name: /fields/i })).toBeInTheDocument();
});
test("renders a skip link targeting the focusable main region", async () => {
renderApp(tree(), { route: "/objects" });
await screen.findByText("objects outlet");
expect(screen.getByRole("link", { name: /skip to content/i })).toHaveAttribute(
"href",
"#main-content",
);
const main = document.getElementById("main-content");
expect(main).toBeTruthy();
expect(main?.tabIndex).toBe(-1);
});
test("moves focus to the main region on route change", async () => {
renderApp(tree(), { route: "/objects" });
await screen.findByText("objects outlet");
// Initial mount must NOT steal focus to main.
expect(document.activeElement).not.toBe(document.getElementById("main-content"));
await userEvent.click(screen.getByRole("link", { name: /fields/i }));
await screen.findByText("fields outlet");
await waitFor(() =>
expect(document.activeElement).toBe(document.getElementById("main-content")),
);
});
test("language switch toggles to Swedish", async () => {
renderApp(tree(), { route: "/objects" });
await userEvent.click(await screen.findByRole("button", { name: "SV" }));
+23 -2
View File
@@ -1,4 +1,6 @@
import { Outlet } from "react-router-dom";
import { useEffect, useRef } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { LangSwitch } from "./lang-switch";
import { ThemeSwitch } from "./theme-switch";
@@ -9,8 +11,27 @@ import { HeaderSearch } from "./header-search";
import { UserMenu } from "./user-menu";
export function AppShell() {
const { t } = useTranslation();
const location = useLocation();
const mainRef = useRef<HTMLElement>(null);
const didMount = useRef(false);
useEffect(() => {
if (!didMount.current) {
didMount.current = true;
return;
}
mainRef.current?.focus();
}, [location.pathname]);
return (
<div className="flex min-h-screen">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-2 focus:top-2 focus:z-50 focus:rounded-md focus:border focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:ring-3 focus:ring-ring/50"
>
{t("common.skipToContent")}
</a>
<Sidebar />
<BreadcrumbProvider>
<div className="flex flex-1 flex-col">
@@ -21,7 +42,7 @@ export function AppShell() {
<LangSwitch />
<UserMenu />
</header>
<main className="flex-1 overflow-hidden">
<main ref={mainRef} id="main-content" tabIndex={-1} className="flex-1 overflow-hidden outline-none">
<Outlet />
</main>
</div>