Publish curated Knowledge Hub source from 33cc04fb0ba8ce2b64e4ba3c4b91e6743695cf63

This commit is contained in:
Public Source Publisher committed 2026-10-04 18:31:43 +00:00
1 parent 9dad3425ce
commit d48f28c9f6
6 files changed
+364 -23

No files matched your search

+1 -1
View File
@@ -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.
+17 -5
View File
@@ -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"
},
{
@@ -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<typeof vi.fn>;
beforeEach(() => {
window.history.replaceState(null, "", "/infrastructure");
document.body.innerHTML = `
<a class="skip-link" href="#main-content">Ga naar de inhoud</a>
<div id="root">
<main id="main-content" tabindex="-1">Laden</main>
</div>
<button type="button">Andere bediening</button>
`;
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<HTMLAnchorElement>("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<HTMLAnchorElement>("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<void>((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<void>((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<HTMLAnchorElement>("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<void>((resolve) => window.setTimeout(resolve, 0));
const loadedMain = document.createElement("main");
loadedMain.id = "main-content";
loadedMain.tabIndex = -1;
routeSkeleton.replaceWith(loadedMain);
await new Promise<void>((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<void>((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<HTMLAnchorElement>("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<HTMLAnchorElement>("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<void>((resolve) => window.setTimeout(resolve, 0));
expect(loadedMain).not.toHaveFocus();
});
it("disconnects the pending observer when the handler is disposed", async () => {
const link = document.querySelector<HTMLAnchorElement>("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<void>((resolve) => window.setTimeout(resolve, 0));
expect(loadedMain).not.toHaveFocus();
});
});
@@ -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 <main> after the session and lazy route
* resolve. If that was the focused target, browsers move focus back to <body>.
* 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<HTMLAnchorElement>("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);
};
}
+5 -17
View File
@@ -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<HTMLAnchorElement>("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);
// Keep the document-level skip link available on pre-authentication screens too.
const removeSkipLinkFocusHandler = installSkipLinkFocusHandler();
if (import.meta.hot) {
import.meta.hot.dispose(removeSkipLinkFocusHandler);
}
target.focus();
target.scrollIntoView({ block: "start" });
});
const root = document.getElementById("root");
if (!root) {
@@ -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<void>((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");