Add table accessibility labels plugin (#56474)
Co-authored-by: Robert Sese <734194+rsese@users.noreply.github.com>
This commit is contained in:
212
src/content-render/tests/table-accessibility-labels.js
Normal file
212
src/content-render/tests/table-accessibility-labels.js
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
129
src/content-render/unified/rewrite-table-captions.js
Normal file
129
src/content-render/unified/rewrite-table-captions.js
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user