From 7665bf87e3a57959bb3158189c877d6c4a7b2738 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Thu, 15 Oct 2026 11:04:15 -0700 Subject: [PATCH] fix(web): wrap markdown tables in an accessible scroll region `.prose table` used display: block and overflow-x: auto so wide tables scroll on phones, but display: block drops the table's implicit role in Safari + VoiceOver. A shared rehype plugin now wraps each markdown table in
, which owns the horizontal scroll, and the table keeps its native display. The plugin is wired into the blog's Astro markdown pipeline and into the packages site's own unified() README renderer (after rehype-sanitize, so the region attributes survive). The landing /ai page already wraps its hand-written tables and is unchanged; Starlight sites don't use .prose. Fixes #4575 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- web/packages/ui/package.json | 1 + .../ui/src/markdown/rehype-table-scroll.mjs | 59 +++++++++++++++++ .../src/markdown/rehype-table-scroll.test.mjs | 65 +++++++++++++++++++ web/packages/ui/src/styles/base.css | 13 ++-- web/sites/blog/astro.config.mjs | 6 ++ web/sites/packages/src/lib/markdown.test.ts | 13 ++++ web/sites/packages/src/lib/markdown.ts | 4 ++ 7 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 web/packages/ui/src/markdown/rehype-table-scroll.mjs create mode 100644 web/packages/ui/src/markdown/rehype-table-scroll.test.mjs diff --git a/web/packages/ui/package.json b/web/packages/ui/package.json index c86b0e462a..ad66493223 100644 --- a/web/packages/ui/package.json +++ b/web/packages/ui/package.json @@ -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": { diff --git a/web/packages/ui/src/markdown/rehype-table-scroll.mjs b/web/packages/ui/src/markdown/rehype-table-scroll.mjs new file mode 100644 index 0000000000..2befe65c80 --- /dev/null +++ b/web/packages/ui/src/markdown/rehype-table-scroll.mjs @@ -0,0 +1,59 @@ +/** + * Wrap every markdown-rendered `` in an accessible horizontal-scroll + * container, so a wide table stays inside the page while the `
` 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): + * + *
+ * + * 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('
'); }); + 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(/
]*>\s*
1
/); + }); + it('highlights fenced code blocks via shiki', async () => { const { renderMarkdown } = await import('./markdown'); const html = await renderMarkdown('```js\nconst x = 1;\n```'); diff --git a/web/sites/packages/src/lib/markdown.ts b/web/sites/packages/src/lib/markdown.ts index b983e3308f..72de738335 100644 --- a/web/sites/packages/src/lib/markdown.ts +++ b/web/sites/packages/src/lib/markdown.ts @@ -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
//).
@@ -26,6 +27,9 @@ export async function renderMarkdown(src: string): Promise {
     .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);