Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion web-ui/app/_components/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -117,9 +118,26 @@ export function Markdown({
const displayed = useMemo(() => stripCitationMarkers(source), [source]);
return (
<div className="md-view">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={rehypePlugins}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={rehypePlugins}
components={MARKDOWN_COMPONENTS}
>
{displayed}
</ReactMarkdown>
</div>
);
}

/**
* 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<typeof ReactMarkdown>['components'] =
{
table: ({ children, className }) => (
<MarkdownTable className={className}>{children}</MarkdownTable>
),
};
54 changes: 54 additions & 0 deletions web-ui/app/_components/MarkdownTable.tsx
Original file line number Diff line number Diff line change
@@ -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 `<table>` 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 `<thead>` 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<HTMLDivElement>(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 (
<div
ref={ref}
className="md-table-wrap"
tabIndex={overflows ? 0 : undefined}
role={overflows ? 'group' : undefined}
aria-label={overflows ? t('scrollRegionLabel') : undefined}
>
<table className={className}>{children}</table>
</div>
);
}
10 changes: 5 additions & 5 deletions web-ui/app/_components/__tests__/Markdown.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -10,7 +10,7 @@ import { Markdown } from '../Markdown';
*/
describe('<Markdown /> — Privacy Shield v4 highlight', () => {
it('wraps a highlightTerm occurrence in a violet span', () => {
const { container } = render(
const { container } = renderWithIntl(
<Markdown
source={'| Name | Days |\n| --- | --- |\n| Marvin Vomberg | 24 |'}
highlightTerms={['Marvin Vomberg']}
Expand All @@ -22,15 +22,15 @@ describe('<Markdown /> — Privacy Shield v4 highlight', () => {
});

it('does not highlight anything when no terms are given', () => {
const { container } = render(
const { container } = renderWithIntl(
<Markdown source={'Marvin Vomberg steht hier.'} />,
);
expect(container.querySelector('span[class*="bg-[color:var(--accent)]/10"]')).toBeNull();
expect(container.textContent).toContain('Marvin Vomberg steht hier.');
});

it('highlights every occurrence and leaves surrounding text intact', () => {
const { container } = render(
const { container } = renderWithIntl(
<Markdown
source={'Anna Rüsche und Anna Rüsche, aber nicht Bob.'}
highlightTerms={['Anna Rüsche']}
Expand All @@ -41,7 +41,7 @@ describe('<Markdown /> — Privacy Shield v4 highlight', () => {
});

it('ignores empty / whitespace-only terms', () => {
const { container } = render(
const { container } = renderWithIntl(
<Markdown source={'Some answer text.'} highlightTerms={['', ' ']} />,
);
expect(container.querySelector('span[class*="bg-[color:var(--accent)]/10"]')).toBeNull();
Expand Down
88 changes: 88 additions & 0 deletions web-ui/app/_components/__tests__/MarkdownTable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';

import { renderWithIntl } from '../../_lib/test-utils';
import { MarkdownTable } from '../MarkdownTable';

/**
* MarkdownTable wraps the GFM `<table>` 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('<MarkdownTable />', () => {
it('wraps children in a .md-table-wrap div with the inner table', () => {
const { container } = renderWithIntl(
<MarkdownTable>
<tbody>
<tr>
<td>cell</td>
</tr>
</tbody>
</MarkdownTable>,
);
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(
<MarkdownTable>
<tbody>
<tr>
<td>cell</td>
</tr>
</tbody>
</MarkdownTable>,
);
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(
<MarkdownTable>
<tbody>
<tr>
<td>cell</td>
</tr>
</tbody>
</MarkdownTable>,
);
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 <table>', () => {
const { container } = renderWithIntl(
<MarkdownTable className="custom-class">
<tbody>
<tr>
<td>cell</td>
</tr>
</tbody>
</MarkdownTable>,
);
const table = container.querySelector('table');
expect(table?.className).toBe('custom-class');
});
});
61 changes: 60 additions & 1 deletion web-ui/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
ConnysCode marked this conversation as resolved.
@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 `<thead>` 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;
Comment thread
ConnysCode marked this conversation as resolved.
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 {
Comment thread
ConnysCode marked this conversation as resolved.
.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);
Expand Down
3 changes: 3 additions & 0 deletions web-ui/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,9 @@
"outputLabel": "Output",
"subIterations": "{count} interne Iterationen"
},
"markdownTable": {
"scrollRegionLabel": "Scrollbare Tabelle"
},
"nudgeCard": {
"kicker": "Vorschlag",
"ackTriggered": "✓ ausgelöst",
Expand Down
3 changes: 3 additions & 0 deletions web-ui/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,9 @@
"outputLabel": "Output",
"subIterations": "{count} inner iterations"
},
"markdownTable": {
"scrollRegionLabel": "Scrollable table"
},
"nudgeCard": {
"kicker": "Suggestion",
"ackTriggered": "✓ triggered",
Expand Down
10 changes: 10 additions & 0 deletions web-ui/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -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 {}
};
}
Loading