Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
30 changes: 0 additions & 30 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
isTrailingDoubleClick,
orderItemsByPreferredIds,
resolveProjectStatusIndicator,
resolveSidebarRowAccessibility,
resolveSidebarThreadStatus,
resolveThreadStatusPill,
resolveWorkingStartedAt,
Expand Down Expand Up @@ -70,35 +69,6 @@ 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
14 changes: 0 additions & 14 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,6 @@ 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
140 changes: 65 additions & 75 deletions apps/web/src/components/Sidebar.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,6 @@ import {
resolveAdjacentThreadId,
resolveSidebarDropTarget,
resolveSidebarDropVerb,
resolveSidebarRowAccessibility,
type SidebarDropVerb,
resolveSidebarThreadStatus,
searchSidebarThreads,
Expand Down Expand Up @@ -505,8 +504,8 @@ function SnoozeMenuButton(props: {
// Subset of useSortable applied to a thread row's root <li>. Listeners go
// on the whole row (no dedicated handle): the pointer sensor's distance
// constraint keeps plain clicks working, and we skip dnd-kit's aria
// attributes since there is no keyboard sensor and the row body already
// carries its own button semantics.
// attributes since there is no keyboard sensor and the row already carries
// its own primary button.
type SortableThreadRowBag = Pick<
ReturnType<typeof useSortable>,
"listeners" | "setNodeRef" | "transform" | "transition" | "isDragging"
Expand Down Expand Up @@ -536,6 +535,44 @@ function SortableThreadRow(props: {
const draftSurfaceClassName = "bg-warning/4 hover:bg-warning/8";
const draftPenClassName = "size-3 shrink-0 text-warning-foreground";

// The row's one control for opening it, named by the row's title. It fills
// the row behind the visible content, which gives screen reader cursors and
// voice control a row-sized target, and its activation bubbles to the row
// surface's click handler like any other click on the row. Secondary actions
// must stay its siblings, never its children: WebKit drops everything inside a
// button from the accessibility tree, and NVDA's browse mode hides the
// contents of a named button. The row surface needs `isolate` so the negative
// z-index keeps this above the surface's background.
const SIDEBAR_ROW_SECONDARY_CONTROL_SELECTOR = "button:not([data-sidebar-row-primary]), a, input";

// Row surface mousedown handler. A press on the row's content lands on
// elements that cannot take focus, so hand focus to the primary action, the
// way pressing a button focuses it. The keyboard and the context menu key then
// pick up from this row.
function focusSidebarRowPrimaryAction(event: ReactMouseEvent<HTMLElement>) {
if ((event.target as HTMLElement).closest(SIDEBAR_ROW_SECONDARY_CONTROL_SELECTOR)) return;
const primaryAction = event.currentTarget.querySelector<HTMLElement>(
"[data-sidebar-row-primary]",
);
if (!primaryAction) return;
event.preventDefault();
primaryAction.focus({ preventScroll: true });
}

function SidebarRowPrimaryAction(props: { name: string; isActive: boolean; isBusy?: boolean }) {
return (
<button
type="button"
data-sidebar-row-primary
aria-current={props.isActive ? "page" : undefined}
aria-busy={props.isBusy || undefined}
className="absolute inset-0 -z-1 cursor-pointer rounded-[inherit] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<span className="sr-only">{props.name}</span>
</button>
);
}

// Structural list items — the section headers and the
// empty-section placeholders — take part in the sortable list so they shift
// with the rows and the gap can open on either side of them. They can't be
Expand Down Expand Up @@ -729,26 +766,7 @@ 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) => {
// Keys targeting the nested discard button belong to the button:
// preventDefault here would swallow Space's synthesized click and
// navigate instead of discarding.
if ((event.target as HTMLElement).closest("button")) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onNavigate(draftId);
}
},
[draftId, onNavigate],
);
const handleDiscard = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
Expand All @@ -760,19 +778,15 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
return (
<li className="list-none py-0.5">
<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",
"group/sidebar-row relative isolate w-full cursor-pointer overflow-hidden rounded-md text-left text-sidebar-foreground select-none",
props.isActive ? "bg-sidebar-row-active" : draftSurfaceClassName,
)}
onMouseDown={focusSidebarRowPrimaryAction}
onClick={handleActivate}
onKeyDown={handleKeyDown}
>
<span className="sr-only">{preview}</span>
<SidebarRowPrimaryAction name={`${preview}, Unsent draft`} isActive={props.isActive} />
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
<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 @@ -1253,21 +1267,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
},
[onContextMenu, threadRef],
);
const handleKeyDown = useCallback(
(event: ReactKeyboardEvent) => {
if (event.target !== event.currentTarget) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onThreadActivate(threadRef);
},
[onThreadActivate, threadRef],
);
const handleDoubleClick = useCallback(
(event: ReactMouseEvent) => {
if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
if ((event.target as HTMLElement).closest("button, a, input")) return;
if ((event.target as HTMLElement).closest(SIDEBAR_ROW_SECONDARY_CONTROL_SELECTOR)) return;
event.preventDefault();
onStartRename(threadRef, thread.title);
},
Expand Down Expand Up @@ -1404,7 +1409,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
// a useful hierarchy nor a reliable hover cue. Status now lives in the row
// content; surface is reserved for interaction (hover, multi-select, route).
const rowSurfaceClassName = cn(
"group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
"group/sidebar-row relative isolate w-full cursor-pointer overflow-hidden rounded-md text-left select-none",
variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70",
props.isActive
? "bg-sidebar-row-active text-sidebar-foreground"
Expand Down Expand Up @@ -1459,13 +1464,6 @@ 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 Down Expand Up @@ -1512,7 +1510,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
{thread.title}
</span>
);
const accessibleTitle = isRenaming ? null : <span className="sr-only">{thread.title}</span>;
const primaryAction = (
<SidebarRowPrimaryAction
name={thread.title}
isActive={props.isActive}
isBusy={isRegeneratingTitle}
/>
);

// 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 @@ -1612,21 +1616,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
render={
<div
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")}
onMouseDown={focusSidebarRowPrimaryAction}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onKeyDown={handleKeyDown}
onContextMenu={handleContextMenu}
/>
}
>
{accessibleTitle}
{primaryAction}
{/* Settled history recedes: dimmed favicon at rest, restored on
hover so the tail stays scannable when you're hunting. */}
<span
Expand Down Expand Up @@ -1768,21 +1767,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
render={
<div
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}
onMouseDown={focusSidebarRowPrimaryAction}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onKeyDown={handleKeyDown}
onContextMenu={handleContextMenu}
/>
}
>
{accessibleTitle}
{primaryAction}
<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 @@ -2035,12 +2029,6 @@ 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 @@ -2117,8 +2105,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
// which owns all keyboard interaction for the listbox.
tabIndex={-1}
aria-selected={props.isHighlighted}
aria-current={accessibility.current}
aria-label={accessibility.label}
aria-current={props.isRouteActive ? "page" : undefined}
aria-label={
props.projectDisplayName
? `${thread.title}, ${props.projectDisplayName}`
: thread.title
}
onMouseMove={props.onHighlight}
onClick={props.onSelect}
className={cn(
Expand Down Expand Up @@ -4693,11 +4685,9 @@ export default function Sidebar() {
<SortableContext items={sortableIds} strategy={sidebarSortingStrategy}>
<ul
ref={attachListMotionRef}
// 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"
// Explicit because Safari drops list semantics from a list
// styled without markers.
role="list"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium components/Sidebar.tsx:4938

role="list" makes the thread container a VoiceOver interaction boundary, so Safari/VoiceOver users cannot traverse its row controls through ordinary linear navigation and must first enter the group. Keep this container presentational, or use a structure that does not introduce that boundary.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/Sidebar.tsx around line 4938:

`role="list"` makes the thread container a VoiceOver interaction boundary, so Safari/VoiceOver users cannot traverse its row controls through ordinary linear navigation and must first enter the group. Keep this container presentational, or use a structure that does not introduce that boundary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Keeping role="list" here is deliberate. Without it, NVDA doesn't announce the thread list, and its L and I quick-navigation keys skip the threads. That was half of the regression in #13959.

Whether the list is an interaction boundary depends on the VoiceOver user's web navigation setting. Per Apple's guide, lists need interaction under Grouping items but not under DOM order. Under Grouping items, entering a list is the expected way to move through it, as it is for any other list on the web.

The VoiceOver pass in Safari and Chrome, under both settings, is still the merge check for this PR. I'm leaving this thread open until that's done. If that pass shows the list gets in the way, we'll revisit it then.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

className={cn(
"relative flex flex-col gap-px",
sidebarListItems.length > 0 && "flex-1",
Expand Down
Loading