diff --git a/web-ui/app/_components/Markdown.tsx b/web-ui/app/_components/Markdown.tsx index daa18802..f2a96fc2 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 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'] = + { + 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..bb8b3067 --- /dev/null +++ b/web-ui/app/_components/MarkdownTable.tsx @@ -0,0 +1,54 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { useLayoutEffect, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; + +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 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. + * + * 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 ( +
+
{children}
+ + ); +} 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/_components/__tests__/MarkdownTable.test.tsx b/web-ui/app/_components/__tests__/MarkdownTable.test.tsx new file mode 100644 index 00000000..0ec9ac93 --- /dev/null +++ b/web-ui/app/_components/__tests__/MarkdownTable.test.tsx @@ -0,0 +1,88 @@ +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('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( + + + + + + + , + ); + const wrap = container.querySelector('div.md-table-wrap'); + 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( + + + + + + + , + ); + 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
cell
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 dad3b5b0..6401c157 100644 --- a/web-ui/app/globals.css +++ b/web-ui/app/globals.css @@ -480,18 +480,77 @@ 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-view table { - @apply my-3 w-full border-collapse text-xs; + @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 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. 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-table-wrap th, +.md-table-wrap td { + border: 0; + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} +.md-table-wrap th:first-child, +.md-table-wrap td:first-child { + border-left: 1px solid var(--border); +} +/* `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-table-wrap 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 { + overflow: visible; + max-height: none; + } + .md-table-wrap 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 ac60ee99..e735917a 100644 --- a/web-ui/messages/de.json +++ b/web-ui/messages/de.json @@ -578,6 +578,9 @@ "outputLabel": "Output", "subIterations": "{count} interne Iterationen" }, + "markdownTable": { + "scrollRegionLabel": "Scrollbare Tabelle" + }, "nudgeCard": { "kicker": "Vorschlag", "ackTriggered": "✓ ausgelöst", diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json index 78373813..a71e324c 100644 --- a/web-ui/messages/en.json +++ b/web-ui/messages/en.json @@ -578,6 +578,9 @@ "outputLabel": "Output", "subIterations": "{count} inner iterations" }, + "markdownTable": { + "scrollRegionLabel": "Scrollable table" + }, "nudgeCard": { "kicker": "Suggestion", "ackTriggered": "✓ triggered", 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 {} + }; +}
cell