From d48f28c9f6ea06f07350ab3953235625a387de06 Mon Sep 17 00:00:00 2001 From: Public Source Publisher Date: Sun, 4 Oct 2026 18:31:43 +0000 Subject: [PATCH] Publish curated Knowledge Hub source from 33cc04fb0ba8ce2b64e4ba3c4b91e6743695cf63 --- PUBLIC_SOURCE_EXPORT.md | 2 +- PUBLIC_SOURCE_MANIFEST.json | 22 ++- .../src/accessibility/skip-link-focus.test.ts | 182 ++++++++++++++++++ .../src/accessibility/skip-link-focus.ts | 127 ++++++++++++ src/frontend/src/main.tsx | 24 +-- .../e2e/specs/platform-productization.spec.ts | 32 +++ 6 files changed, 365 insertions(+), 24 deletions(-) create mode 100644 src/frontend/src/accessibility/skip-link-focus.test.ts create mode 100644 src/frontend/src/accessibility/skip-link-focus.ts diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index ac94801..55526ef 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,3 +1,3 @@ # Public source export -This source snapshot was generated from the private canonical repository at revision `0b5076b0af37a5732dc5eb871a693abd0ece1dca`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. +This source snapshot was generated from the private canonical repository at revision `33cc04fb0ba8ce2b64e4ba3c4b91e6743695cf63`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index ca5d8a7..bc422e0 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "0b5076b0af37a5732dc5eb871a693abd0ece1dca", + "sourceRevision": "33cc04fb0ba8ce2b64e4ba3c4b91e6743695cf63", "files": [ { "path": ".config/dotnet-tools.json", @@ -3512,6 +3512,18 @@ "bytes": 2918, "mode": "100644" }, + { + "path": "src/frontend/src/accessibility/skip-link-focus.test.ts", + "sha256": "43e7dcf05ea313765ac344af37c2f3e8d422ecda7f726c1cdfd8854f4de154c4", + "bytes": 7133, + "mode": "100644" + }, + { + "path": "src/frontend/src/accessibility/skip-link-focus.ts", + "sha256": "7937e76e10b0690b662c9b297080de6cf827016043fe5171ab2348e0ac93d986", + "bytes": 5190, + "mode": "100644" + }, { "path": "src/frontend/src/api/activityTypes.ts", "sha256": "807fc7880567fe41cee14eddeb7f1fa839527b13f8dbed7bfb0f7de09a93ca00", @@ -4078,8 +4090,8 @@ }, { "path": "src/frontend/src/main.tsx", - "sha256": "4f20f3838b5baf770522d9d13919a47a216f8b9751a662704fe3387b2a825b23", - "bytes": 1667, + "sha256": "47753dd1eda39f32241ffab3079d17d3d07c9a9ae6288e5533e0bd631e03c951", + "bytes": 1233, "mode": "100644" }, { @@ -5560,8 +5572,8 @@ }, { "path": "tests/e2e/specs/platform-productization.spec.ts", - "sha256": "ee4e1871dd9e57231b32d1506d9fd8fa637e08c935cb2a3b1a7644d9fed9a7ed", - "bytes": 6616, + "sha256": "0a72ec4ca11ea56c03e43355fdbce9997899fcff8e79dae979188035ddd674ca", + "bytes": 8053, "mode": "100644" }, { diff --git a/src/frontend/src/accessibility/skip-link-focus.test.ts b/src/frontend/src/accessibility/skip-link-focus.test.ts new file mode 100644 index 0000000..10fd4ea --- /dev/null +++ b/src/frontend/src/accessibility/skip-link-focus.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { installSkipLinkFocusHandler } from "./skip-link-focus"; + +describe("skip-link focus handling", () => { + let cleanup: (() => void) | undefined; + let originalScrollIntoView: PropertyDescriptor | undefined; + let scrollIntoViewMock: ReturnType; + + beforeEach(() => { + window.history.replaceState(null, "", "/infrastructure"); + document.body.innerHTML = ` + +
+
Laden
+
+ + `; + originalScrollIntoView = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollIntoView"); + scrollIntoViewMock = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: scrollIntoViewMock + }); + cleanup = installSkipLinkFocusHandler(document); + }); + + afterEach(() => { + vi.useRealTimers(); + cleanup?.(); + cleanup = undefined; + if (originalScrollIntoView) { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", originalScrollIntoView); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); + } + vi.restoreAllMocks(); + document.body.replaceChildren(); + window.history.replaceState(null, "", "/"); + }); + + it("keeps fragment, focus and scrolling behavior for a skip-link activation", () => { + const link = document.querySelector("a.skip-link")!; + const main = document.getElementById("main-content")!; + const click = new MouseEvent("click", { bubbles: true, cancelable: true }); + + link.dispatchEvent(click); + + expect(click.defaultPrevented).toBe(true); + expect(window.location.hash).toBe("#main-content"); + expect(main).toHaveFocus(); + expect(scrollIntoViewMock).toHaveBeenCalledWith({ block: "start" }); + }); + + it("follows focus from the session loading main through the route skeleton to loaded content", async () => { + const link = document.querySelector("a.skip-link")!; + const loadingMain = document.getElementById("main-content")!; + const loadingWrapper = document.createElement("div"); + loadingMain.replaceWith(loadingWrapper); + loadingWrapper.append(loadingMain); + link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + expect(loadingMain).toHaveFocus(); + + const routeSkeleton = document.createElement("main"); + routeSkeleton.id = "main-content"; + routeSkeleton.tabIndex = -1; + routeSkeleton.setAttribute("aria-busy", "true"); + routeSkeleton.textContent = "Werkruimte wordt geladen"; + const loadedRouteWrapper = document.createElement("div"); + loadedRouteWrapper.className = "route-workspace"; + loadedRouteWrapper.append(routeSkeleton); + loadingWrapper.replaceWith(loadedRouteWrapper); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + expect(routeSkeleton).toHaveFocus(); + + const loadedMain = document.createElement("main"); + loadedMain.id = "main-content"; + loadedMain.tabIndex = -1; + loadedMain.textContent = "Operationeel overzicht"; + routeSkeleton.replaceWith(loadedMain); + + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + expect(loadedMain).toHaveFocus(); + }); + + it.each([ + [ + "tabs away", + () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })) + ], + [ + "presses elsewhere", + () => document.querySelector("button")!.dispatchEvent(new Event("pointerdown", { bubbles: true })) + ], + ["focuses another control", () => document.querySelector("button")!.focus()] + ])( + "does not restore focus after the user %s before later main replacements", + async (_interaction, interact) => { + const link = document.querySelector("a.skip-link")!; + const loadingMain = document.getElementById("main-content")!; + link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + interact(); + + const routeSkeleton = document.createElement("main"); + routeSkeleton.id = "main-content"; + routeSkeleton.tabIndex = -1; + loadingMain.replaceWith(routeSkeleton); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + const loadedMain = document.createElement("main"); + loadedMain.id = "main-content"; + loadedMain.tabIndex = -1; + routeSkeleton.replaceWith(loadedMain); + + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + expect(loadedMain).not.toHaveFocus(); + } + ); + + it("does not focus main content before a skip link is activated", async () => { + const readyMain = document.getElementById("main-content")!; + readyMain.remove(); + + const replacement = document.createElement("main"); + replacement.id = "main-content"; + replacement.tabIndex = -1; + document.getElementById("root")!.append(replacement); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + expect(replacement).not.toHaveFocus(); + }); + + it("stops the replacement watcher when its bounded focus intent expires", async () => { + vi.useFakeTimers(); + const link = document.querySelector("a.skip-link")!; + const loadingMain = document.getElementById("main-content")!; + link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + + await vi.advanceTimersByTimeAsync(31_000); + + const loadedMain = document.createElement("main"); + loadedMain.id = "main-content"; + loadedMain.tabIndex = -1; + loadingMain.replaceWith(loadedMain); + await Promise.resolve(); + + expect(loadedMain).not.toHaveFocus(); + }); + + it("does not restore focus after the route or fragment changes", async () => { + const link = document.querySelector("a.skip-link")!; + const loadingMain = document.getElementById("main-content")!; + link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + window.history.pushState(null, "", "/projects#other"); + + const loadedMain = document.createElement("main"); + loadedMain.id = "main-content"; + loadedMain.tabIndex = -1; + loadingMain.replaceWith(loadedMain); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + expect(loadedMain).not.toHaveFocus(); + }); + + it("disconnects the pending observer when the handler is disposed", async () => { + const link = document.querySelector("a.skip-link")!; + const loadingMain = document.getElementById("main-content")!; + link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + + cleanup?.(); + cleanup = undefined; + + const loadedMain = document.createElement("main"); + loadedMain.id = "main-content"; + loadedMain.tabIndex = -1; + loadingMain.replaceWith(loadedMain); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + expect(loadedMain).not.toHaveFocus(); + }); +}); diff --git a/src/frontend/src/accessibility/skip-link-focus.ts b/src/frontend/src/accessibility/skip-link-focus.ts new file mode 100644 index 0000000..f876f18 --- /dev/null +++ b/src/frontend/src/accessibility/skip-link-focus.ts @@ -0,0 +1,127 @@ +/** + * Give fragment-based skip links an explicit focus transfer, since some mobile + * browsers scroll to fragment targets without moving keyboard focus. + * + * The application replaces its loading
after the session and lazy route + * resolve. If that was the focused target, browsers move focus back to . + * A short-lived observer follows only this activated skip-link intent through + * those replacements, and stops as soon as the user acts elsewhere. + */ +const focusRecoveryLifetimeMs = 30_000; + +interface PendingFocusRecovery { + currentTarget: HTMLElement; + hash: string; + pathnameAndSearch: string; + observer: MutationObserver; + timeoutId: number; + focusingTarget: HTMLElement | undefined; +} + +export function installSkipLinkFocusHandler(documentRef: Document = document): () => void { + const view = documentRef.defaultView; + if (!view) return () => undefined; + + let pendingRecovery: PendingFocusRecovery | null = null; + + const cancelPendingRecovery = () => { + const recovery = pendingRecovery; + if (!recovery) return; + pendingRecovery = null; + recovery.observer.disconnect(); + view.clearTimeout(recovery.timeoutId); + }; + + const cancelOnUserInput = () => cancelPendingRecovery(); + + const onFocusIn = (event: FocusEvent) => { + if (pendingRecovery?.focusingTarget === event.target) return; + cancelPendingRecovery(); + }; + + const onClick = (event: MouseEvent) => { + if (!(event.target instanceof view.Element)) { + cancelPendingRecovery(); + return; + } + const link = event.target.closest("a.skip-link[href^='#']"); + cancelPendingRecovery(); + if (!link) return; + + const target = documentRef.getElementById(link.hash.slice(1)); + if (!(target instanceof view.HTMLElement)) return; + + event.preventDefault(); + if (view.location.hash !== link.hash) { + view.history.pushState(view.history.state, "", link.hash); + } + target.focus(); + target.scrollIntoView({ block: "start" }); + + // Only a successful explicit focus transfer creates a recovery intent. + // React keeps #root stable across auth and route transitions, even if it + // replaces a transient wrapper around the current main element. + if (documentRef.activeElement !== target || !target.parentNode) return; + const reactRoot = documentRef.getElementById("root"); + const observationRoot = reactRoot?.contains(target) ? reactRoot : target.parentNode; + const observer = new view.MutationObserver(() => { + const recovery = pendingRecovery; + if (!recovery) return; + const currentLocation = `${view.location.pathname}${view.location.search}`; + if (view.location.hash !== recovery.hash || currentLocation !== recovery.pathnameAndSearch) { + cancelPendingRecovery(); + return; + } + if (recovery.currentTarget.isConnected) return; + if (documentRef.activeElement !== documentRef.body) { + cancelPendingRecovery(); + return; + } + + const replacement = documentRef.getElementById(link.hash.slice(1)); + if (!(replacement instanceof view.HTMLElement) || replacement === recovery.currentTarget) return; + + // Keep following the same intent: the session loading main can be replaced + // by a lazy-route skeleton, which can then be replaced by the loaded route. + recovery.currentTarget = replacement; + recovery.focusingTarget = replacement; + replacement.focus(); + recovery.focusingTarget = undefined; + replacement.scrollIntoView({ block: "start" }); + }); + const recovery: PendingFocusRecovery = { + currentTarget: target, + hash: link.hash, + pathnameAndSearch: `${view.location.pathname}${view.location.search}`, + observer, + timeoutId: 0, + focusingTarget: undefined + }; + pendingRecovery = recovery; + recovery.timeoutId = view.setTimeout(cancelPendingRecovery, focusRecoveryLifetimeMs); + observer.observe(observationRoot, { childList: true, subtree: true }); + }; + + documentRef.addEventListener("click", onClick); + documentRef.addEventListener("keydown", cancelOnUserInput); + documentRef.addEventListener("pointerdown", cancelOnUserInput); + documentRef.addEventListener("mousedown", cancelOnUserInput); + documentRef.addEventListener("touchstart", cancelOnUserInput, { passive: true }); + documentRef.addEventListener("wheel", cancelOnUserInput, { passive: true }); + documentRef.addEventListener("focusin", onFocusIn); + view.addEventListener("popstate", cancelOnUserInput); + view.addEventListener("hashchange", cancelOnUserInput); + + return () => { + cancelPendingRecovery(); + documentRef.removeEventListener("click", onClick); + documentRef.removeEventListener("keydown", cancelOnUserInput); + documentRef.removeEventListener("pointerdown", cancelOnUserInput); + documentRef.removeEventListener("mousedown", cancelOnUserInput); + documentRef.removeEventListener("touchstart", cancelOnUserInput); + documentRef.removeEventListener("wheel", cancelOnUserInput); + documentRef.removeEventListener("focusin", onFocusIn); + view.removeEventListener("popstate", cancelOnUserInput); + view.removeEventListener("hashchange", cancelOnUserInput); + }; +} diff --git a/src/frontend/src/main.tsx b/src/frontend/src/main.tsx index 86e4fd4..3d68f1e 100644 --- a/src/frontend/src/main.tsx +++ b/src/frontend/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; +import { installSkipLinkFocusHandler } from "./accessibility/skip-link-focus"; // Order is the cascade. Tokens and base resets first, then the component vocabulary, // then progressively more specific surfaces, and finally the responsive overrides, which // must stay last so their breakpoints can win against everything above. @@ -15,24 +16,11 @@ import "./design-system/forms.css"; import "./design-system/auth.css"; import "./design-system/responsive.css"; -// Fragment links do not consistently transfer keyboard focus on mobile browsers, -// even when their target has tabindex=-1. Make the document-level skip link's -// purpose explicit while retaining the URL fragment and normal scrolling behavior. -document.addEventListener("click", (event) => { - if (!(event.target instanceof Element)) return; - const link = event.target.closest("a.skip-link[href^='#']"); - if (!link) return; - - const target = document.getElementById(link.hash.slice(1)); - if (!target) return; - - event.preventDefault(); - if (window.location.hash !== link.hash) { - window.history.pushState(window.history.state, "", link.hash); - } - target.focus(); - target.scrollIntoView({ block: "start" }); -}); +// Keep the document-level skip link available on pre-authentication screens too. +const removeSkipLinkFocusHandler = installSkipLinkFocusHandler(); +if (import.meta.hot) { + import.meta.hot.dispose(removeSkipLinkFocusHandler); +} const root = document.getElementById("root"); if (!root) { diff --git a/tests/e2e/specs/platform-productization.spec.ts b/tests/e2e/specs/platform-productization.spec.ts index 66d928e..d421aca 100644 --- a/tests/e2e/specs/platform-productization.spec.ts +++ b/tests/e2e/specs/platform-productization.spec.ts @@ -52,6 +52,38 @@ test.describe("platform workflows at supported widths", () => { expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); }); + test("skip-link focus survives delayed session and lazy-route main replacements", async ({ page }) => { + let releaseSession!: () => void; + const sessionGate = new Promise((resolve) => { + releaseSession = resolve; + }); + await page.route("**/api/v1/auth/session", async (route) => { + await sessionGate; + await route.continue(); + }); + + try { + await page.goto("/infrastructure"); + const loadingMain = page.locator("#main-content"); + await expect(loadingMain).toContainText("Sessiestatus wordt gecontroleerd…"); + const initialLoadingMain = await loadingMain.elementHandle(); + if (!initialLoadingMain) throw new Error("The session-loading main was not rendered."); + await page.keyboard.press("Tab"); + await expect(page.getByRole("link", { name: "Ga naar de inhoud" })).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(loadingMain).toBeFocused(); + + // The held session response lets keyboard activation focus the loading main + // first. Resolving it then replaces that main through lazy-route rendering. + releaseSession(); + await expect.poll(() => initialLoadingMain.evaluate((element) => element.isConnected)).toBe(false); + await expect(page.locator("#main-content")).toContainText("Domeinen"); + await expect(page.locator("#main-content")).toBeFocused(); + } finally { + releaseSession(); + } + }); + test("narrow mobile controls stay touchable with a short virtual-keyboard viewport", async ({ page }) => { await page.setViewportSize({ width: 320, height: 430 }); await page.goto("/admin/jobs");