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
42 changes: 42 additions & 0 deletions apps/mobile/src/features/connection/ConnectionFormField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vite-plus/test";

vi.mock("react-native", () => ({
View: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("../../components/AppText", () => ({
AppText: ({
accessibilityElementsHidden,
importantForAccessibility,
children,
}: {
accessibilityElementsHidden?: boolean;
importantForAccessibility?: string;
children: ReactNode;
}) => (
<span
aria-hidden={
accessibilityElementsHidden || importantForAccessibility === "no-hide-descendants"
}
>
{children}
</span>
),
AppTextInput: ({ accessibilityLabel }: { accessibilityLabel?: string }) => (
<input aria-label={accessibilityLabel} />
),
}));

import { ConnectionFormField } from "./ConnectionFormField";

describe("ConnectionFormField accessibility", () => {
it.each(["Host", "Pairing code"])("exposes %s once as the input name", (label) => {
const markup = renderToStaticMarkup(
<ConnectionFormField label={label} placeholder="Not an accessible name" />,
);

expect(markup).toContain(`<span aria-hidden="true">${label}</span>`);
expect(markup).toContain(`<input aria-label="${label}"/>`);
});
});
10 changes: 7 additions & 3 deletions apps/mobile/src/features/connection/ConnectionFormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { View } from "react-native";
import { AppText, AppTextInput, type AppTextInputProps } from "../../components/AppText";
import { cn } from "../../lib/cn";

type ConnectionFormFieldProps = Omit<AppTextInputProps, "className"> & {
type ConnectionFormFieldProps = Omit<AppTextInputProps, "accessibilityLabel" | "className"> & {
readonly label: string;
readonly className?: string;
};
Expand All @@ -12,12 +12,16 @@ type ConnectionFormFieldProps = Omit<AppTextInputProps, "className"> & {
export function ConnectionFormField({ label, className, ...inputProps }: ConnectionFormFieldProps) {
return (
<View collapsable={false} className={cn("gap-1.5", className)}>
<AppText className="text-2xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted">
<AppText
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
className="text-2xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted"
>
{label}
</AppText>
<AppTextInput
accessibilityLabel={label}
{...inputProps}
accessibilityLabel={label}
className="rounded-[14px] px-4 py-3.5"
/>
</View>
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
side="left"
collapsible="offcanvas"
data-app-sidebar=""
role="navigation"
aria-label={isOnSettings ? "Settings" : "Threads"}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
resizable={{
maxWidth: sidebarMaximumWidth,
minWidth: THREAD_SIDEBAR_MIN_WIDTH,
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
isTrailingDoubleClick,
orderItemsByPreferredIds,
resolveProjectStatusIndicator,
resolveSidebarRowAccessibility,
resolveSidebarThreadStatus,
resolveThreadStatusPill,
resolveWorkingStartedAt,
Expand Down Expand Up @@ -69,6 +70,35 @@ import {

const localEnvironmentId = EnvironmentId.make("environment-local");

describe("resolveSidebarRowAccessibility", () => {
it.each([
{
title: "Can you audit the UI?",
statusLabel: "Working",
projectDisplayName: "T3 Code",
isActive: true,
expected: { label: "Can you audit the UI?, Working, T3 Code", current: "page" },
},
{
title: "The audit is done",
statusLabel: null,
projectDisplayName: "T3 Code",
isActive: false,
expected: { label: "The audit is done, T3 Code", current: undefined },
},
{
title: "Untitled task",
statusLabel: null,
projectDisplayName: null,
isActive: false,
expected: { label: "Untitled task", current: undefined },
},
])("leads with the title without folding row actions into its name: %j", (input) => {
const { expected, ...state } = input;
expect(resolveSidebarRowAccessibility(state)).toEqual(expected);
});
});

describe("animateSidebarLayoutChanges", () => {
const baseArgs: Parameters<AnimateLayoutChanges>[0] = {
active: null,
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ export function shouldNavigateAfterThreadPark(input: {

const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200;

export function resolveSidebarRowAccessibility(input: {
readonly title: string;
readonly statusLabel: string | null;
readonly projectDisplayName: string | null;
readonly isActive: boolean;
}): { readonly label: string; readonly current: "page" | undefined } {
return {
// The title is the row's identity and must lead when users scan tasks.
// Only static context belongs here; nested action labels remain separate controls.
label: [input.title, input.statusLabel, input.projectDisplayName].filter(Boolean).join(", "),
current: input.isActive ? "page" : undefined,
};
}

// Visible sidebar rows are prewarmed into the thread-detail cache so opening a
// nearby thread usually reuses an already-hot subscription. Each prewarmed
// thread holds a live, fully hydrated detail subscription (all messages and
Expand Down
51 changes: 42 additions & 9 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ import {
resolveAdjacentThreadId,
resolveSidebarDropTarget,
resolveSidebarDropVerb,
resolveSidebarRowAccessibility,
type SidebarDropVerb,
resolveSidebarThreadStatus,
searchSidebarThreads,
Expand Down Expand Up @@ -728,6 +729,12 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
promptPreview.length > 0
? promptPreview
: `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`;
const accessibility = resolveSidebarRowAccessibility({
title: preview,
statusLabel: "Unsent draft",
projectDisplayName: props.projectDisplayName,
isActive: props.isActive,
});
const handleActivate = useCallback(() => onNavigate(draftId), [draftId, onNavigate]);
const handleKeyDown = useCallback(
(event: ReactKeyboardEvent) => {
Expand Down Expand Up @@ -755,6 +762,8 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
<div
role="button"
tabIndex={0}
aria-label={accessibility.label}
aria-current={accessibility.current}
data-testid="sidebar-draft-row"
className={cn(
"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",
Expand All @@ -763,6 +772,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
onClick={handleActivate}
onKeyDown={handleKeyDown}
>
<span className="sr-only">{preview}</span>
<div className="relative z-10 h-[4.875rem] px-(--sidebar-row-content-inset) py-(--sidebar-content-inset)">
<div className="flex h-5 min-w-0 items-center gap-1.5">
<SquarePenIcon aria-hidden className={draftPenClassName} />
Expand Down Expand Up @@ -790,7 +800,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
</Tooltip>
</span>
</div>
<div className="mt-0.5 truncate text-sm font-medium text-foreground/90">{preview}</div>
<div aria-hidden className="mt-0.5 truncate text-sm font-medium text-foreground/90">
{preview}
</div>
</div>
</div>
</li>
Expand Down Expand Up @@ -1447,6 +1459,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
</span>
) : null;

const accessibility = resolveSidebarRowAccessibility({
title: thread.title,
statusLabel: topStatus?.label ?? null,
projectDisplayName: props.projectDisplayName,
isActive: props.isActive,
});

const title = isRenaming ? (
<input
autoFocus
Expand All @@ -1462,6 +1481,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
/>
) : (
<span
aria-hidden
className={cn(
"min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none",
shouldRecede ? "font-normal" : "font-medium",
Expand Down Expand Up @@ -1492,6 +1512,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
{thread.title}
</span>
);
const accessibleTitle = isRenaming ? null : <span className="sr-only">{thread.title}</span>;

// Stacks show their layer count; multiple unrelated links show their total count.
// Plain clicks open T3; individual PR links also support opening the host in a new tab.
Expand Down Expand Up @@ -1593,6 +1614,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
ref={rowRef}
role="button"
tabIndex={0}
aria-label={accessibility.label}
aria-current={accessibility.current}
data-testid="sidebar-row-slim"
aria-busy={isRegeneratingTitle || undefined}
className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")}
Expand All @@ -1603,6 +1626,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
/>
}
>
{accessibleTitle}
{/* Settled history recedes: dimmed favicon at rest, restored on
hover so the tail stays scannable when you're hunting. */}
<span
Expand Down Expand Up @@ -1746,6 +1770,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
ref={rowRef}
role="button"
tabIndex={0}
aria-label={accessibility.label}
aria-current={accessibility.current}
data-testid="sidebar-row-card"
aria-busy={isRegeneratingTitle || undefined}
className={rowSurfaceClassName}
Expand All @@ -1756,6 +1782,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
/>
}
>
{accessibleTitle}
<div className="relative z-10 h-[4.875rem] px-(--sidebar-row-content-inset) py-(--sidebar-content-inset)">
<div className="flex h-5 min-w-0 items-center gap-1.5">
{draftIndicator}
Expand Down Expand Up @@ -2008,6 +2035,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;
}) {
const { thread } = props;
const accessibility = resolveSidebarRowAccessibility({
title: thread.title,
statusLabel: null,
projectDisplayName: props.projectDisplayName,
isActive: props.isRouteActive,
});
const threadRef = useMemo(
() => scopeThreadRef(thread.environmentId, thread.id),
[thread.environmentId, thread.id],
Expand Down Expand Up @@ -2084,12 +2117,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
// which owns all keyboard interaction for the listbox.
tabIndex={-1}
aria-selected={props.isHighlighted}
aria-current={props.isRouteActive ? "page" : undefined}
aria-label={
props.projectDisplayName
? `${thread.title}, ${props.projectDisplayName}`
: thread.title
}
aria-current={accessibility.current}
aria-label={accessibility.label}
onMouseMove={props.onHighlight}
onClick={props.onSelect}
className={cn(
Expand Down Expand Up @@ -4573,7 +4602,7 @@ export default function Sidebar() {
</SidebarGroup>
}
>
<SidebarGroup className="flex-1">
<SidebarGroup className="flex-1" role="presentation">
{isSearchingThreads ? (
threadSearchResults.length > 0 ? (
<TooltipProvider
Expand Down Expand Up @@ -4664,7 +4693,11 @@ export default function Sidebar() {
<SortableContext items={sortableIds} strategy={sidebarSortingStrategy}>
<ul
ref={attachListMotionRef}
role="list"
// VoiceOver treats an exposed list as an interaction boundary,
// which hides its rows from ordinary linear navigation. A
// presentational list also makes its implicit listitems
// presentational while preserving every descendant control.
role="presentation"
className={cn(
"relative flex flex-col gap-px",
sidebarListItems.length > 0 && "flex-1",
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/ui/scroll-area.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function ScrollArea({
hideScrollbars = false,
chainVerticalScroll = false,
radius = "inherit",
viewportTabIndex,
...props
}: ScrollAreaPrimitive.Root.Props & {
scrollFade?: boolean;
Expand All @@ -41,6 +42,8 @@ function ScrollArea({
chainVerticalScroll?: boolean;
/** The viewport clips to the parent's radius; "none" for a region flush to an edge. */
radius?: "inherit" | "none";
/** Override Base UI's focusable viewport when focusable descendants provide scroll access. */
viewportTabIndex?: number;
}) {
return (
<ScrollAreaPrimitive.Root
Expand All @@ -52,6 +55,7 @@ function ScrollArea({
{...props}
>
<ScrollAreaPrimitive.Viewport
{...(viewportTabIndex === undefined ? {} : { tabIndex: viewportTabIndex })}
className={cn(
"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",
chainVerticalScroll && "overscroll-y-auto",
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ function Sidebar({
if (isMobile) {
return (
<SidebarInstanceContext value={instanceContextValue}>
<Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>
<Sheet onOpenChange={setOpenMobile} open={openMobile}>
<SheetPopup
className={cn(
"w-(--sidebar-width) max-w-none bg-sidebar surface-grain p-0 text-sidebar-foreground",
Expand All @@ -259,6 +259,7 @@ function Sidebar({
"flex h-full w-full flex-col pb-safe pt-safe",
side === "left" ? "pl-safe" : "pr-safe",
)}
{...props}
>
{children}
</div>
Expand Down Expand Up @@ -597,6 +598,10 @@ function SidebarContent({
hideScrollbars
scrollFade
scrollFadePadding={false}
// Thread rows provide keyboard access to this scroll region. Keeping
// Base UI's viewport out of the tab order lets its presentational role
// flatten in WebKit instead of becoming a VoiceOver interaction group.
viewportTabIndex={-1}
className="h-auto min-h-0 flex-1 [&>[data-slot=scroll-area-viewport]]:[--fade-size:0.75rem]"
>
<div
Expand Down
Loading
Loading