Skip to content
Merged
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
59 changes: 59 additions & 0 deletions ui/src/__tests__/components/a11y/ux-a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: 2026 soundminds.ai
//
// SPDX-License-Identifier: Apache-2.0

import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import { ariaSortForColumn } from '@/components/common/data-table-sort-header';
import { MessageStream } from '@/components/chat/message-stream';
import { ResponsiveLayout } from '@/components/studies/search-space-builder/responsive-layout';

describe('chat stream is a live region', () => {
it('MessageStream root is role=log aria-live=polite so streaming is announced', () => {
render(<MessageStream messages={[]} />);
const region = screen.getByTestId('message-stream');
expect(region).toHaveAttribute('role', 'log');
expect(region).toHaveAttribute('aria-live', 'polite');
});
});

describe('search-space builder tab widget ARIA', () => {
function renderTabs() {
return render(<ResponsiveLayout builder={<div>BUILDER</div>} textarea={<div>JSON</div>} />);
}

it('tabs reference their panels and panels reference their tabs', () => {
renderTabs();
const builderTab = screen.getByTestId('cs-builder-tab-builder');
expect(builderTab).toHaveAttribute('role', 'tab');
expect(builderTab).toHaveAttribute('aria-controls', 'cs-builder-panel-builder');

const builderPanel = screen.getByTestId('cs-builder-slot-builder');
expect(builderPanel).toHaveAttribute('role', 'tabpanel');
expect(builderPanel).toHaveAttribute('aria-labelledby', 'cs-builder-tab-builder');
});

it('ArrowRight moves the active tab AND focus (roving tabindex)', () => {
renderTabs();
const builderTab = screen.getByTestId('cs-builder-tab-builder');
const jsonTab = screen.getByTestId('cs-builder-tab-json');
expect(builderTab).toHaveAttribute('aria-selected', 'true');
fireEvent.keyDown(builderTab, { key: 'ArrowRight' });
expect(jsonTab).toHaveAttribute('aria-selected', 'true');
expect(builderTab).toHaveAttribute('aria-selected', 'false');
// Focus must follow selection so keyboard flow isn't stranded on the now
// tabIndex=-1 button (Gemini review).
expect(jsonTab).toHaveFocus();
expect(jsonTab).toHaveAttribute('tabindex', '0');
});
});

describe('ariaSortForColumn', () => {
it('maps active direction to the aria-sort token for the matching column', () => {
expect(ariaSortForColumn('name:asc', 'name')).toBe('ascending');
expect(ariaSortForColumn('name:desc', 'name')).toBe('descending');
expect(ariaSortForColumn('other:asc', 'name')).toBe('none');
expect(ariaSortForColumn(null, 'name')).toBe('none');
});
});
10 changes: 7 additions & 3 deletions ui/src/app/chat/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,16 @@ function ChatDetailInner({ id }: { id: string }) {
}, [conversation.data]);

return (
<div className="mx-auto flex max-w-3xl flex-col gap-4 p-6">
<main className="mx-auto flex max-w-3xl flex-col gap-4 p-6">
<div className="flex items-center justify-between text-sm">
<Link href="/chat" className="text-blue-600 hover:underline">
← Chats
</Link>
{streaming && <span className="text-xs text-muted-foreground">Streaming…</span>}
{streaming && (
<span className="text-xs text-muted-foreground" role="status" aria-live="polite">
Streaming…
</span>
)}
</div>

{!warningDismissed && (
Expand Down Expand Up @@ -240,7 +244,7 @@ function ChatDetailInner({ id }: { id: string }) {
<ExamplePrompts onSend={handleSend} disabled={streaming} />
)}
<Composer onSend={handleSend} streaming={streaming} />
</div>
</main>
);
}

Expand Down
4 changes: 2 additions & 2 deletions ui/src/app/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function ChatPageInner() {
};

return (
<div className="mx-auto max-w-3xl space-y-4 p-6">
<main className="mx-auto max-w-3xl space-y-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Chat</h1>
<Button onClick={handleNew} disabled={createMut.isPending} data-testid="new-conversation">
Expand Down Expand Up @@ -86,7 +86,7 @@ function ChatPageInner() {
)}
</CardContent>
</Card>
</div>
</main>
);
}

Expand Down
13 changes: 12 additions & 1 deletion ui/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,19 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<ThemeProvider>
<QueryProvider>
<TooltipProvider delayDuration={700}>
{/* Skip link: first focusable element, visually hidden until
focused, so keyboard/switch users can jump past the 8-item
nav straight to page content. */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-background focus:px-4 focus:py-2 focus:shadow focus:ring-2 focus:ring-ring"
>
Skip to content
</a>
<TopNav />
{children}
<div id="main-content" tabIndex={-1} className="outline-none">
{children}
</div>
<Toaster />
<GuideTrigger />
</TooltipProvider>
Expand Down
11 changes: 10 additions & 1 deletion ui/src/components/chat/message-stream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,16 @@ export function MessageStream({ messages }: MessageStreamProps) {
}, [messages]);

return (
<div className="flex flex-col gap-3" data-testid="message-stream">
// role="log" + aria-live announces streaming assistant tokens, tool-call
// cards, and tool results to screen readers as they arrive, instead of the
// response being silent until the user re-navigates the list.
<div
className="flex flex-col gap-3"
data-testid="message-stream"
role="log"
aria-live="polite"
aria-relevant="additions text"
>
Comment on lines +41 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Making the entire MessageStream container a live region (role="log" aria-live="polite") when messages are updated token-by-token can result in a poor screen reader experience. Screen readers may attempt to announce every single token/word fragment as it arrives, leading to constant interruptions and stuttering.

Recommendation:
Instead of making the active stream container live, consider using a visually hidden live region (sr-only with aria-live="polite") that only announces:

  1. When the assistant starts generating (e.g., "Assistant is typing...").
  2. The final completed message once streaming finishes.
  3. Any tool calls or major state transitions (like "Running tool...") rather than raw token updates.

{messages.map((m) => (
<MessageRow key={m.id} message={m} />
))}
Expand Down
20 changes: 18 additions & 2 deletions ui/src/components/common/data-table-sort-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,20 @@ function currentDir(activeSort: string | null, sortKey: string, codec?: SortCode
return defaultCurrentDir(activeSort, sortKey);
}

/**
* The `aria-sort` token for a column, to be placed on its `<th>` /
* columnheader element (that's the only element where `aria-sort` is honored —
* not an inner span). Exported so `<DataTable>` can set it on `<TableHead>`.
*/
export function ariaSortForColumn(
activeSort: string | null,
sortKey: string,
codec?: SortCodec,
): 'ascending' | 'descending' | 'none' {
const dir = currentDir(activeSort, sortKey, codec);
return dir === 'asc' ? 'ascending' : dir === 'desc' ? 'descending' : 'none';
}

/**
* Three-state cycle, with `sortDirections` constraint.
*
Expand Down Expand Up @@ -132,7 +146,9 @@ export function DataTableSortHeader({
}: DataTableSortHeaderProps) {
const dir = currentDir(activeSort, sortKey, sortCodec);
const buttonId = useId();
const ariaSort = dir === 'asc' ? 'ascending' : dir === 'desc' ? 'descending' : 'none';
// aria-sort now lives on the <TableHead> (columnheader) in <DataTable>, where
// it's actually honored; the sr-only "Sorted …" text below conveys state on
// focus regardless.
const Chevron = dir === 'asc' ? ChevronUp : dir === 'desc' ? ChevronDown : ChevronsUpDown;
const chevronClass = dir === null ? 'opacity-40' : '';

Expand All @@ -153,7 +169,7 @@ export function DataTableSortHeader({
};

return (
<span aria-sort={ariaSort} className="inline-flex items-center gap-1">
<span className="inline-flex items-center gap-1">
<button
id={buttonId}
type="button"
Expand Down
12 changes: 10 additions & 2 deletions ui/src/components/common/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import { DataTableEmpty } from './data-table-empty';
import { DataTableFilterChips } from './data-table-filter-chips';
import { DataTableFkSelect } from './data-table-fk-select';
import { DataTableSearch } from './data-table-search';
import { DataTableSortHeader } from './data-table-sort-header';
import { ariaSortForColumn, DataTableSortHeader } from './data-table-sort-header';
import { DataTableToolbar } from './data-table-toolbar';
import { DataTableTotalCount } from './data-table-total-count';
import type { DataTableColumnDef, DataTableProps } from './types';
Expand Down Expand Up @@ -476,7 +476,15 @@ export function DataTable<T extends { id: string }>(props: DataTableProps<T>) {
) : null;
if (colDef.sortable && onSortChange) {
return (
<TableHead key={header.id} className={cellPaddingClass}>
<TableHead
key={header.id}
className={cellPaddingClass}
aria-sort={ariaSortForColumn(
sort,
colDef.sortKey ?? colDef.id,
sortCodec,
)}
>
<DataTableSortHeader
label={rawHeader}
sortKey={colDef.sortKey ?? colDef.id}
Expand Down
11 changes: 9 additions & 2 deletions ui/src/components/dashboard/reset-demo-state-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function ScenarioStateIcon({ state }: { state: ScenarioState }) {
if (state === 'active')
return (
<Loader2
className="mt-0.5 size-4 shrink-0 animate-spin text-foreground"
className="mt-0.5 size-4 shrink-0 animate-spin text-foreground motion-reduce:animate-none"
aria-label="active"
/>
);
Expand Down Expand Up @@ -383,7 +383,14 @@ export function ResetDemoStateButton(): React.ReactElement {
)}
{isRunning && status && (
<AlertDialogDescription asChild>
<div className="space-y-2" data-testid="reset-demo-state-progress">
<div
className="space-y-2"
data-testid="reset-demo-state-progress"
aria-live="polite"
>
{/* aria-live so screen readers hear each 2s-polled scenario
state flip + live step during the multi-minute reseed,
not just the initial dialog description. */}
{status.scenarios && status.scenarios.length > 0 ? (
<>
<div className="text-xs text-muted-foreground">
Expand Down
11 changes: 10 additions & 1 deletion ui/src/components/query-sets/edit-metadata-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,24 @@ export function EditMetadataDialog({
id="metadata_json"
rows={12}
className="font-mono text-xs"
aria-invalid={jsonError ? true : undefined}
aria-describedby={
jsonError ? 'metadata-json-error metadata-helper' : 'metadata-helper'
}
{...form.register('metadata_json')}
data-testid="edit-metadata-textarea"
/>
<p className="text-xs text-muted-foreground" data-testid="metadata-helper">
<p
id="metadata-helper"
className="text-xs text-muted-foreground"
data-testid="metadata-helper"
>
JSON object only — arrays, strings, numbers, and <code>null</code> literal are
rejected. Use <strong>Clear metadata</strong> to set the column to NULL.
</p>
{jsonError && (
<p
id="metadata-json-error"
className="text-xs text-destructive"
role="alert"
data-testid="metadata-json-error"
Expand Down
4 changes: 3 additions & 1 deletion ui/src/components/query-sets/edit-query-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ export function EditQueryPopover({ querySetId, query, trigger }: EditQueryPopove
<Textarea
id="query_text"
rows={3}
aria-invalid={form.formState.errors.query_text ? true : undefined}
aria-describedby={form.formState.errors.query_text ? 'query_text-error' : undefined}
{...form.register('query_text', {
required: 'Query text is required',
minLength: { value: 1, message: 'Query text must be at least 1 character' },
Expand All @@ -98,7 +100,7 @@ export function EditQueryPopover({ querySetId, query, trigger }: EditQueryPopove
data-testid="edit-query-text"
/>
{form.formState.errors.query_text && (
<p className="text-xs text-destructive" role="alert">
<p id="query_text-error" className="text-xs text-destructive" role="alert">
{form.formState.errors.query_text.message}
</p>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,40 @@ export interface ResponsiveLayoutProps {

export function ResponsiveLayout({ builder, textarea }: ResponsiveLayoutProps): React.ReactElement {
const [activeTab, setActiveTab] = React.useState<'builder' | 'json'>('builder');
const builderTabRef = React.useRef<HTMLButtonElement>(null);
const jsonTabRef = React.useRef<HTMLButtonElement>(null);

// ArrowLeft/ArrowRight move between the two tabs per the ARIA tablist pattern.
// Roving tabindex requires FOCUS to follow selection, not just state — else
// focus is stranded on the now-tabIndex=-1 button and keyboard flow breaks.
const onTabKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
const next = activeTab === 'builder' ? 'json' : 'builder';
setActiveTab(next);
(next === 'builder' ? builderTabRef : jsonTabRef).current?.focus();
}
};
Comment on lines +36 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In a roving tabindex tablist pattern, when the active tab changes via keyboard navigation (ArrowLeft/ArrowRight), focus must be programmatically moved to the newly activated tab button. Currently, only the state is updated, which leaves the focus on the old button (now with tabIndex={-1}), breaking keyboard navigation flow and screen reader announcements.

  const onTabKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
    if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
      e.preventDefault();
      setActiveTab(activeTab === 'builder' ? 'json' : 'builder');
      const target = e.currentTarget;
      const parent = target.parentElement;
      if (parent) {
        const buttons = Array.from(parent.querySelectorAll('[role="tab"]')) as HTMLButtonElement[];
        const nextButton = buttons.find((btn) => btn !== target);
        nextButton?.focus();
      }
    }
  };


return (
<div className="space-y-3">
{/* Tab toggle: only visible <1024px (hidden at lg: breakpoint). */}
<div
className="lg:hidden flex gap-2 border-b border-border"
role="tablist"
aria-label="Search-space editor view"
data-testid="cs-builder-tab-toggle"
>
<button
ref={builderTabRef}
type="button"
role="tab"
id="cs-builder-tab-builder"
aria-selected={activeTab === 'builder'}
aria-controls="cs-builder-panel-builder"
tabIndex={activeTab === 'builder' ? 0 : -1}
onClick={() => setActiveTab('builder')}
onKeyDown={onTabKeyDown}
data-testid="cs-builder-tab-builder"
className={
activeTab === 'builder'
Expand All @@ -51,10 +71,15 @@ export function ResponsiveLayout({ builder, textarea }: ResponsiveLayoutProps):
Builder
</button>
<button
ref={jsonTabRef}
type="button"
role="tab"
id="cs-builder-tab-json"
aria-selected={activeTab === 'json'}
aria-controls="cs-builder-panel-json"
tabIndex={activeTab === 'json' ? 0 : -1}
onClick={() => setActiveTab('json')}
onKeyDown={onTabKeyDown}
data-testid="cs-builder-tab-json"
className={
activeTab === 'json'
Expand All @@ -71,12 +96,18 @@ export function ResponsiveLayout({ builder, textarea }: ResponsiveLayoutProps):
RHF register binding + existing test selectors. */}
<div className="grid gap-4 lg:grid-cols-2">
<div
role="tabpanel"
id="cs-builder-panel-builder"
aria-labelledby="cs-builder-tab-builder"
className={activeTab === 'json' ? 'hidden lg:block' : 'lg:block'}
data-testid="cs-builder-slot-builder"
>
{builder}
</div>
<div
role="tabpanel"
id="cs-builder-panel-json"
aria-labelledby="cs-builder-tab-json"
className={activeTab === 'builder' ? 'hidden lg:block' : 'lg:block'}
data-testid="cs-builder-slot-json"
>
Expand Down
Loading