From ddcb11d8e99795d89c79d387a01f9ed61125536d Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Thu, 24 Jul 2025 10:33:00 -0700 Subject: [PATCH] Add table accessibility labels plugin (#56474) Co-authored-by: Robert Sese <734194+rsese@users.noreply.github.com> --- .../tests/table-accessibility-labels.js | 212 ++++++++++++++++++ src/content-render/unified/processor.ts | 2 + .../unified/rewrite-table-captions.js | 129 +++++++++++ 3 files changed, 343 insertions(+) create mode 100644 src/content-render/tests/table-accessibility-labels.js create mode 100644 src/content-render/unified/rewrite-table-captions.js diff --git a/src/content-render/tests/table-accessibility-labels.js b/src/content-render/tests/table-accessibility-labels.js new file mode 100644 index 0000000000..552e8c4341 --- /dev/null +++ b/src/content-render/tests/table-accessibility-labels.js @@ -0,0 +1,212 @@ +import cheerio from 'cheerio' +import { describe, expect, test } from 'vitest' + +import { renderContent } from '#src/content-render/index' +import { EOL } from 'os' + +// Use platform-specific line endings for realistic tests when templates have +// been loaded from disk +const nl = (str) => str.replace(/\n/g, EOL) + +describe('table accessibility labels', () => { + test('adds aria-labelledby to tables following headings', async () => { + const template = nl(` +## Supported Platforms + +| Platform | Status | +|----------|--------| +| Linux | ✅ | +| Windows | ✅ | +`) + + const html = await renderContent(template) + const $ = cheerio.load(html) + + const table = $('table') + expect(table.length).toBe(1) + expect(table.attr('aria-labelledby')).toBe('supported-platforms') + + const heading = $('#supported-platforms') + expect(heading.length).toBe(1) + expect(heading.text()).toBe('Supported Platforms') + }) + + test('works with different heading levels', async () => { + const template = nl(` +### Configuration Options + +| Option | Default | +|--------|---------| +| debug | false | +| port | 3000 | +`) + + const html = await renderContent(template) + const $ = cheerio.load(html) + + const table = $('table') + expect(table.attr('aria-labelledby')).toBe('configuration-options') + }) + + test('skips tables that already have accessibility attributes', async () => { + const template = nl(` +## Test Heading + + + + +
HeaderHeader
DataData
+`) + + const html = await renderContent(template) + const $ = cheerio.load(html) + + const table = $('table') + expect(table.attr('aria-label')).toBe('Pre-labeled table') + expect(table.attr('aria-labelledby')).toBeUndefined() + }) + + test('skips tables that already have captions', async () => { + const template = nl(` +## Test Heading + + + + + +
Existing caption
HeaderHeader
DataData
+`) + + const html = await renderContent(template) + const $ = cheerio.load(html) + + const table = $('table') + expect(table.find('caption').text()).toBe('Existing caption') + expect(table.attr('aria-labelledby')).toBeUndefined() + }) + + test('handles multiple tables with different headings', async () => { + const template = nl(` +## First Table + +| A | B | +|---|---| +| 1 | 2 | + +## Second Table + +| X | Y | +|---|---| +| 3 | 4 | +`) + + const html = await renderContent(template) + const $ = cheerio.load(html) + + const tables = $('table') + expect(tables.length).toBe(2) + expect($(tables[0]).attr('aria-labelledby')).toBe('first-table') + expect($(tables[1]).attr('aria-labelledby')).toBe('second-table') + }) + + test('skips tables without preceding headings', async () => { + const template = nl(` +| Header | Header | +|--------|--------| +| Data | Data | + +Some text here. + +| Another | Table | +|---------|-------| +| More | Data | +`) + + const html = await renderContent(template) + const $ = cheerio.load(html) + + const tables = $('table') + expect(tables.length).toBe(2) + expect($(tables[0]).attr('aria-labelledby')).toBeUndefined() + expect($(tables[1]).attr('aria-labelledby')).toBeUndefined() + }) + + test('finds nearest preceding heading even with content in between', async () => { + const template = nl(` +## Data Table + +This table shows important information: + +Some additional context here. + +| Column | Value | +|--------|-------| +| Item | 123 | +`) + + const html = await renderContent(template) + const $ = cheerio.load(html) + + const table = $('table') + expect(table.attr('aria-labelledby')).toBe('data-table') + }) + + test('stops searching at another table', async () => { + const template = nl(` +## First Heading + +| Table | One | +|-------|-----| +| A | B | + +| Table | Two | +|-------|-----| +| C | D | +`) + + const html = await renderContent(template) + const $ = cheerio.load(html) + + const tables = $('table') + expect(tables.length).toBe(2) + expect($(tables[0]).attr('aria-labelledby')).toBe('first-heading') + // Second table should not get the same heading since the first table is in between + expect($(tables[1]).attr('aria-labelledby')).toBeUndefined() + }) + + test('handles headings with complex content', async () => { + const template = nl(` +## Supported GitHub Actions Features + +| Feature | Status | +|---------|--------| +| Build | ✅ | +| Deploy | ✅ | +`) + + const html = await renderContent(template) + const $ = cheerio.load(html) + + const table = $('table') + expect(table.attr('aria-labelledby')).toBe('supported-github-actions-features') + }) + + test('preserves existing table structure and attributes', async () => { + const template = nl(` +## Test Table + +| Header 1 | Header 2 | +|----------|----------| +| Cell 1 | Cell 2 | +`) + + const html = await renderContent(template) + const $ = cheerio.load(html) + + const table = $('table') + expect(table.find('thead th').length).toBe(2) + expect(table.find('tbody td').length).toBe(2) + expect(table.find('th').first().attr('scope')).toBe('col') + expect(table.attr('aria-labelledby')).toBe('test-table') + }) +}) diff --git a/src/content-render/unified/processor.ts b/src/content-render/unified/processor.ts index 5cce9c65bb..a717a4b709 100644 --- a/src/content-render/unified/processor.ts +++ b/src/content-render/unified/processor.ts @@ -23,6 +23,7 @@ import headingLinks from './heading-links' import rewriteTheadThScope from './rewrite-thead-th-scope' import rewriteEmptyTableRows from './rewrite-empty-table-rows' import rewriteForRowheaders from './rewrite-for-rowheaders' +import rewriteTableCaptions from './rewrite-table-captions' import wrapProceduralImages from './wrap-procedural-images' import parseInfoString from './parse-info-string' import annotate from './annotate' @@ -72,6 +73,7 @@ export function createProcessor(context: Context): UnifiedProcessor { .use(rewriteEmptyTableRows) .use(rewriteTheadThScope) .use(rewriteForRowheaders) + .use(rewriteTableCaptions) .use(rewriteImgSources) .use(rewriteAssetImgTags) // alerts plugin requires context with alertTitles property diff --git a/src/content-render/unified/rewrite-table-captions.js b/src/content-render/unified/rewrite-table-captions.js new file mode 100644 index 0000000000..af534430e9 --- /dev/null +++ b/src/content-render/unified/rewrite-table-captions.js @@ -0,0 +1,129 @@ +import { visit } from 'unist-util-visit' + +/** + * A rehype plugin that automatically adds aria-labelledby attributes to tables + * based on their preceding headings for accessibility. + * + * This plugin improves table accessibility by ensuring screen readers can + * announce table names when users navigate with the 'T' shortcut key. + * + * Transforms this structure: + * + *

Supported platforms

+ * + * ... + * ... + *
+ * + * Into this: + * + *

Supported platforms

+ * + * ... + * ... + *
+ * + * The plugin works by: + * 1. Finding table elements in the HTML AST + * 2. Looking backwards for the nearest preceding heading with an id + * 3. Adding aria-labelledby attribute pointing to that heading's id + * 4. Skipping tables that already have accessibility attributes + */ + +function isTableElement(node) { + return node.type === 'element' && node.tagName === 'table' +} + +function isHeadingElement(node) { + return node.type === 'element' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName) +} + +function hasExistingAccessibilityAttributes(tableNode) { + return ( + tableNode.properties && + (tableNode.properties.ariaLabel || + tableNode.properties.ariaLabelledBy || + tableNode.properties['aria-label'] || + tableNode.properties['aria-labelledby']) + ) +} + +function hasExistingCaption(tableNode) { + return tableNode.children?.some( + (child) => child.type === 'element' && child.tagName === 'caption', + ) +} + +function findPrecedingHeading(parent, tableIndex) { + if (!parent.children || tableIndex === 0) return null + + // Look backwards from the table position for the nearest heading + for (let i = tableIndex - 1; i >= 0; i--) { + const node = parent.children[i] + + if (isHeadingElement(node)) { + // Check if the heading has an id attribute + const headingId = node.properties?.id + if (headingId) { + return { + id: headingId, + text: extractTextFromNode(node), + } + } + } + + // Stop searching if we hit another table or significant content block + if ( + isTableElement(node) || + (node.type === 'element' && ['section', 'article', 'div'].includes(node.tagName)) + ) { + break + } + } + + return null +} + +function extractTextFromNode(node) { + if (node.type === 'text') { + return node.value + } + + if (node.type === 'element' && node.children) { + return node.children + .map((child) => extractTextFromNode(child)) + .filter(Boolean) + .join('') + .trim() + } + + return '' +} + +export default function addTableAccessibilityLabels() { + return (tree) => { + visit(tree, (node, index, parent) => { + if (!isTableElement(node) || !parent || typeof index !== 'number') { + return + } + + // Skip tables that already have accessibility attributes or captions + if (hasExistingAccessibilityAttributes(node) || hasExistingCaption(node)) { + return + } + + // Find the preceding heading + const precedingHeading = findPrecedingHeading(parent, index) + if (!precedingHeading) { + return + } + + // Add aria-labelledby attribute to the table + if (!node.properties) { + node.properties = {} + } + + node.properties.ariaLabelledBy = precedingHeading.id + }) + } +}