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
|
# 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,
|
"schemaVersion": 1,
|
||||||
"sourceRevision": "0b5076b0af37a5732dc5eb871a693abd0ece1dca",
|
"sourceRevision": "33cc04fb0ba8ce2b64e4ba3c4b91e6743695cf63",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": ".config/dotnet-tools.json",
|
"path": ".config/dotnet-tools.json",
|
||||||
@@ -3512,6 +3512,18 @@
|
|||||||
"bytes": 2918,
|
"bytes": 2918,
|
||||||
"mode": "100644"
|
"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",
|
"path": "src/frontend/src/api/activityTypes.ts",
|
||||||
"sha256": "807fc7880567fe41cee14eddeb7f1fa839527b13f8dbed7bfb0f7de09a93ca00",
|
"sha256": "807fc7880567fe41cee14eddeb7f1fa839527b13f8dbed7bfb0f7de09a93ca00",
|
||||||
@@ -4078,8 +4090,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/frontend/src/main.tsx",
|
"path": "src/frontend/src/main.tsx",
|
||||||
"sha256": "4f20f3838b5baf770522d9d13919a47a216f8b9751a662704fe3387b2a825b23",
|
"sha256": "47753dd1eda39f32241ffab3079d17d3d07c9a9ae6288e5533e0bd631e03c951",
|
||||||
"bytes": 1667,
|
"bytes": 1233,
|
||||||
"mode": "100644"
|
"mode": "100644"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5560,8 +5572,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/e2e/specs/platform-productization.spec.ts",
|
"path": "tests/e2e/specs/platform-productization.spec.ts",
|
||||||
"sha256": "ee4e1871dd9e57231b32d1506d9fd8fa637e08c935cb2a3b1a7644d9fed9a7ed",
|
"sha256": "0a72ec4ca11ea56c03e43355fdbce9997899fcff8e79dae979188035ddd674ca",
|
||||||
"bytes": 6616,
|
"bytes": 8053,
|
||||||
"mode": "100644"
|
"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 { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
|
import { installSkipLinkFocusHandler } from "./accessibility/skip-link-focus";
|
||||||
// Order is the cascade. Tokens and base resets first, then the component vocabulary,
|
// Order is the cascade. Tokens and base resets first, then the component vocabulary,
|
||||||
// then progressively more specific surfaces, and finally the responsive overrides, which
|
// then progressively more specific surfaces, and finally the responsive overrides, which
|
||||||
// must stay last so their breakpoints can win against everything above.
|
// 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/auth.css";
|
||||||
import "./design-system/responsive.css";
|
import "./design-system/responsive.css";
|
||||||
|
|
||||||
// Fragment links do not consistently transfer keyboard focus on mobile browsers,
|
// Keep the document-level skip link available on pre-authentication screens too.
|
||||||
// even when their target has tabindex=-1. Make the document-level skip link's
|
const removeSkipLinkFocusHandler = installSkipLinkFocusHandler();
|
||||||
// purpose explicit while retaining the URL fragment and normal scrolling behavior.
|
if (import.meta.hot) {
|
||||||
document.addEventListener("click", (event) => {
|
import.meta.hot.dispose(removeSkipLinkFocusHandler);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
target.focus();
|
|
||||||
target.scrollIntoView({ block: "start" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
if (!root) {
|
if (!root) {
|
||||||
|
|||||||
@@ -52,6 +52,38 @@ test.describe("platform workflows at supported widths", () => {
|
|||||||
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
|
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 }) => {
|
test("narrow mobile controls stay touchable with a short virtual-keyboard viewport", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 320, height: 430 });
|
await page.setViewportSize({ width: 320, height: 430 });
|
||||||
await page.goto("/admin/jobs");
|
await page.goto("/admin/jobs");
|
||||||
|
|||||||
Reference in new issue
Block a user