diff --git a/ui/src/__tests__/components/a11y/ux-a11y.test.tsx b/ui/src/__tests__/components/a11y/ux-a11y.test.tsx new file mode 100644 index 00000000..a4b75ccc --- /dev/null +++ b/ui/src/__tests__/components/a11y/ux-a11y.test.tsx @@ -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(); + 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(BUILDER} textarea={
JSON
} />); + } + + 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'); + }); +}); diff --git a/ui/src/app/chat/[id]/page.tsx b/ui/src/app/chat/[id]/page.tsx index 4417103a..f9a4802a 100644 --- a/ui/src/app/chat/[id]/page.tsx +++ b/ui/src/app/chat/[id]/page.tsx @@ -185,12 +185,16 @@ function ChatDetailInner({ id }: { id: string }) { }, [conversation.data]); return ( -
+
← Chats - {streaming && Streaming…} + {streaming && ( + + Streaming… + + )}
{!warningDismissed && ( @@ -240,7 +244,7 @@ function ChatDetailInner({ id }: { id: string }) { )} -
+ ); } diff --git a/ui/src/app/chat/page.tsx b/ui/src/app/chat/page.tsx index bca4b4b1..9976be0e 100644 --- a/ui/src/app/chat/page.tsx +++ b/ui/src/app/chat/page.tsx @@ -31,7 +31,7 @@ function ChatPageInner() { }; return ( -
+

Chat

+
); } diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx index 8a28e18a..eb724229 100644 --- a/ui/src/app/layout.tsx +++ b/ui/src/app/layout.tsx @@ -28,8 +28,19 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + {/* Skip link: first focusable element, visually hidden until + focused, so keyboard/switch users can jump past the 8-item + nav straight to page content. */} + + Skip to content + - {children} +
+ {children} +
diff --git a/ui/src/components/chat/message-stream.tsx b/ui/src/components/chat/message-stream.tsx index 3c022ef0..1c2110df 100644 --- a/ui/src/components/chat/message-stream.tsx +++ b/ui/src/components/chat/message-stream.tsx @@ -35,7 +35,16 @@ export function MessageStream({ messages }: MessageStreamProps) { }, [messages]); return ( -
+ // 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. +
{messages.map((m) => ( ))} diff --git a/ui/src/components/common/data-table-sort-header.tsx b/ui/src/components/common/data-table-sort-header.tsx index 5f3e7986..ace62222 100644 --- a/ui/src/components/common/data-table-sort-header.tsx +++ b/ui/src/components/common/data-table-sort-header.tsx @@ -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 `` / + * columnheader element (that's the only element where `aria-sort` is honored — + * not an inner span). Exported so `` can set it on ``. + */ +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. * @@ -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 (columnheader) in , 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' : ''; @@ -153,7 +169,7 @@ export function DataTableSortHeader({ }; return ( - +