1
0
mirror of synced 2025-12-19 18:10:59 -05:00

Add table accessibility labels plugin (#56474)

Co-authored-by: Robert Sese <734194+rsese@users.noreply.github.com>
This commit is contained in:
Kevin Heis
2025-07-24 10:33:00 -07:00
committed by GitHub
parent f1d43aa362
commit ddcb11d8e9
3 changed files with 343 additions and 0 deletions

View File

@@ -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
<table aria-label="Pre-labeled table">
<tr><th>Header</th><th>Header</th></tr>
<tr><td>Data</td><td>Data</td></tr>
</table>
`)
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
<table>
<caption>Existing caption</caption>
<tr><th>Header</th><th>Header</th></tr>
<tr><td>Data</td><td>Data</td></tr>
</table>
`)
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')
})
})

View File

@@ -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

View File

@@ -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:
*
* <h2 id="supported-platforms">Supported platforms</h2>
* <table>
* <thead>...</thead>
* <tbody>...</tbody>
* </table>
*
* Into this:
*
* <h2 id="supported-platforms">Supported platforms</h2>
* <table aria-labelledby="supported-platforms">
* <thead>...</thead>
* <tbody>...</tbody>
* </table>
*
* 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
})
}
}