Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web/packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"./styles/starlight-theme.css": "./src/styles/starlight-theme.css",
"./assets/*": "./src/assets/*",
"./markdown/rehype-base-prefix.mjs": "./src/markdown/rehype-base-prefix.mjs",
"./markdown/rehype-table-scroll.mjs": "./src/markdown/rehype-table-scroll.mjs",
"./markdown/remark-base-prefix.mjs": "./src/markdown/remark-base-prefix.mjs"
},
"peerDependencies": {
Expand Down
59 changes: 59 additions & 0 deletions web/packages/ui/src/markdown/rehype-table-scroll.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Wrap every markdown-rendered `<table>` in an accessible horizontal-scroll
* container, so a wide table stays inside the page while the `<table>` keeps
* its native semantics. This replaces the `display: block` stopgap on
* `.prose table`, which scrolled but dropped the implicit table role in
* Safari + VoiceOver (#4575).
*
* Wrapper shape (the standard scrollable-region pattern):
*
* <div class="table-scroll" role="region" aria-label="Table" tabindex="0">
*
* A table already inside a `div.table-scroll` is left alone, so the transform
* is idempotent. No-op for anything that is not a table; plain hast recursion,
* no dependencies. Companion to rehype-base-prefix.mjs.
*/

const WRAPPER_CLASS = 'table-scroll';

function isTableScrollWrapper(node) {
return (
node?.type === 'element' &&
node.tagName === 'div' &&
Array.isArray(node.properties?.className) &&
node.properties.className.includes(WRAPPER_CLASS)
);
}

export function rehypeTableScroll() {
return (tree) => {
const visit = (node) => {
if (!node || !Array.isArray(node.children)) return;

node.children = node.children.map((child) => {
if (child?.type === 'element' && child.tagName === 'table' && !isTableScrollWrapper(node)) {
// Recurse first, then wrap this table exactly once. `node` is the
// table's parent, so this is where an existing wrapper is detected.
visit(child);
return {
type: 'element',
tagName: 'div',
properties: {
className: [WRAPPER_CLASS],
role: 'region',
ariaLabel: 'Table',
tabIndex: 0,
},
children: [child],
};
}
visit(child);
return child;
});
};

visit(tree);
};
}

export default rehypeTableScroll;
65 changes: 65 additions & 0 deletions web/packages/ui/src/markdown/rehype-table-scroll.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { rehypeTableScroll } from './rehype-table-scroll.mjs';

// Minimal hast helpers — no dependencies.
const el = (tagName, children = [], properties = {}) => ({ type: 'element', tagName, properties, children });
const text = (value) => ({ type: 'text', value });
const root = (...children) => ({ type: 'root', children });

const run = (tree) => {
rehypeTableScroll()(tree);
return tree;
};

test('wraps a top-level table and sets the accessible wrapper properties', () => {
const tree = run(root(el('table')));
assert.equal(tree.children.length, 1);
const wrapper = tree.children[0];
assert.equal(wrapper.type, 'element');
assert.equal(wrapper.tagName, 'div');
assert.deepEqual(wrapper.properties.className, ['table-scroll']);
assert.equal(wrapper.properties.role, 'region');
assert.equal(wrapper.properties.ariaLabel, 'Table');
assert.equal(wrapper.properties.tabIndex, 0);
assert.equal(wrapper.children.length, 1);
assert.equal(wrapper.children[0].tagName, 'table');
});

test('wraps a table nested inside another element', () => {
const tree = run(root(el('div', [el('table')])));
const outer = tree.children[0];
assert.equal(outer.tagName, 'div');
const wrapper = outer.children[0];
assert.equal(wrapper.tagName, 'div');
assert.deepEqual(wrapper.properties.className, ['table-scroll']);
assert.equal(wrapper.children[0].tagName, 'table');
});

test('leaves non-table siblings alone', () => {
const paragraph = el('p', [text('hello')]);
const tree = run(root(paragraph, el('table')));
assert.equal(tree.children[0], paragraph);
assert.equal(tree.children[0].tagName, 'p');
assert.equal(tree.children[1].tagName, 'div');
assert.equal(tree.children[1].children[0].tagName, 'table');
});

test('does not double-wrap a table already inside div.table-scroll', () => {
const table = el('table');
const existing = el('div', [table], { className: ['table-scroll'] });
const tree = run(root(existing));
assert.equal(tree.children.length, 1);
assert.equal(tree.children[0], existing);
assert.equal(tree.children[0].children.length, 1);
assert.equal(tree.children[0].children[0], table);
});

test('wraps each of multiple tables in its own wrapper', () => {
const tree = run(root(el('table'), el('table')));
assert.equal(tree.children.length, 2);
assert.notEqual(tree.children[0], tree.children[1]);
assert.equal(tree.children[0].children[0].tagName, 'table');
assert.equal(tree.children[1].children[0].tagName, 'table');
assert.notEqual(tree.children[0].children[0], tree.children[1].children[0]);
});
13 changes: 9 additions & 4 deletions web/packages/ui/src/styles/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -205,14 +205,19 @@ video {
margin-top: var(--space-2);
}

/* Markdown tables have no wrapper, so let a table wider than the viewport
scroll inside its own box rather than pushing the whole page wide. */
.prose table {
display: block;
/* Markdown tables are wrapped by rehype-table-scroll.mjs in a
`.table-scroll` region, which owns the horizontal scroll. The table itself
keeps native table display so it keeps its role for assistive tech (#4575). */
.table-scroll {
max-width: 100%;
overflow-x: auto;
}

.table-scroll:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}

.prose img {
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
Expand Down
6 changes: 6 additions & 0 deletions web/sites/blog/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import { rehypeTableScroll } from '@wheels-dev/ui/markdown/rehype-table-scroll.mjs';

export default defineConfig({
// Astro 7 changed the compressHTML default to 'jsx', which drops the space
// between text and an element on the next line ("Install with<code>").
// true keeps Astro 6's HTML-aware compression.
compressHTML: true,
site: 'https://blog.wheels.dev',
// Wrap every markdown table in the shared accessible scroll container so
// `.prose table` can go back to native table display (#4575).
markdown: {
rehypePlugins: [rehypeTableScroll],
},
integrations: [sitemap()],
});
13 changes: 13 additions & 0 deletions web/sites/packages/src/lib/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ describe('renderMarkdown', () => {
expect(html).toContain('<td>1</td>');
});

it('wraps GFM tables in the accessible table-scroll region (#4575)', async () => {
const { renderMarkdown } = await import('./markdown');
const html = await renderMarkdown('| a | b |\n|---|---|\n| 1 | 2 |');
// Canonical hast properties serialize to their HTML names:
// className -> class, ariaLabel -> aria-label, tabIndex -> tabindex.
expect(html).toContain('class="table-scroll"');
expect(html).toContain('role="region"');
expect(html).toContain('aria-label="Table"');
expect(html).toContain('tabindex="0"');
// The wrapper must enclose the table, not sit beside it.
expect(html).toMatch(/<div class="table-scroll"[^>]*>\s*<table>/);
});

it('highlights fenced code blocks via shiki', async () => {
const { renderMarkdown } = await import('./markdown');
const html = await renderMarkdown('```js\nconst x = 1;\n```');
Expand Down
4 changes: 4 additions & 0 deletions web/sites/packages/src/lib/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import remarkRehype from 'remark-rehype';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import rehypeShiki from '@shikijs/rehype';
import rehypeStringify from 'rehype-stringify';
import { rehypeTableScroll } from '@wheels-dev/ui/markdown/rehype-table-scroll.mjs';

// Relax the sanitizer schema to keep shiki's syntax-highlighting
// class names (which default-schema would strip from <pre>/<code>/<span>).
Expand All @@ -26,6 +27,9 @@ export async function renderMarkdown(src: string): Promise<string> {
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: false })
.use(rehypeSanitize, schema)
// After the sanitizer, so the wrapper's role/aria-label/tabindex survive,
// and before stringify. Gives `.prose table` native display again (#4575).
.use(rehypeTableScroll)
.use(rehypeShiki, { themes: { light: 'github-light', dark: 'github-dark' } })
.use(rehypeStringify)
.process(src);
Expand Down
Loading