Skip to content

Commit ddcb11d

Browse files
heiskrrsese
andauthored
Add table accessibility labels plugin (#56474)
Co-authored-by: Robert Sese <[email protected]>
1 parent f1d43aa commit ddcb11d

File tree

3 files changed

+343
-0
lines changed

3 files changed

+343
-0
lines changed
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import cheerio from 'cheerio'
2+
import { describe, expect, test } from 'vitest'
3+
4+
import { renderContent } from '#src/content-render/index'
5+
import { EOL } from 'os'
6+
7+
// Use platform-specific line endings for realistic tests when templates have
8+
// been loaded from disk
9+
const nl = (str) => str.replace(/\n/g, EOL)
10+
11+
describe('table accessibility labels', () => {
12+
test('adds aria-labelledby to tables following headings', async () => {
13+
const template = nl(`
14+
## Supported Platforms
15+
16+
| Platform | Status |
17+
|----------|--------|
18+
| Linux | ✅ |
19+
| Windows | ✅ |
20+
`)
21+
22+
const html = await renderContent(template)
23+
const $ = cheerio.load(html)
24+
25+
const table = $('table')
26+
expect(table.length).toBe(1)
27+
expect(table.attr('aria-labelledby')).toBe('supported-platforms')
28+
29+
const heading = $('#supported-platforms')
30+
expect(heading.length).toBe(1)
31+
expect(heading.text()).toBe('Supported Platforms')
32+
})
33+
34+
test('works with different heading levels', async () => {
35+
const template = nl(`
36+
### Configuration Options
37+
38+
| Option | Default |
39+
|--------|---------|
40+
| debug | false |
41+
| port | 3000 |
42+
`)
43+
44+
const html = await renderContent(template)
45+
const $ = cheerio.load(html)
46+
47+
const table = $('table')
48+
expect(table.attr('aria-labelledby')).toBe('configuration-options')
49+
})
50+
51+
test('skips tables that already have accessibility attributes', async () => {
52+
const template = nl(`
53+
## Test Heading
54+
55+
<table aria-label="Pre-labeled table">
56+
<tr><th>Header</th><th>Header</th></tr>
57+
<tr><td>Data</td><td>Data</td></tr>
58+
</table>
59+
`)
60+
61+
const html = await renderContent(template)
62+
const $ = cheerio.load(html)
63+
64+
const table = $('table')
65+
expect(table.attr('aria-label')).toBe('Pre-labeled table')
66+
expect(table.attr('aria-labelledby')).toBeUndefined()
67+
})
68+
69+
test('skips tables that already have captions', async () => {
70+
const template = nl(`
71+
## Test Heading
72+
73+
<table>
74+
<caption>Existing caption</caption>
75+
<tr><th>Header</th><th>Header</th></tr>
76+
<tr><td>Data</td><td>Data</td></tr>
77+
</table>
78+
`)
79+
80+
const html = await renderContent(template)
81+
const $ = cheerio.load(html)
82+
83+
const table = $('table')
84+
expect(table.find('caption').text()).toBe('Existing caption')
85+
expect(table.attr('aria-labelledby')).toBeUndefined()
86+
})
87+
88+
test('handles multiple tables with different headings', async () => {
89+
const template = nl(`
90+
## First Table
91+
92+
| A | B |
93+
|---|---|
94+
| 1 | 2 |
95+
96+
## Second Table
97+
98+
| X | Y |
99+
|---|---|
100+
| 3 | 4 |
101+
`)
102+
103+
const html = await renderContent(template)
104+
const $ = cheerio.load(html)
105+
106+
const tables = $('table')
107+
expect(tables.length).toBe(2)
108+
expect($(tables[0]).attr('aria-labelledby')).toBe('first-table')
109+
expect($(tables[1]).attr('aria-labelledby')).toBe('second-table')
110+
})
111+
112+
test('skips tables without preceding headings', async () => {
113+
const template = nl(`
114+
| Header | Header |
115+
|--------|--------|
116+
| Data | Data |
117+
118+
Some text here.
119+
120+
| Another | Table |
121+
|---------|-------|
122+
| More | Data |
123+
`)
124+
125+
const html = await renderContent(template)
126+
const $ = cheerio.load(html)
127+
128+
const tables = $('table')
129+
expect(tables.length).toBe(2)
130+
expect($(tables[0]).attr('aria-labelledby')).toBeUndefined()
131+
expect($(tables[1]).attr('aria-labelledby')).toBeUndefined()
132+
})
133+
134+
test('finds nearest preceding heading even with content in between', async () => {
135+
const template = nl(`
136+
## Data Table
137+
138+
This table shows important information:
139+
140+
Some additional context here.
141+
142+
| Column | Value |
143+
|--------|-------|
144+
| Item | 123 |
145+
`)
146+
147+
const html = await renderContent(template)
148+
const $ = cheerio.load(html)
149+
150+
const table = $('table')
151+
expect(table.attr('aria-labelledby')).toBe('data-table')
152+
})
153+
154+
test('stops searching at another table', async () => {
155+
const template = nl(`
156+
## First Heading
157+
158+
| Table | One |
159+
|-------|-----|
160+
| A | B |
161+
162+
| Table | Two |
163+
|-------|-----|
164+
| C | D |
165+
`)
166+
167+
const html = await renderContent(template)
168+
const $ = cheerio.load(html)
169+
170+
const tables = $('table')
171+
expect(tables.length).toBe(2)
172+
expect($(tables[0]).attr('aria-labelledby')).toBe('first-heading')
173+
// Second table should not get the same heading since the first table is in between
174+
expect($(tables[1]).attr('aria-labelledby')).toBeUndefined()
175+
})
176+
177+
test('handles headings with complex content', async () => {
178+
const template = nl(`
179+
## Supported GitHub Actions Features
180+
181+
| Feature | Status |
182+
|---------|--------|
183+
| Build | ✅ |
184+
| Deploy | ✅ |
185+
`)
186+
187+
const html = await renderContent(template)
188+
const $ = cheerio.load(html)
189+
190+
const table = $('table')
191+
expect(table.attr('aria-labelledby')).toBe('supported-github-actions-features')
192+
})
193+
194+
test('preserves existing table structure and attributes', async () => {
195+
const template = nl(`
196+
## Test Table
197+
198+
| Header 1 | Header 2 |
199+
|----------|----------|
200+
| Cell 1 | Cell 2 |
201+
`)
202+
203+
const html = await renderContent(template)
204+
const $ = cheerio.load(html)
205+
206+
const table = $('table')
207+
expect(table.find('thead th').length).toBe(2)
208+
expect(table.find('tbody td').length).toBe(2)
209+
expect(table.find('th').first().attr('scope')).toBe('col')
210+
expect(table.attr('aria-labelledby')).toBe('test-table')
211+
})
212+
})

src/content-render/unified/processor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import headingLinks from './heading-links'
2323
import rewriteTheadThScope from './rewrite-thead-th-scope'
2424
import rewriteEmptyTableRows from './rewrite-empty-table-rows'
2525
import rewriteForRowheaders from './rewrite-for-rowheaders'
26+
import rewriteTableCaptions from './rewrite-table-captions'
2627
import wrapProceduralImages from './wrap-procedural-images'
2728
import parseInfoString from './parse-info-string'
2829
import annotate from './annotate'
@@ -72,6 +73,7 @@ export function createProcessor(context: Context): UnifiedProcessor {
7273
.use(rewriteEmptyTableRows)
7374
.use(rewriteTheadThScope)
7475
.use(rewriteForRowheaders)
76+
.use(rewriteTableCaptions)
7577
.use(rewriteImgSources)
7678
.use(rewriteAssetImgTags)
7779
// alerts plugin requires context with alertTitles property
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { visit } from 'unist-util-visit'
2+
3+
/**
4+
* A rehype plugin that automatically adds aria-labelledby attributes to tables
5+
* based on their preceding headings for accessibility.
6+
*
7+
* This plugin improves table accessibility by ensuring screen readers can
8+
* announce table names when users navigate with the 'T' shortcut key.
9+
*
10+
* Transforms this structure:
11+
*
12+
* <h2 id="supported-platforms">Supported platforms</h2>
13+
* <table>
14+
* <thead>...</thead>
15+
* <tbody>...</tbody>
16+
* </table>
17+
*
18+
* Into this:
19+
*
20+
* <h2 id="supported-platforms">Supported platforms</h2>
21+
* <table aria-labelledby="supported-platforms">
22+
* <thead>...</thead>
23+
* <tbody>...</tbody>
24+
* </table>
25+
*
26+
* The plugin works by:
27+
* 1. Finding table elements in the HTML AST
28+
* 2. Looking backwards for the nearest preceding heading with an id
29+
* 3. Adding aria-labelledby attribute pointing to that heading's id
30+
* 4. Skipping tables that already have accessibility attributes
31+
*/
32+
33+
function isTableElement(node) {
34+
return node.type === 'element' && node.tagName === 'table'
35+
}
36+
37+
function isHeadingElement(node) {
38+
return node.type === 'element' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName)
39+
}
40+
41+
function hasExistingAccessibilityAttributes(tableNode) {
42+
return (
43+
tableNode.properties &&
44+
(tableNode.properties.ariaLabel ||
45+
tableNode.properties.ariaLabelledBy ||
46+
tableNode.properties['aria-label'] ||
47+
tableNode.properties['aria-labelledby'])
48+
)
49+
}
50+
51+
function hasExistingCaption(tableNode) {
52+
return tableNode.children?.some(
53+
(child) => child.type === 'element' && child.tagName === 'caption',
54+
)
55+
}
56+
57+
function findPrecedingHeading(parent, tableIndex) {
58+
if (!parent.children || tableIndex === 0) return null
59+
60+
// Look backwards from the table position for the nearest heading
61+
for (let i = tableIndex - 1; i >= 0; i--) {
62+
const node = parent.children[i]
63+
64+
if (isHeadingElement(node)) {
65+
// Check if the heading has an id attribute
66+
const headingId = node.properties?.id
67+
if (headingId) {
68+
return {
69+
id: headingId,
70+
text: extractTextFromNode(node),
71+
}
72+
}
73+
}
74+
75+
// Stop searching if we hit another table or significant content block
76+
if (
77+
isTableElement(node) ||
78+
(node.type === 'element' && ['section', 'article', 'div'].includes(node.tagName))
79+
) {
80+
break
81+
}
82+
}
83+
84+
return null
85+
}
86+
87+
function extractTextFromNode(node) {
88+
if (node.type === 'text') {
89+
return node.value
90+
}
91+
92+
if (node.type === 'element' && node.children) {
93+
return node.children
94+
.map((child) => extractTextFromNode(child))
95+
.filter(Boolean)
96+
.join('')
97+
.trim()
98+
}
99+
100+
return ''
101+
}
102+
103+
export default function addTableAccessibilityLabels() {
104+
return (tree) => {
105+
visit(tree, (node, index, parent) => {
106+
if (!isTableElement(node) || !parent || typeof index !== 'number') {
107+
return
108+
}
109+
110+
// Skip tables that already have accessibility attributes or captions
111+
if (hasExistingAccessibilityAttributes(node) || hasExistingCaption(node)) {
112+
return
113+
}
114+
115+
// Find the preceding heading
116+
const precedingHeading = findPrecedingHeading(parent, index)
117+
if (!precedingHeading) {
118+
return
119+
}
120+
121+
// Add aria-labelledby attribute to the table
122+
if (!node.properties) {
123+
node.properties = {}
124+
}
125+
126+
node.properties.ariaLabelledBy = precedingHeading.id
127+
})
128+
}
129+
}

0 commit comments

Comments
 (0)