fix(a11y): close cross-app interaction gaps
This commit is contained in:
@@ -2,6 +2,38 @@ import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const apiUrl = "http://localhost:5302/api";
|
||||
|
||||
type Rgba = { red: number; green: number; blue: number; alpha: number };
|
||||
|
||||
function parseCssColour(value: string): Rgba {
|
||||
const parts = value.match(/[\d.]+/g)?.map(Number) ?? [];
|
||||
expect(parts.length, `expected an rgb/rgba colour but received ${value}`).toBeGreaterThanOrEqual(3);
|
||||
return { red: parts[0], green: parts[1], blue: parts[2], alpha: parts[3] ?? 1 };
|
||||
}
|
||||
|
||||
function composite(foreground: Rgba, background: Rgba): Rgba {
|
||||
return {
|
||||
red: foreground.red * foreground.alpha + background.red * (1 - foreground.alpha),
|
||||
green: foreground.green * foreground.alpha + background.green * (1 - foreground.alpha),
|
||||
blue: foreground.blue * foreground.alpha + background.blue * (1 - foreground.alpha),
|
||||
alpha: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function relativeLuminance(colour: Rgba) {
|
||||
const linear = [colour.red, colour.green, colour.blue].map((channel) => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
|
||||
}
|
||||
|
||||
function contrastRatio(foreground: Rgba, background: Rgba) {
|
||||
const foregroundLuminance = relativeLuminance(foreground);
|
||||
const backgroundLuminance = relativeLuminance(background);
|
||||
return (Math.max(foregroundLuminance, backgroundLuminance) + 0.05)
|
||||
/ (Math.min(foregroundLuminance, backgroundLuminance) + 0.05);
|
||||
}
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Email").fill("e2e@example.test");
|
||||
@@ -153,7 +185,19 @@ test("the dedicated application workspace survives deep links, long data and uns
|
||||
await expect(page.getByRole("row", { name: new RegExp(`Open ${title}`, "i") })).toBeFocused();
|
||||
|
||||
await page.goto("/jobs/2147483647");
|
||||
await expect(page.getByRole("alert").filter({ hasText: /Not Found|Could not open this application/i })).toBeVisible();
|
||||
const missingJobAlert = page.getByRole("alert").filter({ hasText: /Not Found|Could not open this application/i });
|
||||
await expect(missingJobAlert).toBeVisible();
|
||||
const alertColours = await missingJobAlert.evaluate((element) => {
|
||||
const styles = window.getComputedStyle(element);
|
||||
const parentStyles = window.getComputedStyle(element.parentElement!);
|
||||
return {
|
||||
foreground: styles.color,
|
||||
background: styles.backgroundColor,
|
||||
parentBackground: parentStyles.backgroundColor,
|
||||
};
|
||||
});
|
||||
const alertBackground = composite(parseCssColour(alertColours.background), parseCssColour(alertColours.parentBackground));
|
||||
expect(contrastRatio(parseCssColour(alertColours.foreground), alertBackground)).toBeGreaterThanOrEqual(4.5);
|
||||
await expect(page.getByRole("button", { name: "Back to applications" })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -182,8 +226,21 @@ test("a public CV renders anonymously and downloads as PDF", async ({ page }) =>
|
||||
const published = await publish.json();
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.setViewportSize({ width: 375, height: 900 });
|
||||
await page.goto(`/cv/${published.publicSlug}`);
|
||||
await expect(page.getByTitle("Public CV")).toBeVisible();
|
||||
const publicCvFrame = page.getByTitle("Public CV");
|
||||
await expect(publicCvFrame).toBeVisible();
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
const publicCvMetrics = await publicCvFrame.evaluate((element) => {
|
||||
const frame = element as HTMLIFrameElement;
|
||||
return {
|
||||
renderedWidth: frame.getBoundingClientRect().width,
|
||||
innerOverflow: (frame.contentDocument?.documentElement.scrollWidth ?? 0) - (frame.contentDocument?.documentElement.clientWidth ?? 0),
|
||||
};
|
||||
});
|
||||
expect(publicCvMetrics.renderedWidth).toBeLessThanOrEqual(343);
|
||||
expect(publicCvMetrics.innerOverflow).toBeLessThanOrEqual(1);
|
||||
await expect(page.getByRole("link", { name: "Download PDF" })).toHaveAttribute("href", /\/pdf$/);
|
||||
const pdf = await page.request.get(`${apiUrl}/public-cv/${published.publicSlug}/pdf`);
|
||||
expect(pdf.ok()).toBeTruthy();
|
||||
|
||||
@@ -312,6 +312,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={`${t("quickSearch")} (${shortcutHint})`}
|
||||
aria-label={`${t("quickSearch")} (${shortcutHint})`}
|
||||
onClick={() => setQuickOpen(true)}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42, flex: "0 0 auto" }}
|
||||
>
|
||||
|
||||
@@ -74,3 +74,31 @@ test("shows build metadata only when the caller supplies the admin-only badge",
|
||||
|
||||
expect(screen.queryByTestId("admin-version-badge")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shell icon controls expose accessible names", () => {
|
||||
render(
|
||||
<CssVarsProvider theme={getTheme("light") as any} defaultMode="light">
|
||||
<I18nProvider>
|
||||
<AppShell
|
||||
pageTitle="Dashboard"
|
||||
breadcrumbs={["Home"]}
|
||||
pathname="/dashboard"
|
||||
nav={[]}
|
||||
navBottom={[]}
|
||||
onNavigate={() => undefined}
|
||||
onToggleDrawer={() => undefined}
|
||||
drawerOpen={false}
|
||||
onOpenSettings={() => undefined}
|
||||
user={{ userName: "Ada", roleLabel: "Administrator" }}
|
||||
>
|
||||
<div>Content</div>
|
||||
</AppShell>
|
||||
</I18nProvider>
|
||||
</CssVarsProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Collapse sidebar" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Settings" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "User" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -68,6 +68,10 @@ test('attachments metadata controls update purpose and ai usage', async () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByDisplayValue(/resume/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Preview: resume.pdf' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Download: resume.pdf' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Rename: resume.pdf' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Delete: resume.pdf' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole('combobox'));
|
||||
fireEvent.click(await screen.findByRole('option', { name: /portfolio/i }));
|
||||
|
||||
@@ -317,17 +317,17 @@ export default function Attachments({ jobId }: { jobId: number }) {
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", gap: 0.5, flex: "0 0 auto" }}>
|
||||
{canPreview ? (
|
||||
<IconButton size="small" onClick={() => void openPreview(a)} title={t("attachmentsPreview")}>
|
||||
<IconButton size="small" onClick={() => void openPreview(a)} title={t("attachmentsPreview")} aria-label={`${t("attachmentsPreview")}: ${a.fileName}`}>
|
||||
<VisibilityOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
) : null}
|
||||
<IconButton size="small" onClick={() => void download(a)} title={t("attachmentsDownload")}>
|
||||
<IconButton size="small" onClick={() => void download(a)} title={t("attachmentsDownload")} aria-label={`${t("attachmentsDownload")}: ${a.fileName}`}>
|
||||
<DownloadIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => void rename(a)} title={t("attachmentsRename")}>
|
||||
<IconButton size="small" onClick={() => void rename(a)} title={t("attachmentsRename")} aria-label={`${t("attachmentsRename")}: ${a.fileName}`}>
|
||||
<DriveFileRenameOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => void remove(a)} title={t("attachmentsDelete")}>
|
||||
<IconButton size="small" onClick={() => void remove(a)} title={t("attachmentsDelete")} aria-label={`${t("attachmentsDelete")}: ${a.fileName}`}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
@@ -136,7 +136,7 @@ export default function CompaniesTable() {
|
||||
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{c.name}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{c.location || t("companiesLocation")}</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={() => openEdit(c)}>
|
||||
<IconButton size="small" aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
@@ -183,7 +183,7 @@ export default function CompaniesTable() {
|
||||
</TableCell>
|
||||
<TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => openEdit(c)}>
|
||||
<IconButton size="small" aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
|
||||
@@ -479,7 +479,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{isMe ? t("correspondenceMe") : t("correspondenceCompany")}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={() => void deleteMessage(m.id)} sx={{ color: "text.secondary" }}>
|
||||
<IconButton size="small" aria-label={t("correspondenceDeleteTitle")} onClick={() => void deleteMessage(m.id)} sx={{ color: "text.secondary" }}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
@@ -589,7 +589,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
) : null}
|
||||
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => changeIncludeDeleted(e.target.checked)} />} label={t("jobTableShowDeleted")} sx={{ mr: 0 }} /> : null}
|
||||
<SavedViewsMenu current={{ q: search.trim() || undefined, status: statusFilter !== "All" ? statusFilter : undefined, companyId: companyFilterId === "All" ? undefined : (companyFilterId as number), location: locationFilter.trim() || undefined, needsFollowUp: needsFollowUpOnly ? true : undefined }} onApply={applySavedView} />
|
||||
{!isMobile ? <Tooltip title={t("jobTableColumns")}><IconButton onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip> : null}
|
||||
{!isMobile ? <Tooltip title={t("jobTableColumns")}><IconButton aria-label={t("jobTableColumns")} onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
@@ -839,9 +839,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null}
|
||||
<TableCell align="right">
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 0.5, whiteSpace: "nowrap" }}>
|
||||
<Tooltip title={t("jobTableEdit")}><IconButton size="small" onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title={t("jobTableQuickStatus")}><IconButton size="small" onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
|
||||
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title={t("jobTableRestore")}><IconButton size="small" onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title={t("jobTableSoftDelete")}><IconButton size="small" onClick={() => void softDelete(job)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
|
||||
<Tooltip title={t("jobTableEdit")}><IconButton size="small" aria-label={`${t("jobTableEdit")}: ${job.jobTitle}`} onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title={t("jobTableQuickStatus")}><IconButton size="small" aria-label={`${t("jobTableQuickStatus")}: ${job.jobTitle}`} onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
|
||||
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title={t("jobTableRestore")}><IconButton size="small" aria-label={`${t("jobTableRestore")}: ${job.jobTitle}`} onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title={t("jobTableSoftDelete")}><IconButton size="small" aria-label={`${t("jobTableSoftDelete")}: ${job.jobTitle}`} onClick={() => void softDelete(job)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function SavedViewsMenu({
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={t("savedViewsTooltip")}>
|
||||
<IconButton size="small" onClick={(e) => setAnchor(e.currentTarget)}>
|
||||
<IconButton size="small" aria-label={t("savedViewsTooltip")} onClick={(e) => setAnchor(e.currentTarget)}>
|
||||
<BookmarkBorderIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
@@ -152,6 +152,7 @@ export default function SavedViewsMenu({
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`${t("attachmentsDelete")}: ${v.name}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -61,6 +61,11 @@ test('lists existing CVs from the variants API', async () => {
|
||||
|
||||
expect(await screen.findByText('Frontend CV')).toBeInTheDocument();
|
||||
expect(screen.getByText('Public')).toBeInTheDocument();
|
||||
const cvCard = screen.getByRole('link', { name: 'Open Frontend CV' });
|
||||
cvCard.focus();
|
||||
expect(cvCard).toHaveFocus();
|
||||
fireEvent.keyDown(cvCard, { key: 'Enter' });
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/career/builder/1');
|
||||
expect(mockedApi.get).toHaveBeenCalledWith('/cv/variants');
|
||||
});
|
||||
|
||||
|
||||
@@ -293,6 +293,7 @@ export default function AppShell({
|
||||
edge="start"
|
||||
size="small"
|
||||
color="secondary"
|
||||
aria-label="Open navigation"
|
||||
onClick={() => onToggleDrawer(true)}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
|
||||
>
|
||||
@@ -311,6 +312,7 @@ export default function AppShell({
|
||||
{user ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={t("user")}
|
||||
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
||||
sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", width: 42, height: 42, flex: "0 0 auto" }}
|
||||
>
|
||||
@@ -337,6 +339,7 @@ export default function AppShell({
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("settings")}
|
||||
aria-label={t("settings")}
|
||||
onClick={onOpenSettings}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
|
||||
>
|
||||
@@ -366,6 +369,7 @@ export default function AppShell({
|
||||
color="secondary"
|
||||
onClick={() => setDesktopNavCollapsed((value) => !value)}
|
||||
title={desktopNavCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
aria-label={desktopNavCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, width: 40, height: 40 }}
|
||||
>
|
||||
<MenuOpenIcon fontSize="small" sx={{ transform: desktopNavCollapsed ? "scaleX(-1)" : "none" }} />
|
||||
@@ -400,6 +404,7 @@ export default function AppShell({
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("settings")}
|
||||
aria-label={t("settings")}
|
||||
onClick={onOpenSettings}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
||||
>
|
||||
@@ -414,6 +419,7 @@ export default function AppShell({
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.25, pl: { xs: 0, sm: 1 } }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={t("user")}
|
||||
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
||||
sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { api } from './api';
|
||||
@@ -16,6 +16,8 @@ jest.mock('./api', () => ({
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
test('public CV exposes the rendered CV and PDF download', async () => {
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 375 });
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Public CV</p>', name: 'Ada Lovelace' } } as any);
|
||||
|
||||
render(
|
||||
@@ -24,7 +26,12 @@ test('public CV exposes the rendered CV and PDF download', async () => {
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Public CV</p>');
|
||||
const frame = await screen.findByTitle('Public CV');
|
||||
expect(frame).toHaveAttribute('srcdoc', '<p>Public CV</p>');
|
||||
expect(Number.parseFloat(frame.style.width)).toBeGreaterThan(790);
|
||||
await waitFor(() => expect(frame.style.transform).not.toBe('scale(1)'));
|
||||
expect(Number.parseFloat(screen.getByTestId('public-cv-frame-container').style.width)).toBeLessThanOrEqual(343);
|
||||
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/public-slug/pdf');
|
||||
expect(mockedApi.get).toHaveBeenCalledWith('/public-cv/public-slug');
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
|
||||
});
|
||||
|
||||
@@ -352,7 +352,7 @@ export default function CvBuilderEditor() {
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(360px, 460px) 1fr" }, gap: 2, alignItems: "start" }}>
|
||||
<Paper sx={{ p: 2, borderRadius: 4, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 24px)" }, overflowY: { md: "auto" } }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<Tooltip title="Back to CVs"><IconButton size="small" onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Back to CVs"><IconButton size="small" aria-label="Back to CVs" onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<TextField variant="standard" fullWidth value={name} onChange={(e) => renameVariant(e.target.value)}
|
||||
error={!name.trim()} helperText={!name.trim() ? "Enter a name before saving." : undefined}
|
||||
slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } }, htmlInput: { "aria-label": "CV name" } }} />
|
||||
|
||||
@@ -99,7 +99,20 @@ export default function CvBuilderPage() {
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
|
||||
{variants.map((v) => (
|
||||
<Paper key={v.id} sx={{ p: 2, borderRadius: 4, cursor: "pointer", "&:hover": { boxShadow: 4 } }} onClick={() => navigate(`/career/builder/${v.id}`)}>
|
||||
<Paper
|
||||
key={v.id}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-label={`Open ${v.name}`}
|
||||
sx={{ p: 2, borderRadius: 4, cursor: "pointer", "&:hover": { boxShadow: 4 }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }}
|
||||
onClick={() => navigate(`/career/builder/${v.id}`)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) {
|
||||
event.preventDefault();
|
||||
navigate(`/career/builder/${v.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="flex-start" justifyContent="space-between">
|
||||
<Typography sx={{ fontWeight: 800 }}>{v.name}</Typography>
|
||||
<IconButton size="small" aria-label={`Actions for ${v.name}`} onClick={(e) => { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}>
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useParams } from "react-router-dom";
|
||||
|
||||
import { api } from "../api";
|
||||
|
||||
const A4_WIDTH_PX = (210 / 25.4) * 96;
|
||||
const A4_HEIGHT_PX = (297 / 25.4) * 96;
|
||||
|
||||
// Anonymous read-only public CV at /cv/:slug. Renders the server-produced HTML in a sandboxed
|
||||
// iframe. noindex is enforced server-side (X-Robots-Tag) and reinforced with a meta tag here.
|
||||
export default function PublicCvPage() {
|
||||
@@ -10,6 +13,8 @@ export default function PublicCvPage() {
|
||||
const pdfUrl = `${api.defaults.baseURL}/public-cv/${slug}/pdf`;
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [frameScale, setFrameScale] = useState(1);
|
||||
const [frameHeight, setFrameHeight] = useState(A4_HEIGHT_PX);
|
||||
|
||||
useEffect(() => {
|
||||
const meta = document.createElement("meta");
|
||||
@@ -34,6 +39,13 @@ export default function PublicCvPage() {
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateScale = () => setFrameScale(Math.min(1, Math.max(0.1, (window.innerWidth - 32) / A4_WIDTH_PX)));
|
||||
updateScale();
|
||||
window.addEventListener("resize", updateScale);
|
||||
return () => window.removeEventListener("resize", updateScale);
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#374151" }}>
|
||||
@@ -43,23 +55,32 @@ export default function PublicCvPage() {
|
||||
}
|
||||
if (html === null) {
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#9ca3af" }}>
|
||||
<div role="status" style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#4b5563" }}>
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "#e9edf2", padding: "24px 0", display: "flex", flexDirection: "column", alignItems: "center", gap: 16 }}>
|
||||
<div style={{ minHeight: "100vh", background: "#e9edf2", padding: "24px 16px", display: "flex", flexDirection: "column", alignItems: "center", gap: 16, overflowX: "hidden" }}>
|
||||
<a href={pdfUrl} download style={{ padding: "10px 16px", borderRadius: 8, background: "#1d4ed8", color: "#fff", font: "600 14px system-ui", textDecoration: "none" }}>
|
||||
Download PDF
|
||||
</a>
|
||||
<iframe
|
||||
title="Public CV"
|
||||
srcDoc={html}
|
||||
sandbox="allow-same-origin"
|
||||
style={{ width: "210mm", height: "297mm", border: "none", background: "#fff", boxShadow: "0 8px 30px rgba(0,0,0,0.18)" }}
|
||||
/>
|
||||
<div
|
||||
data-testid="public-cv-frame-container"
|
||||
style={{ width: A4_WIDTH_PX * frameScale, height: frameHeight * frameScale, maxWidth: "100%", overflow: "hidden", boxShadow: "0 8px 30px rgba(0,0,0,0.18)" }}
|
||||
>
|
||||
<iframe
|
||||
title="Public CV"
|
||||
srcDoc={html}
|
||||
sandbox="allow-same-origin"
|
||||
onLoad={(event) => {
|
||||
const documentHeight = event.currentTarget.contentDocument?.documentElement.scrollHeight ?? A4_HEIGHT_PX;
|
||||
setFrameHeight(Math.max(A4_HEIGHT_PX, documentHeight));
|
||||
}}
|
||||
style={{ width: A4_WIDTH_PX, height: frameHeight, transform: `scale(${frameScale})`, transformOrigin: "top left", border: "none", background: "#fff", display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user