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";...@@ -3,7 +3,7 @@ import { View } from "react-native";
3import { AppText, AppTextInput, type AppTextInputProps } from "../../components/AppText";3import { AppText, AppTextInput, type AppTextInputProps } from "../../components/AppText";
4import { cn } from "../../lib/cn";4import { cn } from "../../lib/cn";
55
6type ConnectionFormFieldProps = Omit<AppTextInputProps, "className"> & {6type ConnectionFormFieldProps = Omit<AppTextInputProps, "accessibilityLabel" | "className"> & {
7 readonly label: string;7 readonly label: string;
8 readonly className?: string;8 readonly className?: string;
9};9};
...@@ -12,12 +12,16 @@ type ConnectionFormFieldProps = Omit<AppTextInputProps, "className"> & {...@@ -12,12 +12,16 @@ type ConnectionFormFieldProps = Omit<AppTextInputProps, "className"> & {
12export function ConnectionFormField({ label, className, ...inputProps }: ConnectionFormFieldProps) {12export function ConnectionFormField({ label, className, ...inputProps }: ConnectionFormFieldProps) {
13 return (13 return (
14 <View collapsable={false} className={cn("gap-1.5", className)}>14 <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 >
16 {label}20 {label}
17 </AppText>21 </AppText>
18 <AppTextInput22 <AppTextInput
19 accessibilityLabel={label}
20 {...inputProps}23 {...inputProps}
24 accessibilityLabel={label}
21 className="rounded-[14px] px-4 py-3.5"25 className="rounded-[14px] px-4 py-3.5"
22 />26 />
23 </View>27 </View>
apps/web/src/components/AppSidebarLayout.tsx+2
...@@ -304,6 +304,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -304,6 +304,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
304 side="left"304 side="left"
305 collapsible="offcanvas"305 collapsible="offcanvas"
306 data-app-sidebar=""306 data-app-sidebar=""
307 role="navigation"
308 aria-label={isOnSettings ? "Settings" : "Threads"}
307 resizable={{309 resizable={{
308 maxWidth: sidebarMaximumWidth,310 maxWidth: sidebarMaximumWidth,
309 minWidth: THREAD_SIDEBAR_MIN_WIDTH,311 minWidth: THREAD_SIDEBAR_MIN_WIDTH,
apps/web/src/components/Sidebar.logic.test.ts+30
...@@ -23,6 +23,7 @@ import {...@@ -23,6 +23,7 @@ import {
23 isTrailingDoubleClick,23 isTrailingDoubleClick,
24 orderItemsByPreferredIds,24 orderItemsByPreferredIds,
25 resolveProjectStatusIndicator,25 resolveProjectStatusIndicator,
26 resolveSidebarRowAccessibility,
26 resolveSidebarThreadStatus,27 resolveSidebarThreadStatus,
27 resolveThreadStatusPill,28 resolveThreadStatusPill,
28 resolveWorkingStartedAt,29 resolveWorkingStartedAt,
...@@ -69,6 +70,35 @@ import {...@@ -69,6 +70,35 @@ import {
6970
70const localEnvironmentId = EnvironmentId.make("environment-local");71const 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
72describe("animateSidebarLayoutChanges", () => {102describe("animateSidebarLayoutChanges", () => {
73 const baseArgs: Parameters<AnimateLayoutChanges>[0] = {103 const baseArgs: Parameters<AnimateLayoutChanges>[0] = {
74 active: null,104 active: null,
apps/web/src/components/Sidebar.logic.ts+15
...@@ -42,6 +42,21 @@ export function shouldNavigateAfterThreadPark(input: {...@@ -42,6 +42,21 @@ export function shouldNavigateAfterThreadPark(input: {
4242
43const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";43const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
44export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200;44export 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
45// Visible sidebar rows are prewarmed into the thread-detail cache so opening a60// Visible sidebar rows are prewarmed into the thread-detail cache so opening a
46// nearby thread usually reuses an already-hot subscription. Each prewarmed61// nearby thread usually reuses an already-hot subscription. Each prewarmed
47// thread holds a live, fully hydrated detail subscription (all messages and62// thread holds a live, fully hydrated detail subscription (all messages and
apps/web/src/components/Sidebar.tsx+42-9
...@@ -173,6 +173,7 @@ import {...@@ -173,6 +173,7 @@ import {
173 resolveAdjacentThreadId,173 resolveAdjacentThreadId,
174 resolveSidebarDropTarget,174 resolveSidebarDropTarget,
175 resolveSidebarDropVerb,175 resolveSidebarDropVerb,
176 resolveSidebarRowAccessibility,
176 type SidebarDropVerb,177 type SidebarDropVerb,
177 resolveSidebarThreadStatus,178 resolveSidebarThreadStatus,
178 searchSidebarThreads,179 searchSidebarThreads,
...@@ -728,6 +729,12 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {...@@ -728,6 +729,12 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
728 promptPreview.length > 0729 promptPreview.length > 0
729 ? promptPreview730 ? promptPreview
730 : `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`;731 : `${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 });
731 const handleActivate = useCallback(() => onNavigate(draftId), [draftId, onNavigate]);738 const handleActivate = useCallback(() => onNavigate(draftId), [draftId, onNavigate]);
732 const handleKeyDown = useCallback(739 const handleKeyDown = useCallback(
733 (event: ReactKeyboardEvent) => {740 (event: ReactKeyboardEvent) => {
...@@ -755,6 +762,8 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {...@@ -755,6 +762,8 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
755 <div762 <div
756 role="button"763 role="button"
757 tabIndex={0}764 tabIndex={0}
765 aria-label={accessibility.label}
766 aria-current={accessibility.current}
758 data-testid="sidebar-draft-row"767 data-testid="sidebar-draft-row"
759 className={cn(768 className={cn(
760 "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",769 "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: {...@@ -763,6 +772,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
763 onClick={handleActivate}772 onClick={handleActivate}
764 onKeyDown={handleKeyDown}773 onKeyDown={handleKeyDown}
765 >774 >
775 <span className="sr-only">{preview}</span>
766 <div className="relative z-10 h-[4.875rem] px-(--sidebar-row-content-inset) py-(--sidebar-content-inset)">776 <div className="relative z-10 h-[4.875rem] px-(--sidebar-row-content-inset) py-(--sidebar-content-inset)">
767 <div className="flex h-5 min-w-0 items-center gap-1.5">777 <div className="flex h-5 min-w-0 items-center gap-1.5">
768 <SquarePenIcon aria-hidden className={draftPenClassName} />778 <SquarePenIcon aria-hidden className={draftPenClassName} />
...@@ -790,7 +800,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {...@@ -790,7 +800,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
790 </Tooltip>800 </Tooltip>
791 </span>801 </span>
792 </div>802 </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>
794 </div>806 </div>
795 </div>807 </div>
796 </li>808 </li>
...@@ -1447,6 +1459,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1447,6 +1459,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1447 </span>1459 </span>
1448 ) : null;1460 ) : 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
1450 const title = isRenaming ? (1469 const title = isRenaming ? (
1451 <input1470 <input
1452 autoFocus1471 autoFocus
...@@ -1462,6 +1481,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1462,6 +1481,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1462 />1481 />
1463 ) : (1482 ) : (
1464 <span1483 <span
1484 aria-hidden
1465 className={cn(1485 className={cn(
1466 "min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none",1486 "min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none",
1467 shouldRecede ? "font-normal" : "font-medium",1487 shouldRecede ? "font-normal" : "font-medium",
...@@ -1492,6 +1512,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1492,6 +1512,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1492 {thread.title}1512 {thread.title}
1493 </span>1513 </span>
1494 );1514 );
1515 const accessibleTitle = isRenaming ? null : <span className="sr-only">{thread.title}</span>;
14951516
1496 // Stacks show their layer count; multiple unrelated links show their total count.1517 // Stacks show their layer count; multiple unrelated links show their total count.
1497 // Plain clicks open T3; individual PR links also support opening the host in a new tab.1518 // 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: {...@@ -1593,6 +1614,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1593 ref={rowRef}1614 ref={rowRef}
1594 role="button"1615 role="button"
1595 tabIndex={0}1616 tabIndex={0}
1617 aria-label={accessibility.label}
1618 aria-current={accessibility.current}
1596 data-testid="sidebar-row-slim"1619 data-testid="sidebar-row-slim"
1597 aria-busy={isRegeneratingTitle || undefined}1620 aria-busy={isRegeneratingTitle || undefined}
1598 className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")}1621 className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")}
...@@ -1603,6 +1626,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1603,6 +1626,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1603 />1626 />
1604 }1627 }
1605 >1628 >
1629 {accessibleTitle}
1606 {/* Settled history recedes: dimmed favicon at rest, restored on1630 {/* Settled history recedes: dimmed favicon at rest, restored on
1607 hover so the tail stays scannable when you're hunting. */}1631 hover so the tail stays scannable when you're hunting. */}
1608 <span1632 <span
...@@ -1746,6 +1770,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1746,6 +1770,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1746 ref={rowRef}1770 ref={rowRef}
1747 role="button"1771 role="button"
1748 tabIndex={0}1772 tabIndex={0}
1773 aria-label={accessibility.label}
1774 aria-current={accessibility.current}
1749 data-testid="sidebar-row-card"1775 data-testid="sidebar-row-card"
1750 aria-busy={isRegeneratingTitle || undefined}1776 aria-busy={isRegeneratingTitle || undefined}
1751 className={rowSurfaceClassName}1777 className={rowSurfaceClassName}
...@@ -1756,6 +1782,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1756,6 +1782,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1756 />1782 />
1757 }1783 }
1758 >1784 >
1785 {accessibleTitle}
1759 <div className="relative z-10 h-[4.875rem] px-(--sidebar-row-content-inset) py-(--sidebar-content-inset)">1786 <div className="relative z-10 h-[4.875rem] px-(--sidebar-row-content-inset) py-(--sidebar-content-inset)">
1760 <div className="flex h-5 min-w-0 items-center gap-1.5">1787 <div className="flex h-5 min-w-0 items-center gap-1.5">
1761 {draftIndicator}1788 {draftIndicator}
...@@ -2008,6 +2035,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -2008,6 +2035,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
2008 onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;2035 onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;
2009}) {2036}) {
2010 const { thread } = props;2037 const { thread } = props;
2038 const accessibility = resolveSidebarRowAccessibility({
2039 title: thread.title,
2040 statusLabel: null,
2041 projectDisplayName: props.projectDisplayName,
2042 isActive: props.isRouteActive,
2043 });
2011 const threadRef = useMemo(2044 const threadRef = useMemo(
2012 () => scopeThreadRef(thread.environmentId, thread.id),2045 () => scopeThreadRef(thread.environmentId, thread.id),
2013 [thread.environmentId, thread.id],2046 [thread.environmentId, thread.id],
...@@ -2084,12 +2117,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -2084,12 +2117,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
2084 // which owns all keyboard interaction for the listbox.2117 // which owns all keyboard interaction for the listbox.
2085 tabIndex={-1}2118 tabIndex={-1}
2086 aria-selected={props.isHighlighted}2119 aria-selected={props.isHighlighted}
2087 aria-current={props.isRouteActive ? "page" : undefined}2120 aria-current={accessibility.current}
2088 aria-label={2121 aria-label={accessibility.label}
2089 props.projectDisplayName
2090 ? `${thread.title}, ${props.projectDisplayName}`
2091 : thread.title
2092 }
2093 onMouseMove={props.onHighlight}2122 onMouseMove={props.onHighlight}
2094 onClick={props.onSelect}2123 onClick={props.onSelect}
2095 className={cn(2124 className={cn(
...@@ -4573,7 +4602,7 @@ export default function Sidebar() {...@@ -4573,7 +4602,7 @@ export default function Sidebar() {
4573 </SidebarGroup>4602 </SidebarGroup>
4574 }4603 }
4575 >4604 >
4576 <SidebarGroup className="flex-1">4605 <SidebarGroup className="flex-1" role="presentation">
4577 {isSearchingThreads ? (4606 {isSearchingThreads ? (
4578 threadSearchResults.length > 0 ? (4607 threadSearchResults.length > 0 ? (
4579 <TooltipProvider4608 <TooltipProvider
...@@ -4664,7 +4693,11 @@ export default function Sidebar() {...@@ -4664,7 +4693,11 @@ export default function Sidebar() {
4664 <SortableContext items={sortableIds} strategy={sidebarSortingStrategy}>4693 <SortableContext items={sortableIds} strategy={sidebarSortingStrategy}>
4665 <ul4694 <ul
4666 ref={attachListMotionRef}4695 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"
4668 className={cn(4701 className={cn(
4669 "relative flex flex-col gap-px",4702 "relative flex flex-col gap-px",
4670 sidebarListItems.length > 0 && "flex-1",4703 sidebarListItems.length > 0 && "flex-1",
apps/web/src/components/ui/scroll-area.tsx+4
...@@ -30,6 +30,7 @@ function ScrollArea({...@@ -30,6 +30,7 @@ function ScrollArea({
30 hideScrollbars = false,30 hideScrollbars = false,
31 chainVerticalScroll = false,31 chainVerticalScroll = false,
32 radius = "inherit",32 radius = "inherit",
33 viewportTabIndex,
33 ...props34 ...props
34}: ScrollAreaPrimitive.Root.Props & {35}: ScrollAreaPrimitive.Root.Props & {
35 scrollFade?: boolean;36 scrollFade?: boolean;
...@@ -41,6 +42,8 @@ function ScrollArea({...@@ -41,6 +42,8 @@ function ScrollArea({
41 chainVerticalScroll?: boolean;42 chainVerticalScroll?: boolean;
42 /** The viewport clips to the parent's radius; "none" for a region flush to an edge. */43 /** The viewport clips to the parent's radius; "none" for a region flush to an edge. */
43 radius?: "inherit" | "none";44 radius?: "inherit" | "none";
45 /** Override Base UI's focusable viewport when focusable descendants provide scroll access. */
46 viewportTabIndex?: number;
44}) {47}) {
45 return (48 return (
46 <ScrollAreaPrimitive.Root49 <ScrollAreaPrimitive.Root
...@@ -52,6 +55,7 @@ function ScrollArea({...@@ -52,6 +55,7 @@ function ScrollArea({
52 {...props}55 {...props}
53 >56 >
54 <ScrollAreaPrimitive.Viewport57 <ScrollAreaPrimitive.Viewport
58 {...(viewportTabIndex === undefined ? {} : { tabIndex: viewportTabIndex })}
55 className={cn(59 className={cn(
56 "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",60 "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",
57 chainVerticalScroll && "overscroll-y-auto",61 chainVerticalScroll && "overscroll-y-auto",
apps/web/src/components/ui/sidebar.tsx+6-1
...@@ -233,7 +233,7 @@ function Sidebar({...@@ -233,7 +233,7 @@ function Sidebar({
233 if (isMobile) {233 if (isMobile) {
234 return (234 return (
235 <SidebarInstanceContext value={instanceContextValue}>235 <SidebarInstanceContext value={instanceContextValue}>
236 <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>236 <Sheet onOpenChange={setOpenMobile} open={openMobile}>
237 <SheetPopup237 <SheetPopup
238 className={cn(238 className={cn(
239 "w-(--sidebar-width) max-w-none bg-sidebar surface-grain p-0 text-sidebar-foreground",239 "w-(--sidebar-width) max-w-none bg-sidebar surface-grain p-0 text-sidebar-foreground",
...@@ -259,6 +259,7 @@ function Sidebar({...@@ -259,6 +259,7 @@ function Sidebar({
259 "flex h-full w-full flex-col pb-safe pt-safe",259 "flex h-full w-full flex-col pb-safe pt-safe",
260 side === "left" ? "pl-safe" : "pr-safe",260 side === "left" ? "pl-safe" : "pr-safe",
261 )}261 )}
262 {...props}
262 >263 >
263 {children}264 {children}
264 </div>265 </div>
...@@ -597,6 +598,10 @@ function SidebarContent({...@@ -597,6 +598,10 @@ function SidebarContent({
597 hideScrollbars598 hideScrollbars
598 scrollFade599 scrollFade
599 scrollFadePadding={false}600 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}
600 className="h-auto min-h-0 flex-1 [&>[data-slot=scroll-area-viewport]]:[--fade-size:0.75rem]"605 className="h-auto min-h-0 flex-1 [&>[data-slot=scroll-area-viewport]]:[--fade-size:0.75rem]"
601 >606 >
602 <div607 <div
apps/web/src/components/ui/switch.test.tsx+71-11
...@@ -1,20 +1,80 @@...@@ -1,20 +1,80 @@
1import { renderToStaticMarkup } from "react-dom/server";1// @vitest-environment jsdom
2import { describe, expect, it } from "vite-plus/test";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
4import { Switch } from "./switch";7import { Switch } from "./switch";
58
6function renderSwitchRoot(props: Partial<Parameters<typeof Switch>[0]>) {9let root: Root;
7 const html = renderToStaticMarkup(<Switch aria-label="Enable Claude Code" {...props} />);10let container: HTMLDivElement;
8 return html.match(/<span[^>]*role="switch"[^>]*>/)?.[0] ?? "";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 );
9}46}
1047
11describe("Switch accessibility", () => {48function switchRoot() {
12 it("exposes the checked state to assistive tech", () => {49 const element = container.querySelector<HTMLElement>('[role="switch"]');
13 expect(renderSwitchRoot({ checked: true })).toContain('aria-checked="true"');50 if (!element) throw new Error("Switch was not rendered");
14 expect(renderSwitchRoot({ checked: false })).toContain('aria-checked="false"');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 }} />);
15 });61 });
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", () => {75 expect(switchRoot().getAttribute("aria-checked")).toBe(String(state.after));
18 expect(renderSwitchRoot({ checked: false, mixed: true })).toContain('aria-checked="mixed"');76 expect(container.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked).toBe(
77 state.after,
78 );
19 });79 });
20});80});