From 2314b2780e4029d1dde1425f33ee9c77dd22e8ee Mon Sep 17 00:00:00 2001 From: Sophie Neumann Date: Wed, 24 Jun 2026 15:00:10 +0200 Subject: [PATCH 1/5] fix(ui): update table rendering behavior --- web-ui/app/_components/Markdown.tsx | 20 ++- web-ui/app/_components/MarkdownTable.tsx | 160 +++++++++++++++++++++++ web-ui/app/globals.css | 36 ++++- web-ui/messages/de.json | 6 + web-ui/messages/en.json | 6 + 5 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 web-ui/app/_components/MarkdownTable.tsx diff --git a/web-ui/app/_components/Markdown.tsx b/web-ui/app/_components/Markdown.tsx index daa18802..228491d0 100644 --- a/web-ui/app/_components/Markdown.tsx +++ b/web-ui/app/_components/Markdown.tsx @@ -5,6 +5,7 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { stripCitationMarkers } from '../_lib/citations'; +import { MarkdownTable } from './MarkdownTable'; /** * Thin wrapper that renders GitHub-flavored markdown with our project-local @@ -117,9 +118,26 @@ export function Markdown({ const displayed = useMemo(() => stripCitationMarkers(source), [source]); return (
- + {displayed}
); } + +/** + * GFM table renderer override. Wraps every table in a scroll-container with + * a "full view" toolbar — see {@link MarkdownTable}. Hoisted to module scope + * so the components object is stable across re-renders (no needless tree + * recomputation during streaming). + */ +const MARKDOWN_COMPONENTS: ComponentProps['components'] = + { + table: ({ children, className }) => ( + {children} + ), + }; diff --git a/web-ui/app/_components/MarkdownTable.tsx b/web-ui/app/_components/MarkdownTable.tsx new file mode 100644 index 00000000..78bba1ba --- /dev/null +++ b/web-ui/app/_components/MarkdownTable.tsx @@ -0,0 +1,160 @@ +'use client'; + +import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Maximize2, X } from 'lucide-react'; +import { useTranslations } from 'next-intl'; + +import { cn } from '../_lib/cn'; + +interface TableProps { + children?: ReactNode; + className?: string; +} + +/** + * Custom `` renderer for {@link Markdown}. Wraps the GFM table in a + * scroll-container so very wide / very long tables stay inside the chat + * bubble: horizontal scroll for many columns or long compound-word cells, + * vertical scroll with a sticky `` for many rows. A toolbar button + * opens the table in a full-viewport modal for inspection. + * + * Streaming-safe: the table DOM is owned by React; the full-view modal + * renders the same children in a body-portal overlay, so chat-bubble + * ancestors with `transform`/`filter` can't re-anchor its `position: fixed`. + */ +export function MarkdownTable({ + children, + className, +}: TableProps): React.ReactElement { + const t = useTranslations('markdownTable'); + const [open, setOpen] = useState(false); + + const onOpen = useCallback(() => { + setOpen(true); + }, []); + const onClose = useCallback(() => { + setOpen(false); + }, []); + + return ( +
+ +
+
{children}
+ + + {children} + + + ); +} + +// --------------------------------------------------------------------------- +// Full-view modal — body-portal so the `fixed` panel is always anchored to +// the viewport, regardless of chat-bubble ancestors that may have set +// `transform`/`filter`/`backdrop-filter` (which would otherwise turn the +// nearest such ancestor into the containing block for `position: fixed`). +// --------------------------------------------------------------------------- + +interface ModalProps { + open: boolean; + onClose: () => void; + children?: ReactNode; + className?: string; +} + +function TableFullViewModal({ + open, + onClose, + children, + className, +}: ModalProps): React.ReactElement | null { + const t = useTranslations('markdownTable'); + // SSR has no `document` — defer portal creation until mounted to avoid a + // hydration mismatch. Server renders null; client mounts → portal appears. + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => { + window.removeEventListener('keydown', onKey); + }; + }, [open, onClose]); + + if (!mounted) return null; + + return createPortal( + + {open ? ( + +
+

+ {t('modalTitle')} +

+ +
+ +
+
+ {children}
+
+
+
+ ) : null} +
, + document.body, + ); +} + +// --------------------------------------------------------------------------- +// Motion variants — pure opacity fade. Avoids `transform` on the panel so +// nothing inside the fullscreen pane inherits a stacking-context that would +// re-anchor a hypothetical nested `position: fixed` child. +// --------------------------------------------------------------------------- + +const EASE_OUT: [number, number, number, number] = [0.22, 0.61, 0.36, 1]; +const TRANSITION_FAST = { duration: 0.14, ease: EASE_OUT }; + +const FADE_VARIANTS = { + hidden: { opacity: 0 }, + shown: { opacity: 1 }, +}; diff --git a/web-ui/app/globals.css b/web-ui/app/globals.css index 3c386df9..9345ad47 100644 --- a/web-ui/app/globals.css +++ b/web-ui/app/globals.css @@ -480,18 +480,52 @@ body { @apply font-semibold text-[color:var(--fg-strong)]; } +/* Tables live inside a scroll-wrapper (MarkdownTable) so very wide tables + scroll horizontally inside the chat bubble instead of bleeding past it. + The wrapper also gives a max-height + sticky thead for long tables. The + `min-width: max-content` keeps columns from being squeezed to unreadable + widths — the wrapper takes the scroll instead. */ +.md-table-wrap { + @apply relative my-3 overflow-auto rounded-md; + max-width: 100%; + max-height: 60vh; + border: 1px solid var(--border); +} +.md-table-wrap--full { + max-height: none; + border: 0; + border-radius: 0; +} .md-view table { - @apply my-3 w-full border-collapse text-xs; + @apply border-collapse text-xs; + min-width: max-content; + width: 100%; } .md-view th, .md-view td { @apply px-3 py-2 text-left align-top tabular-nums; border: 1px solid var(--border); + overflow-wrap: anywhere; + word-break: break-word; } .md-view th { @apply font-semibold; background: var(--bg-soft); } +.md-view thead th { + position: sticky; + top: 0; + z-index: 1; +} +@media print { + .md-table-wrap { + overflow: visible; + max-height: none; + } + .md-view thead th { + position: static; + } +} .md-view hr { @apply my-3; border-color: var(--divider); diff --git a/web-ui/messages/de.json b/web-ui/messages/de.json index 3cb82dee..2506527d 100644 --- a/web-ui/messages/de.json +++ b/web-ui/messages/de.json @@ -273,6 +273,12 @@ "outputLabel": "Output", "subIterations": "{count} interne Iterationen" }, + "markdownTable": { + "openFullView": "Tabelle in Vollansicht öffnen", + "modalTitle": "Tabelle — Vollansicht", + "ariaClose": "Schließen", + "ariaCloseBackdrop": "Schließen" + }, "nudgeCard": { "kicker": "Vorschlag", "ackTriggered": "✓ ausgelöst", diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json index 57fa5c2c..5470c83a 100644 --- a/web-ui/messages/en.json +++ b/web-ui/messages/en.json @@ -273,6 +273,12 @@ "outputLabel": "Output", "subIterations": "{count} inner iterations" }, + "markdownTable": { + "openFullView": "Open table in full view", + "modalTitle": "Table — full view", + "ariaClose": "Close", + "ariaCloseBackdrop": "Close" + }, "nudgeCard": { "kicker": "Suggestion", "ackTriggered": "✓ triggered", From 4b34ab07de24d7187e3d5774f8e24439317625cb Mon Sep 17 00:00:00 2001 From: Sophie Neumann Date: Fri, 26 Jun 2026 10:12:59 +0200 Subject: [PATCH 2/5] fix(ui): update ui changes to requested scope --- web-ui/app/_components/MarkdownTable.tsx | 152 ++---------------- .../_components/__tests__/Markdown.test.tsx | 10 +- web-ui/app/globals.css | 33 ++-- web-ui/messages/de.json | 5 +- web-ui/messages/en.json | 5 +- 5 files changed, 44 insertions(+), 161 deletions(-) diff --git a/web-ui/app/_components/MarkdownTable.tsx b/web-ui/app/_components/MarkdownTable.tsx index 78bba1ba..5325eb6a 100644 --- a/web-ui/app/_components/MarkdownTable.tsx +++ b/web-ui/app/_components/MarkdownTable.tsx @@ -1,13 +1,8 @@ 'use client'; -import { useCallback, useEffect, useState, type ReactNode } from 'react'; -import { createPortal } from 'react-dom'; -import { AnimatePresence, motion } from 'framer-motion'; -import { Maximize2, X } from 'lucide-react'; +import type { ReactNode } from 'react'; import { useTranslations } from 'next-intl'; -import { cn } from '../_lib/cn'; - interface TableProps { children?: ReactNode; className?: string; @@ -15,146 +10,25 @@ interface TableProps { /** * Custom `` renderer for {@link Markdown}. Wraps the GFM table in a - * scroll-container so very wide / very long tables stay inside the chat - * bubble: horizontal scroll for many columns or long compound-word cells, - * vertical scroll with a sticky `` for many rows. A toolbar button - * opens the table in a full-viewport modal for inspection. - * - * Streaming-safe: the table DOM is owned by React; the full-view modal - * renders the same children in a body-portal overlay, so chat-bubble - * ancestors with `transform`/`filter` can't re-anchor its `position: fixed`. + * scroll-container so very wide / very long tables stay inside their + * surrounding block: horizontal scroll for many columns or long + * compound-word cells, vertical scroll with a sticky `` for many + * rows. The wrapper is keyboard-focusable so arrow-key scrolling works + * for keyboard-only users. */ export function MarkdownTable({ children, className, }: TableProps): React.ReactElement { const t = useTranslations('markdownTable'); - const [open, setOpen] = useState(false); - - const onOpen = useCallback(() => { - setOpen(true); - }, []); - const onClose = useCallback(() => { - setOpen(false); - }, []); - return ( -
- -
-
{children}
- - - {children} - +
+ {children}
); } - -// --------------------------------------------------------------------------- -// Full-view modal — body-portal so the `fixed` panel is always anchored to -// the viewport, regardless of chat-bubble ancestors that may have set -// `transform`/`filter`/`backdrop-filter` (which would otherwise turn the -// nearest such ancestor into the containing block for `position: fixed`). -// --------------------------------------------------------------------------- - -interface ModalProps { - open: boolean; - onClose: () => void; - children?: ReactNode; - className?: string; -} - -function TableFullViewModal({ - open, - onClose, - children, - className, -}: ModalProps): React.ReactElement | null { - const t = useTranslations('markdownTable'); - // SSR has no `document` — defer portal creation until mounted to avoid a - // hydration mismatch. Server renders null; client mounts → portal appears. - const [mounted, setMounted] = useState(false); - useEffect(() => { - setMounted(true); - }, []); - - useEffect(() => { - if (!open) return; - const onKey = (e: KeyboardEvent): void => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', onKey); - return () => { - window.removeEventListener('keydown', onKey); - }; - }, [open, onClose]); - - if (!mounted) return null; - - return createPortal( - - {open ? ( - -
-

- {t('modalTitle')} -

- -
- -
-
- {children}
-
-
-
- ) : null} -
, - document.body, - ); -} - -// --------------------------------------------------------------------------- -// Motion variants — pure opacity fade. Avoids `transform` on the panel so -// nothing inside the fullscreen pane inherits a stacking-context that would -// re-anchor a hypothetical nested `position: fixed` child. -// --------------------------------------------------------------------------- - -const EASE_OUT: [number, number, number, number] = [0.22, 0.61, 0.36, 1]; -const TRANSITION_FAST = { duration: 0.14, ease: EASE_OUT }; - -const FADE_VARIANTS = { - hidden: { opacity: 0 }, - shown: { opacity: 1 }, -}; diff --git a/web-ui/app/_components/__tests__/Markdown.test.tsx b/web-ui/app/_components/__tests__/Markdown.test.tsx index 44541926..b8480db2 100644 --- a/web-ui/app/_components/__tests__/Markdown.test.tsx +++ b/web-ui/app/_components/__tests__/Markdown.test.tsx @@ -1,6 +1,6 @@ -import { render } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; +import { renderWithIntl } from '../../_lib/test-utils'; import { Markdown } from '../Markdown'; /** @@ -10,7 +10,7 @@ import { Markdown } from '../Markdown'; */ describe(' — Privacy Shield v4 highlight', () => { it('wraps a highlightTerm occurrence in a violet span', () => { - const { container } = render( + const { container } = renderWithIntl( — Privacy Shield v4 highlight', () => { }); it('does not highlight anything when no terms are given', () => { - const { container } = render( + const { container } = renderWithIntl( , ); expect(container.querySelector('span[class*="bg-[color:var(--accent)]/10"]')).toBeNull(); @@ -30,7 +30,7 @@ describe(' — Privacy Shield v4 highlight', () => { }); it('highlights every occurrence and leaves surrounding text intact', () => { - const { container } = render( + const { container } = renderWithIntl( — Privacy Shield v4 highlight', () => { }); it('ignores empty / whitespace-only terms', () => { - const { container } = render( + const { container } = renderWithIntl( , ); expect(container.querySelector('span[class*="bg-[color:var(--accent)]/10"]')).toBeNull(); diff --git a/web-ui/app/globals.css b/web-ui/app/globals.css index 9345ad47..859a13a0 100644 --- a/web-ui/app/globals.css +++ b/web-ui/app/globals.css @@ -491,31 +491,46 @@ body { max-height: 60vh; border: 1px solid var(--border); } -.md-table-wrap--full { - max-height: none; - border: 0; - border-radius: 0; -} .md-view table { - @apply border-collapse text-xs; + @apply text-xs; + border-collapse: separate; + border-spacing: 0; +} +/* Inside the scroll wrapper: `min-width: max-content` keeps wide tables from + being squeezed (the wrapper takes the horizontal scroll instead); `width: + 100%` makes small tables fill the wrapper so its border/rounded corners + don't gap. Outside the wrapper (e.g. BuilderMarkdown), small tables stay + at content width instead of stretching to full width. */ +.md-table-wrap table { min-width: max-content; width: 100%; } .md-view th, .md-view td { @apply px-3 py-2 text-left align-top tabular-nums; - border: 1px solid var(--border); - overflow-wrap: anywhere; - word-break: break-word; + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + overflow-wrap: break-word; +} +.md-view th:first-child, +.md-view td:first-child { + border-left: 1px solid var(--border); } .md-view th { @apply font-semibold; background: var(--bg-soft); } +/* `border-collapse: separate` keeps cell borders on the cell, so a sticky + header keeps its divider while scrolling. We still render the bottom rule + as an inset shadow (instead of `border-bottom`) so it's painted by the + sticky cell itself — guaranteed visible regardless of how the row above + resolves the shared edge. */ .md-view thead th { position: sticky; top: 0; z-index: 1; + border-bottom: 0; + box-shadow: inset 0 -1px 0 var(--border); } @media print { .md-table-wrap { diff --git a/web-ui/messages/de.json b/web-ui/messages/de.json index 2506527d..3e1c658a 100644 --- a/web-ui/messages/de.json +++ b/web-ui/messages/de.json @@ -274,10 +274,7 @@ "subIterations": "{count} interne Iterationen" }, "markdownTable": { - "openFullView": "Tabelle in Vollansicht öffnen", - "modalTitle": "Tabelle — Vollansicht", - "ariaClose": "Schließen", - "ariaCloseBackdrop": "Schließen" + "scrollRegionLabel": "Scrollbare Tabelle" }, "nudgeCard": { "kicker": "Vorschlag", diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json index 5470c83a..46ae9b25 100644 --- a/web-ui/messages/en.json +++ b/web-ui/messages/en.json @@ -274,10 +274,7 @@ "subIterations": "{count} inner iterations" }, "markdownTable": { - "openFullView": "Open table in full view", - "modalTitle": "Table — full view", - "ariaClose": "Close", - "ariaCloseBackdrop": "Close" + "scrollRegionLabel": "Scrollable table" }, "nudgeCard": { "kicker": "Suggestion", From e3ba05aa0f3916dafb9251ca1e6318789ff883d0 Mon Sep 17 00:00:00 2001 From: Sophie Neumann Date: Mon, 29 Jun 2026 09:43:48 +0200 Subject: [PATCH 3/5] fix: implement feedback --- web-ui/app/_components/Markdown.tsx | 6 +- web-ui/app/_components/MarkdownTable.tsx | 2 +- .../__tests__/MarkdownTable.test.tsx | 57 +++++++++++++++++++ web-ui/app/globals.css | 46 +++++++++------ 4 files changed, 89 insertions(+), 22 deletions(-) create mode 100644 web-ui/app/_components/__tests__/MarkdownTable.test.tsx diff --git a/web-ui/app/_components/Markdown.tsx b/web-ui/app/_components/Markdown.tsx index 228491d0..f2a96fc2 100644 --- a/web-ui/app/_components/Markdown.tsx +++ b/web-ui/app/_components/Markdown.tsx @@ -130,9 +130,9 @@ export function Markdown({ } /** - * GFM table renderer override. Wraps every table in a scroll-container with - * a "full view" toolbar — see {@link MarkdownTable}. Hoisted to module scope - * so the components object is stable across re-renders (no needless tree + * GFM table renderer override. Wraps every table in a horizontal/vertical + * scroll container — see {@link MarkdownTable}. Hoisted to module scope so + * the components object is stable across re-renders (no needless tree * recomputation during streaming). */ const MARKDOWN_COMPONENTS: ComponentProps['components'] = diff --git a/web-ui/app/_components/MarkdownTable.tsx b/web-ui/app/_components/MarkdownTable.tsx index 5325eb6a..4bfb67df 100644 --- a/web-ui/app/_components/MarkdownTable.tsx +++ b/web-ui/app/_components/MarkdownTable.tsx @@ -25,7 +25,7 @@ export function MarkdownTable({
{children}
diff --git a/web-ui/app/_components/__tests__/MarkdownTable.test.tsx b/web-ui/app/_components/__tests__/MarkdownTable.test.tsx new file mode 100644 index 00000000..f3b2d994 --- /dev/null +++ b/web-ui/app/_components/__tests__/MarkdownTable.test.tsx @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { renderWithIntl } from '../../_lib/test-utils'; +import { MarkdownTable } from '../MarkdownTable'; + +/** + * MarkdownTable wraps the GFM `` in a focusable scroll container. + * Asserts the wrapper contract: container class, accessibility attrs from + * the i18n key, and that the inner table receives the className passed by + * the react-markdown override slot. + */ +describe('', () => { + it('wraps children in a .md-table-wrap div with the inner table', () => { + const { container } = renderWithIntl( + + + + + + + , + ); + const wrap = container.querySelector('div.md-table-wrap'); + expect(wrap).not.toBeNull(); + expect(wrap?.querySelector('table')).not.toBeNull(); + }); + + it('exposes the scroll container as a focusable group with an i18n aria-label', () => { + const { container } = renderWithIntl( + + + + + + + , + ); + const wrap = container.querySelector('div.md-table-wrap'); + expect(wrap?.getAttribute('role')).toBe('group'); + expect(wrap?.getAttribute('tabindex')).toBe('0'); + expect(wrap?.getAttribute('aria-label')).toBe('Scrollable table'); + }); + + it('forwards className to the inner
cell
cell
', () => { + const { container } = renderWithIntl( + + + + + + + , + ); + const table = container.querySelector('table'); + expect(table?.className).toBe('custom-class'); + }); +}); diff --git a/web-ui/app/globals.css b/web-ui/app/globals.css index 859a13a0..921b3174 100644 --- a/web-ui/app/globals.css +++ b/web-ui/app/globals.css @@ -492,40 +492,50 @@ body { border: 1px solid var(--border); } .md-view table { - @apply text-xs; - border-collapse: separate; - border-spacing: 0; + @apply my-3 w-full text-xs; + border-collapse: collapse; +} +.md-view th, +.md-view td { + @apply px-3 py-2 text-left align-top tabular-nums; + border: 1px solid var(--border); + overflow-wrap: break-word; +} +.md-view th { + @apply font-semibold; + background: var(--bg-soft); } -/* Inside the scroll wrapper: `min-width: max-content` keeps wide tables from +/* Inside the scroll wrapper we switch to `border-collapse: separate` so cell + borders stay on the cell — required for the sticky `` divider to + ride along with the scroll. `min-width: max-content` keeps wide tables from being squeezed (the wrapper takes the horizontal scroll instead); `width: 100%` makes small tables fill the wrapper so its border/rounded corners - don't gap. Outside the wrapper (e.g. BuilderMarkdown), small tables stay - at content width instead of stretching to full width. */ + don't gap. Margin is zeroed because the wrapper itself supplies `my-3`. + Outside the wrapper (e.g. BuilderMarkdown) the `.md-view table` defaults + above stay in force — no sticky header, classic collapse model. */ .md-table-wrap table { + border-collapse: separate; + border-spacing: 0; min-width: max-content; width: 100%; + margin: 0; } -.md-view th, -.md-view td { - @apply px-3 py-2 text-left align-top tabular-nums; +.md-table-wrap th, +.md-table-wrap td { + border: 0; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); - overflow-wrap: break-word; } -.md-view th:first-child, -.md-view td:first-child { +.md-table-wrap th:first-child, +.md-table-wrap td:first-child { border-left: 1px solid var(--border); } -.md-view th { - @apply font-semibold; - background: var(--bg-soft); -} /* `border-collapse: separate` keeps cell borders on the cell, so a sticky header keeps its divider while scrolling. We still render the bottom rule as an inset shadow (instead of `border-bottom`) so it's painted by the sticky cell itself — guaranteed visible regardless of how the row above resolves the shared edge. */ -.md-view thead th { +.md-table-wrap thead th { position: sticky; top: 0; z-index: 1; @@ -537,7 +547,7 @@ body { overflow: visible; max-height: none; } - .md-view thead th { + .md-table-wrap thead th { position: static; } } From 8eb28651ff050b2e3beb730ee48652866745c52f Mon Sep 17 00:00:00 2001 From: Sophie Neumann Date: Tue, 30 Jun 2026 07:41:35 +0200 Subject: [PATCH 4/5] fix(ui): make scrollability of table optional --- web-ui/app/_components/MarkdownTable.tsx | 26 +++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/web-ui/app/_components/MarkdownTable.tsx b/web-ui/app/_components/MarkdownTable.tsx index 4bfb67df..bb8b3067 100644 --- a/web-ui/app/_components/MarkdownTable.tsx +++ b/web-ui/app/_components/MarkdownTable.tsx @@ -1,6 +1,7 @@ 'use client'; import type { ReactNode } from 'react'; +import { useLayoutEffect, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; interface TableProps { @@ -15,18 +16,37 @@ interface TableProps { * compound-word cells, vertical scroll with a sticky `` for many * rows. The wrapper is keyboard-focusable so arrow-key scrolling works * for keyboard-only users. + * + * The scroll-region affordance (focusable + labelled group) is only applied + * when the wrapper actually overflows, so a table that fits its container + * doesn't consume a tab stop or announce a redundant group (WCAG 2.1.1). */ export function MarkdownTable({ children, className, }: TableProps): React.ReactElement { const t = useTranslations('markdownTable'); + const ref = useRef(null); + const [overflows, setOverflows] = useState(false); + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const measure = (): void => + setOverflows( + el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight, + ); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, []); return (
cell
{children}
From 2c6fd11ae488b45041f774350901b5c32bf9e06b Mon Sep 17 00:00:00 2001 From: Sophie Neumann Date: Tue, 30 Jun 2026 08:04:01 +0200 Subject: [PATCH 5/5] fix(ui): update tests --- .../__tests__/MarkdownTable.test.tsx | 39 +++++++++++++++++-- web-ui/vitest.setup.ts | 10 +++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/web-ui/app/_components/__tests__/MarkdownTable.test.tsx b/web-ui/app/_components/__tests__/MarkdownTable.test.tsx index f3b2d994..0ec9ac93 100644 --- a/web-ui/app/_components/__tests__/MarkdownTable.test.tsx +++ b/web-ui/app/_components/__tests__/MarkdownTable.test.tsx @@ -25,7 +25,9 @@ describe('', () => { expect(wrap?.querySelector('table')).not.toBeNull(); }); - it('exposes the scroll container as a focusable group with an i18n aria-label', () => { + it('omits the scroll-region affordance when the table fits its container', () => { + // jsdom reports 0 for all scroll/client dims, so the wrapper never + // overflows: no tab stop, no group role, no aria-label (WCAG 2.1.1). const { container } = renderWithIntl( @@ -36,9 +38,38 @@ describe('', () => { , ); const wrap = container.querySelector('div.md-table-wrap'); - expect(wrap?.getAttribute('role')).toBe('group'); - expect(wrap?.getAttribute('tabindex')).toBe('0'); - expect(wrap?.getAttribute('aria-label')).toBe('Scrollable table'); + expect(wrap?.getAttribute('role')).toBeNull(); + expect(wrap?.getAttribute('tabindex')).toBeNull(); + expect(wrap?.getAttribute('aria-label')).toBeNull(); + }); + + it('exposes a focusable group with an i18n aria-label when the table overflows', () => { + // Force horizontal overflow: scrollWidth > clientWidth at mount. + const proto = window.HTMLElement.prototype; + const sw = Object.getOwnPropertyDescriptor(proto, 'scrollWidth'); + const cw = Object.getOwnPropertyDescriptor(proto, 'clientWidth'); + Object.defineProperty(proto, 'scrollWidth', { configurable: true, value: 200 }); + Object.defineProperty(proto, 'clientWidth', { configurable: true, value: 100 }); + try { + const { container } = renderWithIntl( + + + + cell + + + , + ); + const wrap = container.querySelector('div.md-table-wrap'); + expect(wrap?.getAttribute('role')).toBe('group'); + expect(wrap?.getAttribute('tabindex')).toBe('0'); + expect(wrap?.getAttribute('aria-label')).toBe('Scrollable table'); + } finally { + if (sw) Object.defineProperty(proto, 'scrollWidth', sw); + else delete (proto as { scrollWidth?: unknown }).scrollWidth; + if (cw) Object.defineProperty(proto, 'clientWidth', cw); + else delete (proto as { clientWidth?: unknown }).clientWidth; + } }); it('forwards className to the inner ', () => { diff --git a/web-ui/vitest.setup.ts b/web-ui/vitest.setup.ts index bb02c60c..ce20bddd 100644 --- a/web-ui/vitest.setup.ts +++ b/web-ui/vitest.setup.ts @@ -1 +1,11 @@ import '@testing-library/jest-dom/vitest'; + +// jsdom has no ResizeObserver; stub it so components that observe element +// size (e.g. MarkdownTable's overflow detection) can mount in tests. +if (!('ResizeObserver' in globalThis)) { + globalThis.ResizeObserver = class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }; +}