Publish curated Knowledge Hub source from 33cc04fb0ba8ce2b64e4ba3c4b91e6743695cf63
This commit is contained in:
1 parent
9dad3425ce
commit
d48f28c9f6
6 files changed
+364
-23
No files matched your search
@@ -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.
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user