From acd2a9cfe946431da5eb54586f02c3dcdf618592 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sun, 6 Sep 2026 06:18:19 +0200 Subject: [PATCH] fix(web): close accessibility outline gaps --- .../preview/resume-accessible-text.test.tsx | 38 +++++++++++++++++++ .../resume/preview/resume-accessible-text.tsx | 21 +++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/web/src/features/resume/preview/resume-accessible-text.test.tsx b/apps/web/src/features/resume/preview/resume-accessible-text.test.tsx index d6f7250e1..57a5ce66f 100644 --- a/apps/web/src/features/resume/preview/resume-accessible-text.test.tsx +++ b/apps/web/src/features/resume/preview/resume-accessible-text.test.tsx @@ -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([" ", "", "", ""])( + "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: "

Role detail.

", + }, + ], + }, + ]; + + 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(); +}); diff --git a/apps/web/src/features/resume/preview/resume-accessible-text.tsx b/apps/web/src/features/resume/preview/resume-accessible-text.tsx index ef90d69d5..627d82caa 100644 --- a/apps/web/src/features/resume/preview/resume-accessible-text.tsx +++ b/apps/web/src/features/resume/preview/resume-accessible-text.tsx @@ -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
  • @@ -384,7 +401,7 @@ export function ResumeAccessibleText({ data }: ResumeAccessibleTextProps) { ) : null} - {summaryText ? ( + {hasRenderableRichText(summaryText) ? (

    {summary.title?.trim() || getSectionTitle("summary")}