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 rewriteTheadThScope from './rewrite-thead-th-scope'
|
||||||
import rewriteEmptyTableRows from './rewrite-empty-table-rows'
|
import rewriteEmptyTableRows from './rewrite-empty-table-rows'
|
||||||
import rewriteForRowheaders from './rewrite-for-rowheaders'
|
import rewriteForRowheaders from './rewrite-for-rowheaders'
|
||||||
|
import rewriteTableCaptions from './rewrite-table-captions'
|
||||||
import wrapProceduralImages from './wrap-procedural-images'
|
import wrapProceduralImages from './wrap-procedural-images'
|
||||||
import parseInfoString from './parse-info-string'
|
import parseInfoString from './parse-info-string'
|
||||||
import annotate from './annotate'
|
import annotate from './annotate'
|
||||||
@@ -72,6 +73,7 @@ export function createProcessor(context: Context): UnifiedProcessor {
|
|||||||
.use(rewriteEmptyTableRows)
|
.use(rewriteEmptyTableRows)
|
||||||
.use(rewriteTheadThScope)
|
.use(rewriteTheadThScope)
|
||||||
.use(rewriteForRowheaders)
|
.use(rewriteForRowheaders)
|
||||||
|
.use(rewriteTableCaptions)
|
||||||
.use(rewriteImgSources)
|
.use(rewriteImgSources)
|
||||||
.use(rewriteAssetImgTags)
|
.use(rewriteAssetImgTags)
|
||||||
// alerts plugin requires context with alertTitles property
|
// 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