+ *
+ * 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;
diff --git a/web/packages/ui/src/markdown/rehype-table-scroll.test.mjs b/web/packages/ui/src/markdown/rehype-table-scroll.test.mjs
new file mode 100644
index 0000000000..e654373bc6
--- /dev/null
+++ b/web/packages/ui/src/markdown/rehype-table-scroll.test.mjs
@@ -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]);
+});
diff --git a/web/packages/ui/src/styles/base.css b/web/packages/ui/src/styles/base.css
index d29e084459..a82ce22700 100644
--- a/web/packages/ui/src/styles/base.css
+++ b/web/packages/ui/src/styles/base.css
@@ -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);
diff --git a/web/sites/blog/astro.config.mjs b/web/sites/blog/astro.config.mjs
index 7b8474a308..8816da8792 100644
--- a/web/sites/blog/astro.config.mjs
+++ b/web/sites/blog/astro.config.mjs
@@ -1,5 +1,6 @@
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
@@ -7,5 +8,10 @@ export default defineConfig({
// 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()],
});
diff --git a/web/sites/packages/src/lib/markdown.test.ts b/web/sites/packages/src/lib/markdown.test.ts
index 7ed5aced4d..0b6fa21839 100644
--- a/web/sites/packages/src/lib/markdown.test.ts
+++ b/web/sites/packages/src/lib/markdown.test.ts
@@ -45,6 +45,19 @@ describe('renderMarkdown', () => {
expect(html).toContain('
1 | ');
});
+ 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(/