fix(web): close accessibility outline gaps

This commit is contained in:
Amruth Pillai
2026-09-06 06:18:19 +02:00
parent 903f9280d5
commit acd2a9cfe9
2 changed files with 57 additions and 2 deletions
@@ -180,3 +180,41 @@ it("exposes entry and subordinate-role headings with safe nested rich-text lists
expect(screen.queryByText("alert(1)")).not.toBeInTheDocument();
expect(screen.getByText("Acme Company")).toHaveTextContent("Acme Company");
});
it.each([" ", "<script>alert(1)</script>", "<style>.hidden { display: none; }</style>", "<iframe>ignored</iframe>"])(
"omits summary section when content has no renderable text: %s",
(content) => {
const data = structuredClone(sampleResumeData);
data.summary.content = content;
renderAccessibleText(data);
const summaryTitle = data.summary.title?.trim() || "Summary";
expect(screen.queryByRole("heading", { level: 2, name: summaryTitle })).not.toBeInTheDocument();
},
);
it("promotes role heading to H3 when experience company is blank", () => {
const data = structuredClone(sampleResumeData);
data.sections.experience.items = [
{
...data.sections.experience.items[0],
id: "blank-company-item",
company: "",
position: "",
roles: [
{
id: "blank-company-role",
position: "Role Without Company",
period: "2020 - 2022",
description: "<p>Role detail.</p>",
},
],
},
];
renderAccessibleText(data);
expect(screen.getByRole("heading", { level: 3, name: "Role Without Company" })).toBeInTheDocument();
expect(screen.queryByRole("heading", { level: 4, name: "Role Without Company" })).not.toBeInTheDocument();
});
@@ -128,6 +128,23 @@ function RichText({ html }: { html: string }) {
return Array.from(body.childNodes).map((node, index) => renderRichTextNode(node, `rich-text-${index}`));
}
function hasRenderableRichText(html: string): boolean {
if (!html.trim()) return false;
if (typeof DOMParser === "undefined") return stripHtml(html).trim().length > 0;
const body = new DOMParser().parseFromString(html, "text/html").body;
const getText = (node: ChildNode): string => {
if (node.nodeType === 3) return node.textContent ?? "";
if (node.nodeType !== 1) return "";
const element = node as Element;
if (RICH_TEXT_OMIT_TAGS.has(element.tagName.toLowerCase())) return "";
return Array.from(element.childNodes).map(getText).join("");
};
return Array.from(body.childNodes).some((node) => getText(node).trim().length > 0);
}
type ItemWebsite = { url?: string; label?: string };
type ItemBodyProps = {
@@ -174,7 +191,7 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay
<li key={role.id}>
<ItemBody
heading={role.position}
headingLevel={4}
headingLevel={it.company.trim() ? 4 : 3}
details={role.period}
description={role.description}
/>
@@ -384,7 +401,7 @@ export function ResumeAccessibleText({ data }: ResumeAccessibleTextProps) {
) : null}
</header>
{summaryText ? (
{hasRenderableRichText(summaryText) ? (
<section>
<h2>{summary.title?.trim() || getSectionTitle("summary")}</h2>
<RichText html={summaryText} />