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);