authorgravatar for blindingpixels@protonmail.comblinding-pixels <blindingpixels@protonmail.com> 2026-09-27 01:03:04+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-26 15:33:04-04:00
log393d595984f66df08504e1659997e12ad551bce9
tree7d269df8196ecd7e9b6513585d316ad8b7efdea8
parent75d63d64c554a425adc485f1205ef03ec50f8564
signature Signed by PGP key B5690EEEBB952194

fix(accessibility): correct control announcements and sidebar traversal (#13491)

Co-authored-by: eshaan Sood <eshaansood@192.168.1.14> Co-authored-by: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

9 files changed, 219 insertions(+), 24 deletions(-)

apps/mobile/src/features/connection/ConnectionFormField.test.tsx created+42
......@@ -0,0 +1,42 @@
1import type { ReactNode } from "react";
2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it, vi } from "vite-plus/test";
4
5vi.mock("react-native", () => ({
6 View: ({ children }: { children: ReactNode }) => <div>{children}</div>,
7}));
8vi.mock("../../components/AppText", () => ({
9 AppText: ({
10 accessibilityElementsHidden,
11 importantForAccessibility,
12 children,
13 }: {
14 accessibilityElementsHidden?: boolean;
15 importantForAccessibility?: string;
16 children: ReactNode;
17 }) => (
18 <span
19 aria-hidden={
20 accessibilityElementsHidden || importantForAccessibility === "no-hide-descendants"
21 }
22 >
23 {children}
24 </span>
25 ),
26 AppTextInput: ({ accessibilityLabel }: { accessibilityLabel?: string }) => (
27 <input aria-label={accessibilityLabel} />
28 ),
29}));
30
31import { ConnectionFormField } from "./ConnectionFormField";
32
33describe("ConnectionFormField accessibility", () => {
34 it.each(["Host", "Pairing code"])("exposes %s once as the input name", (label) => {
35 const markup = renderToStaticMarkup(
36 <ConnectionFormField label={label} placeholder="Not an accessible name" />,
37 );
38
39 expect(markup).toContain(`<span aria-hidden="true">${label}</span>`);
40 expect(markup).toContain(`<input aria-label="${label}"/>`);
41 });
42});
apps/mobile/src/features/connection/ConnectionFormField.tsx+7-3
......@@ -3,7 +3,7 @@ import { View } from "react-native";
33import { AppText, AppTextInput, type AppTextInputProps } from "../../components/AppText";
44import { cn } from "../../lib/cn";
55
6type ConnectionFormFieldProps = Omit<AppTextInputProps, "className"> & {
6type ConnectionFormFieldProps = Omit<AppTextInputProps, "accessibilityLabel" | "className"> & {
77 readonly label: string;
88 readonly className?: string;
99};
......@@ -12,12 +12,16 @@ type ConnectionFormFieldProps = Omit<AppTextInputProps, "className"> & {
1212export function ConnectionFormField({ label, className, ...inputProps }: ConnectionFormFieldProps) {
1313 return (
1414 <View collapsable={false} className={cn("gap-1.5", className)}>
15 <AppText className="text-2xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted">
15 <AppText
16 accessibilityElementsHidden
17 importantForAccessibility="no-hide-descendants"
18 className="text-2xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted"
19 >
1620 {label}
1721 </AppText>
1822 <AppTextInput
19 accessibilityLabel={label}
2023 {...inputProps}
24 accessibilityLabel={label}
2125 className="rounded-[14px] px-4 py-3.5"
2226 />
2327 </View>
apps/web/src/components/AppSidebarLayout.tsx+2
......@@ -304,6 +304,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
304304 side="left"
305305 collapsible="offcanvas"
306306 data-app-sidebar=""
307 role="navigation"
308 aria-label={isOnSettings ? "Settings" : "Threads"}
307309 resizable={{
308310 maxWidth: sidebarMaximumWidth,
309311 minWidth: THREAD_SIDEBAR_MIN_WIDTH,
apps/web/src/components/Sidebar.logic.test.ts+30
......@@ -23,6 +23,7 @@ import {
2323 isTrailingDoubleClick,
2424 orderItemsByPreferredIds,
2525 resolveProjectStatusIndicator,
26 resolveSidebarRowAccessibility,
2627 resolveSidebarThreadStatus,
2728 resolveThreadStatusPill,
2829 resolveWorkingStartedAt,
......@@ -69,6 +70,35 @@ import {
6970
7071const localEnvironmentId = EnvironmentId.make("environment-local");
7172
73describe("resolveSidebarRowAccessibility", () => {
74 it.each([
75 {
76 title: "Can you audit the UI?",
77 statusLabel: "Working",
78 projectDisplayName: "T3 Code",
79 isActive: true,
80 expected: { label: "Can you audit the UI?, Working, T3 Code", current: "page" },
81 },
82 {
83 title: "The audit is done",
84 statusLabel: null,
85 projectDisplayName: "T3 Code",
86 isActive: false,
87 expected: { label: "The audit is done, T3 Code", current: undefined },
88 },
89 {
90 title: "Untitled task",
91 statusLabel: null,
92 projectDisplayName: null,
93 isActive: false,
94 expected: { label: "Untitled task", current: undefined },
95 },
96 ])("leads with the title without folding row actions into its name: %j", (input) => {
97 const { expected, ...state } = input;
98 expect(resolveSidebarRowAccessibility(state)).toEqual(expected);
99 });
100});
101
72102describe("animateSidebarLayoutChanges", () => {
73103 const baseArgs: Parameters<AnimateLayoutChanges>[0] = {
74104 active: null,
apps/web/src/components/Sidebar.logic.ts+15
......@@ -42,6 +42,21 @@ export function shouldNavigateAfterThreadPark(input: {
4242
4343const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
4444export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200;
45
46export function resolveSidebarRowAccessibility(input: {
47 readonly title: string;
48 readonly statusLabel: string | null;
49 readonly projectDisplayName: string | null;
50 readonly isActive: boolean;
51}): { readonly label: string; readonly current: "page" | undefined } {
52 return {
53 // The title is the row's identity and must lead when users scan tasks.
54 // Only static context belongs here; nested action labels remain separate controls.
55 label: [input.title, input.statusLabel, input.projectDisplayName].filter(Boolean).join(", "),
56 current: input.isActive ? "page" : undefined,
57 };
58}
59
4560// Visible sidebar rows are prewarmed into the thread-detail cache so opening a
4661// nearby thread usually reuses an already-hot subscription. Each prewarmed
4762// thread holds a live, fully hydrated detail subscription (all messages and
apps/web/src/components/Sidebar.tsx+42-9
......@@ -173,6 +173,7 @@ import {
173173 resolveAdjacentThreadId,
174174 resolveSidebarDropTarget,
175175 resolveSidebarDropVerb,
176 resolveSidebarRowAccessibility,
176177 type SidebarDropVerb,
177178 resolveSidebarThreadStatus,
178179 searchSidebarThreads,
......@@ -728,6 +729,12 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
728729 promptPreview.length > 0
729730 ? promptPreview
730731 : `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`;
732 const accessibility = resolveSidebarRowAccessibility({
733 title: preview,
734 statusLabel: "Unsent draft",
735 projectDisplayName: props.projectDisplayName,
736 isActive: props.isActive,
737 });
731738 const handleActivate = useCallback(() => onNavigate(draftId), [draftId, onNavigate]);
732739 const handleKeyDown = useCallback(
733740 (event: ReactKeyboardEvent) => {
......@@ -755,6 +762,8 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
755762 <div
756763 role="button"
757764 tabIndex={0}
765 aria-label={accessibility.label}
766 aria-current={accessibility.current}
758767 data-testid="sidebar-draft-row"
759768 className={cn(
760769 "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left text-sidebar-foreground outline-none select-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
......@@ -763,6 +772,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
763772 onClick={handleActivate}
764773 onKeyDown={handleKeyDown}
765774 >
775 <span className="sr-only">{preview}</span>
766776 <div className="relative z-10 h-[4.875rem] px-(--sidebar-row-content-inset) py-(--sidebar-content-inset)">
767777 <div className="flex h-5 min-w-0 items-center gap-1.5">
768778 <SquarePenIcon aria-hidden className={draftPenClassName} />
......@@ -790,7 +800,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
790800 </Tooltip>
791801 </span>
792802 </div>
793 <div className="mt-0.5 truncate text-sm font-medium text-foreground/90">{preview}</div>
803 <div aria-hidden className="mt-0.5 truncate text-sm font-medium text-foreground/90">
804 {preview}
805 </div>
794806 </div>
795807 </div>
796808 </li>
......@@ -1447,6 +1459,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
14471459 </span>
14481460 ) : null;
14491461
1462 const accessibility = resolveSidebarRowAccessibility({
1463 title: thread.title,
1464 statusLabel: topStatus?.label ?? null,
1465 projectDisplayName: props.projectDisplayName,
1466 isActive: props.isActive,
1467 });
1468
14501469 const title = isRenaming ? (
14511470 <input
14521471 autoFocus
......@@ -1462,6 +1481,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
14621481 />
14631482 ) : (
14641483 <span
1484 aria-hidden
14651485 className={cn(
14661486 "min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none",
14671487 shouldRecede ? "font-normal" : "font-medium",
......@@ -1492,6 +1512,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
14921512 {thread.title}
14931513 </span>
14941514 );
1515 const accessibleTitle = isRenaming ? null : <span className="sr-only">{thread.title}</span>;
14951516
14961517 // Stacks show their layer count; multiple unrelated links show their total count.
14971518 // Plain clicks open T3; individual PR links also support opening the host in a new tab.
......@@ -1593,6 +1614,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
15931614 ref={rowRef}
15941615 role="button"
15951616 tabIndex={0}
1617 aria-label={accessibility.label}
1618 aria-current={accessibility.current}
15961619 data-testid="sidebar-row-slim"
15971620 aria-busy={isRegeneratingTitle || undefined}
15981621 className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")}
......@@ -1603,6 +1626,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
16031626 />
16041627 }
16051628 >
1629 {accessibleTitle}
16061630 {/* Settled history recedes: dimmed favicon at rest, restored on
16071631 hover so the tail stays scannable when you're hunting. */}
16081632 <span
......@@ -1746,6 +1770,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
17461770 ref={rowRef}
17471771 role="button"
17481772 tabIndex={0}
1773 aria-label={accessibility.label}
1774 aria-current={accessibility.current}
17491775 data-testid="sidebar-row-card"
17501776 aria-busy={isRegeneratingTitle || undefined}
17511777 className={rowSurfaceClassName}
......@@ -1756,6 +1782,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
17561782 />
17571783 }
17581784 >
1785 {accessibleTitle}
17591786 <div className="relative z-10 h-[4.875rem] px-(--sidebar-row-content-inset) py-(--sidebar-content-inset)">
17601787 <div className="flex h-5 min-w-0 items-center gap-1.5">
17611788 {draftIndicator}
......@@ -2008,6 +2035,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
20082035 onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;
20092036}) {
20102037 const { thread } = props;
2038 const accessibility = resolveSidebarRowAccessibility({
2039 title: thread.title,
2040 statusLabel: null,
2041 projectDisplayName: props.projectDisplayName,
2042 isActive: props.isRouteActive,
2043 });
20112044 const threadRef = useMemo(
20122045 () => scopeThreadRef(thread.environmentId, thread.id),
20132046 [thread.environmentId, thread.id],
......@@ -2084,12 +2117,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
20842117 // which owns all keyboard interaction for the listbox.
20852118 tabIndex={-1}
20862119 aria-selected={props.isHighlighted}
2087 aria-current={props.isRouteActive ? "page" : undefined}
2088 aria-label={
2089 props.projectDisplayName
2090 ? `${thread.title}, ${props.projectDisplayName}`
2091 : thread.title
2092 }
2120 aria-current={accessibility.current}
2121 aria-label={accessibility.label}
20932122 onMouseMove={props.onHighlight}
20942123 onClick={props.onSelect}
20952124 className={cn(
......@@ -4573,7 +4602,7 @@ export default function Sidebar() {
45734602 </SidebarGroup>
45744603 }
45754604 >
4576 <SidebarGroup className="flex-1">
4605 <SidebarGroup className="flex-1" role="presentation">
45774606 {isSearchingThreads ? (
45784607 threadSearchResults.length > 0 ? (
45794608 <TooltipProvider
......@@ -4664,7 +4693,11 @@ export default function Sidebar() {
46644693 <SortableContext items={sortableIds} strategy={sidebarSortingStrategy}>
46654694 <ul
46664695 ref={attachListMotionRef}
4667 role="list"
4696 // VoiceOver treats an exposed list as an interaction boundary,
4697 // which hides its rows from ordinary linear navigation. A
4698 // presentational list also makes its implicit listitems
4699 // presentational while preserving every descendant control.
4700 role="presentation"
46684701 className={cn(
46694702 "relative flex flex-col gap-px",
46704703 sidebarListItems.length > 0 && "flex-1",
apps/web/src/components/ui/scroll-area.tsx+4
......@@ -30,6 +30,7 @@ function ScrollArea({
3030 hideScrollbars = false,
3131 chainVerticalScroll = false,
3232 radius = "inherit",
33 viewportTabIndex,
3334 ...props
3435}: ScrollAreaPrimitive.Root.Props & {
3536 scrollFade?: boolean;
......@@ -41,6 +42,8 @@ function ScrollArea({
4142 chainVerticalScroll?: boolean;
4243 /** The viewport clips to the parent's radius; "none" for a region flush to an edge. */
4344 radius?: "inherit" | "none";
45 /** Override Base UI's focusable viewport when focusable descendants provide scroll access. */
46 viewportTabIndex?: number;
4447}) {
4548 return (
4649 <ScrollAreaPrimitive.Root
......@@ -52,6 +55,7 @@ function ScrollArea({
5255 {...props}
5356 >
5457 <ScrollAreaPrimitive.Viewport
58 {...(viewportTabIndex === undefined ? {} : { tabIndex: viewportTabIndex })}
5559 className={cn(
5660 "h-full max-h-[inherit] overflow-auto overscroll-contain rounded-[inherit] outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain",
5761 chainVerticalScroll && "overscroll-y-auto",
apps/web/src/components/ui/sidebar.tsx+6-1
......@@ -233,7 +233,7 @@ function Sidebar({
233233 if (isMobile) {
234234 return (
235235 <SidebarInstanceContext value={instanceContextValue}>
236 <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>
236 <Sheet onOpenChange={setOpenMobile} open={openMobile}>
237237 <SheetPopup
238238 className={cn(
239239 "w-(--sidebar-width) max-w-none bg-sidebar surface-grain p-0 text-sidebar-foreground",
......@@ -259,6 +259,7 @@ function Sidebar({
259259 "flex h-full w-full flex-col pb-safe pt-safe",
260260 side === "left" ? "pl-safe" : "pr-safe",
261261 )}
262 {...props}
262263 >
263264 {children}
264265 </div>
......@@ -597,6 +598,10 @@ function SidebarContent({
597598 hideScrollbars
598599 scrollFade
599600 scrollFadePadding={false}
601 // Thread rows provide keyboard access to this scroll region. Keeping
602 // Base UI's viewport out of the tab order lets its presentational role
603 // flatten in WebKit instead of becoming a VoiceOver interaction group.
604 viewportTabIndex={-1}
600605 className="h-auto min-h-0 flex-1 [&>[data-slot=scroll-area-viewport]]:[--fade-size:0.75rem]"
601606 >
602607 <div
apps/web/src/components/ui/switch.test.tsx+71-11
......@@ -1,20 +1,80 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
1// @vitest-environment jsdom
2
3import { act, useState } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
36
47import { Switch } from "./switch";
58
6function renderSwitchRoot(props: Partial<Parameters<typeof Switch>[0]>) {
7 const html = renderToStaticMarkup(<Switch aria-label="Enable Claude Code" {...props} />);
8 return html.match(/<span[^>]*role="switch"[^>]*>/)?.[0] ?? "";
9let root: Root;
10let container: HTMLDivElement;
11
12beforeEach(() => {
13 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
14 container = document.createElement("div");
15 document.body.append(container);
16 root = createRoot(container);
17});
18
19afterEach(async () => {
20 await act(async () => root.unmount());
21 container.remove();
22 vi.unstubAllGlobals();
23});
24
25function ControlledSwitch({
26 initialChecked,
27 initialMixed = false,
28}: {
29 initialChecked: boolean;
30 initialMixed?: boolean;
31}) {
32 const [checked, setChecked] = useState(initialChecked);
33 const [mixed, setMixed] = useState(initialMixed);
34
35 return (
36 <Switch
37 aria-label="Enable Claude Code"
38 checked={checked}
39 mixed={mixed}
40 onCheckedChange={(nextChecked) => {
41 setMixed(false);
42 setChecked(nextChecked);
43 }}
44 />
45 );
946}
1047
11describe("Switch accessibility", () => {
12 it("exposes the checked state to assistive tech", () => {
13 expect(renderSwitchRoot({ checked: true })).toContain('aria-checked="true"');
14 expect(renderSwitchRoot({ checked: false })).toContain('aria-checked="false"');
48function switchRoot() {
49 const element = container.querySelector<HTMLElement>('[role="switch"]');
50 if (!element) throw new Error("Switch was not rendered");
51 return element;
52}
53
54async function clickSwitch() {
55 await act(async () => switchRoot().click());
56}
57
58async function renderSwitch(initialChecked: boolean, initialMixed = false) {
59 await act(async () => {
60 root.render(<ControlledSwitch {...{ initialChecked, initialMixed }} />);
1561 });
62}
63
64describe("Switch accessibility", () => {
65 it.each([
66 { initialChecked: false, initialMixed: false, before: false, after: true },
67 { initialChecked: true, initialMixed: false, before: true, after: false },
68 { initialChecked: false, initialMixed: true, before: "mixed", after: true },
69 ])("exposes $before before activation and $after after activation", async (state) => {
70 await renderSwitch(state.initialChecked, state.initialMixed);
71 expect(switchRoot().getAttribute("aria-checked")).toBe(String(state.before));
72
73 await clickSwitch();
1674
17 it("exposes the mixed state", () => {
18 expect(renderSwitchRoot({ checked: false, mixed: true })).toContain('aria-checked="mixed"');
75 expect(switchRoot().getAttribute("aria-checked")).toBe(String(state.after));
76 expect(container.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked).toBe(
77 state.after,
78 );
1979 });
2080});