fix(ui): preserve CV preview keyboard access across viewports

This commit is contained in:
cesnimda
2026-09-12 00:55:04 +02:00
parent ebd4a3e245
commit 806eee4e1e
35 changed files with 721 additions and 8 deletions
@@ -0,0 +1,192 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test as base, type APIRequestContext, type Locator, type Page, type TestInfo } from "@playwright/test";
import { translations } from "../src/i18n/translations";
const apiUrl = "http://localhost:5302/api";
const password = "E2ePassword123!";
async function login(api: APIRequestContext, email: string) {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
const csrf = (await api.storageState()).cookies.find(c => c.name === "XSRF-TOKEN")!.value;
return { "X-CSRF-TOKEN": csrf, "X-Session-Context": me.sessionContext as string };
}
const test = base.extend<{ owner: { headers: Record<string, string>; shortId: number; longId: number } }>({
owner: async ({ playwright, page, context }, provide) => {
const admin = await playwright.request.newContext();
try {
const adminHeaders = await login(admin, "e2e@example.test");
const email = `p403-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
const headers = await login(page.request, email);
await context.route("**/*", route => {
const url = new URL(route.request().url());
return ["localhost", "127.0.0.1"].includes(url.hostname) || ["data:", "blob:", "about:"].includes(url.protocol) ? route.continue() : route.abort();
});
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: {
version: "1", contact: { fullName: "Åse Eksempel", headline: "Canonical engineer" }, skills: ["Norsk", "C#"],
} } })).status()).toBe(200);
const ids: number[] = [];
for (const long of [false, true]) {
const created = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: long ? "Erfaring og kvalifikasjoner — æøå" : "One page CV" } });
expect(created.status()).toBe(200);
const cv = await created.json();
const customSections = long ? [{ key: "keyboard-evidence", title: "Erfaring og kvalifikasjoner", contentType: "bullets", items: Array.from({ length: 45 }, (_, i) => `Erfaring ${i + 1}: Samarbeid om tilgjengelige løsninger og pålitelig dokumentasjon for norske arbeidsgivere.`) }] : [];
expect((await page.request.put(`${apiUrl}/cv/variants/${cv.id}`, { headers, data: { name: cv.name, settings: { ...cv.settings, themeId: "minimal", customSections } } })).status()).toBe(200);
ids.push(cv.id);
}
await provide({ headers, shortId: ids[0], longId: ids[1] });
} finally { await admin.dispose(); }
},
});
async function capture(page: Page, info: TestInfo, state: string) {
const path = process.env.P4_03_CAPTURE === "true" ? `../docs/redesign/phase-4/p4-03/visual/${state}.png` : info.outputPath(`${state}.png`);
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await page.screenshot({ path });
await info.attach(state, { path, contentType: "image/png" });
}
async function scan(page: Page, info: TestInfo, state: string) {
const result = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze();
await info.attach(`${state}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }, null, 2), contentType: "application/json" });
// P4-03-D01: unchanged light Tooltip foreground/background fails on Redo.
// Retain the full finding; no preview rule or other contrast failure is exempted.
expect(result.violations.filter(v => !(state === "loaded-final" && v.id === "color-contrast" && v.nodes.every(n => n.target.length === 1 && n.target[0] === ".MuiTooltip-tooltip")))).toEqual([]);
}
async function enterPreview(page: Page, region: Locator, start = page.getByRole("tab").last()) {
// Traverse the real tab order from a preceding control, not a DOM focus call on the region.
await start.focus();
for (let i = 0; i < 80 && !(await region.evaluate(node => node === document.activeElement)); i++) await page.keyboard.press("Tab");
await expect(region).toBeFocused();
const ring = await region.evaluate(node => {
const css = getComputedStyle(node);
const rgba = (value: string) => value.match(/[\d.]+/g)!.map(Number);
const blend = (front: number[], back: number[]) => front.slice(0, 3).map((v, i) => v * (front[3] ?? 1) + back[i] * (1 - (front[3] ?? 1)));
const ancestors: Element[] = [];
for (let el = node.parentElement; el; el = el.parentElement) ancestors.unshift(el);
const surface = ancestors.reduce((back, el) => blend(rgba(getComputedStyle(el).backgroundColor), back), [255, 255, 255]);
const luminance = (rgb: number[]) => rgb.map(v => v / 255).map(v => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4).reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0);
const a = luminance(blend(rgba(css.outlineColor), surface));
const b = luminance(surface);
return { width: css.outlineWidth, style: css.outlineStyle, visible: node.matches(":focus-visible"), contrast: (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) };
});
expect(ring).toMatchObject({ width: "3px", style: "solid", visible: true });
expect(ring.contrast).toBeGreaterThanOrEqual(3);
}
for (const [mode, language] of [["light", "en"], ["dark", "nb"], ["light", "nb"], ["dark", "en"]] as const) {
test(`[PRES-C06 PRES-C07 PRES-C10 PRES-E08] ${mode}/${language} preview keyboard, pagination and responsive state`, async ({ page, owner }, info) => {
test.setTimeout(120_000);
const t = translations[language];
const suffix = `${mode}-${language}`;
await page.setViewportSize({ width: 1440, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await page.addInitScript(({ mode, language }) => { localStorage.setItem("jobtracker.themeMode", mode); localStorage.setItem("uiLanguage", language); }, { mode, language });
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: language === "nb" ? "nb-NO" : "en" } })).ok()).toBe(true);
await page.goto(`/career/builder/${owner.shortId}`);
const frame = page.frameLocator(`iframe[title="${t.cvEditorPreviewPage.replace("{page}", "1")}"]`);
await expect(frame.getByText("Åse Eksempel", { exact: true })).toBeVisible();
await expect(page.locator("[data-cv-page]")).toHaveCount(1);
const region = page.getByRole("region", { name: t.cvEditorLivePreview, exact: true });
await expect(region).toBeVisible();
await enterPreview(page, region);
await capture(page, info, `one-page-${suffix}`);
const profileBefore = (await (await page.request.get(`${apiUrl}/career/profile`)).json()).profile;
const siblingBefore = await (await page.request.get(`${apiUrl}/cv/variants/${owner.shortId}`)).json();
await page.goto(`/career/builder/${owner.longId}`);
await expect(frame.getByText("Åse Eksempel", { exact: true })).toBeVisible();
await expect.poll(() => page.locator("[data-cv-page]").count()).toBeGreaterThan(1);
await page.getByRole("tab", { name: t.cvEditorContent, exact: true }).click();
const headline = page.getByRole("textbox", { name: t.cvEditorHeadlineOverride, exact: true });
await headline.fill("Reviewed CV-only æøå");
await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible();
await enterPreview(page, region);
const top = await region.evaluate(node => node.scrollTop);
await page.keyboard.press("PageDown");
await expect.poll(() => region.evaluate(node => node.scrollTop)).toBeGreaterThan(top + 100);
await page.keyboard.press("Control+End");
await expect.poll(() => region.evaluate(node => Math.abs(node.scrollHeight - node.clientHeight - node.scrollTop))).toBeLessThan(3);
await capture(page, info, `multipage-end-${suffix}`);
await page.keyboard.press("Control+Home");
await expect.poll(() => region.evaluate(node => node.scrollTop)).toBe(0);
await page.keyboard.press("Shift+Tab");
expect(await region.evaluate(node => node.contains(document.activeElement))).toBe(false);
await page.keyboard.press("Tab");
await expect(region).toBeFocused();
// Native iframe content remains reachable. Forward traversal must eventually leave the preview.
const frames = await page.locator("[data-cv-page]").count();
const traversal = [];
for (let i = 0; i < frames * 3 + 5 && await region.evaluate(node => node.contains(document.activeElement)); i++) {
await page.keyboard.press("Tab");
traversal.push(await page.evaluate(() => {
const active = document.activeElement;
return { outer: active?.tagName, title: active?.getAttribute("title"), inner: active instanceof HTMLIFrameElement ? active.contentDocument?.activeElement?.outerHTML.slice(0, 200) : null };
}));
}
await info.attach("forward-tab-traversal", { body: JSON.stringify(traversal, null, 2), contentType: "application/json" });
expect(await region.evaluate(node => node.contains(document.activeElement))).toBe(false);
for (const width of [390, 768, 899, 900, 1024, 1440]) {
await page.setViewportSize({ width, height: width < 900 ? 844 : 900 });
if (width < 900) {
const previewButton = page.getByRole("button", { name: t.cvEditorPreview, exact: true });
await previewButton.focus();
await previewButton.press("Enter");
await expect(region).toBeVisible();
await enterPreview(page, region, previewButton);
await page.keyboard.press("PageDown");
await expect.poll(() => region.evaluate(node => node.scrollTop)).toBeGreaterThan(0);
await capture(page, info, `preview-${width}-${suffix}`);
await page.getByRole("button", { name: t.cvEditorEdit, exact: true }).press("Enter");
await expect(region).not.toBeVisible();
} else {
await expect(region).toBeVisible();
await enterPreview(page, region);
await scan(page, info, `transition-${width}`);
}
await expect(headline).toHaveValue("Reviewed CV-only æøå");
}
// Resizing while the preview owns focus must not hide that focused pane.
await enterPreview(page, region);
await page.setViewportSize({ width: 390, height: 844 });
await expect(region).toBeVisible();
await expect(region).toBeFocused();
await page.setViewportSize({ width: 1440, height: 900 });
await headline.focus();
await page.setViewportSize({ width: 390, height: 844 });
await expect(headline).toBeVisible();
await expect(headline).toBeFocused();
await page.setViewportSize({ width: 1440, height: 900 });
let failPreview!: () => void;
const heldPreview = new Promise<void>(resolve => { failPreview = resolve; });
await page.route(`${apiUrl}/cv/preview`, async route => {
await heldPreview;
await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "Synthetic preview unavailable" }) });
}, { times: 1 });
await page.getByRole("button", { name: t.cvEditorUndo, exact: true }).click();
await expect(headline).toHaveValue("");
await expect(page.getByText(t.cvEditorUpdatingPreview, { exact: true })).toBeVisible();
await enterPreview(page, region);
await page.getByText(t.cvEditorUpdatingPreview, { exact: true }).evaluate(node => node.scrollIntoView({ block: "center" }));
await capture(page, info, `loading-${suffix}`);
failPreview();
await expect(page.getByText(t.cvEditorPreviewUnavailable, { exact: true })).toBeVisible();
await expect(region).toBeFocused();
await expect(frame.getByText("Reviewed CV-only æøå", { exact: true })).toBeVisible();
await page.getByText(t.cvEditorPreviewUnavailable, { exact: true }).evaluate(node => node.scrollIntoView({ block: "center" }));
await capture(page, info, `error-${suffix}`);
await page.getByRole("button", { name: t.cvEditorRetryPreview, exact: true }).click();
await expect(frame.getByText("Canonical engineer", { exact: true })).toBeVisible();
await expect(page.getByText(t.cvEditorPreviewUnavailable, { exact: true })).not.toBeVisible();
await page.getByRole("button", { name: t.cvEditorRedo, exact: true }).click();
await expect(headline).toHaveValue("Reviewed CV-only æøå");
await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible();
expect((await (await page.request.get(`${apiUrl}/cv/variants/${owner.longId}`)).json()).settings.headline).toBe("Reviewed CV-only æøå");
expect(await (await page.request.get(`${apiUrl}/cv/variants/${owner.shortId}`)).json()).toEqual(siblingBefore);
expect((await (await page.request.get(`${apiUrl}/career/profile`)).json()).profile).toEqual(profileBefore);
await scan(page, info, "loaded-final");
});
}
+3 -3
View File
@@ -449,7 +449,7 @@ export default function CvBuilderEditor() {
</Paper>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(360px, 480px) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
<Paper sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", p: 2, borderRadius: 3, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" }, border: "1px solid", borderColor: "divider" }}>
<Paper onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", p: 2, borderRadius: 3, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" }, border: "1px solid", borderColor: "divider" }}>
<Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}>
<Tab label={t("cvEditorTemplate")} />
@@ -466,7 +466,7 @@ export default function CvBuilderEditor() {
{tab === 4 && <HistoryTab versions={versions} onRestore={restore} />}
</Paper>
<Box sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", minWidth: 0 }}>
<Box onFocusCapture={() => setMobilePane("preview")} sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", minWidth: 0 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ fontWeight: 700 }}>{t("cvEditorLivePreview")}</Typography>
{previewing && <Chip size="small" label={t("cvEditorUpdatingPreview")} variant="outlined" />}
@@ -478,7 +478,7 @@ export default function CvBuilderEditor() {
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>{t("cvEditorOverflowWarning")}</Alert>}
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>{t("cvEditorLongDocument", { pages })}</Alert>}
<iframe ref={iframeRef} title={t("cvEditorPreviewTitle")} srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, pageMarginMm)} sandbox="allow-same-origin allow-scripts" onLoad={onIframeLoad} aria-hidden tabIndex={-1} style={{ position: "absolute", left: "-10000px", top: 0, width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, visibility: "hidden", pointerEvents: "none" }} />
<Box ref={scrollRef} sx={{ overflow: "auto", maxHeight: "86vh", pr: 0.5, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" } }}>
<Box ref={scrollRef} role="region" aria-label={t("cvEditorLivePreview")} tabIndex={0} sx={{ overflow: "auto", maxHeight: "86vh", pr: 0.5, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" } }}>
<Stack spacing={2} alignItems="center">
{Array.from({ length: pages }).map((_, index) => (
<Box key={index} data-cv-page={index + 1} sx={{ flex: "0 0 auto" }}>