Skip to content
Closed
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
70 changes: 69 additions & 1 deletion apps/web/src/components/Sidebar.pointer.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { SensorProps } from "@dnd-kit/core";
import { describe, expect, it, vi } from "vite-plus/test";
import { fakeTimers } from "../../test/render";
import { userEvent } from "vite-plus/test/browser";
import { scopeProjectRef } from "@t3tools/client-runtime/environment";
import { EnvironmentId, ProjectId } from "@t3tools/contracts";
import { DraftId, useComposerDraftStore } from "../composerDraftStore";
import { SidebarDraftBlock } from "./sidebar/SidebarDraftBlock";
import { act, render, fakeTimers } from "../../test/render";
import { SidebarPointerSensor } from "./Sidebar.pointer";

const sensors: SidebarPointerSensor[] = [];
Expand Down Expand Up @@ -86,3 +91,66 @@ describe("sidebar pointer lifecycle", () => {
expect(drag.onFinish).toHaveBeenCalledExactlyOnceWith(true);
});
});

it("keeps draft actions separate from keyboard navigation and anchors keyboard menus to the row", async () => {
const draftId = DraftId.make("accessible-draft");
const environmentId = EnvironmentId.make("sidebar-test");
const projectId = ProjectId.make("project-test");
const store = useComposerDraftStore.getState();
store.setProjectDraftThreadId(scopeProjectRef(environmentId, projectId), draftId);
store.setPrompt(draftId, "Fix tests");
using _cleanup = {
[Symbol.dispose]: () => useComposerDraftStore.getState().clearDraftThread(draftId),
};
const onNavigate = vi.fn();
const onContextMenu = vi.fn();
await using screen = await render(
<ul>
<SidebarDraftBlock
projectByKey={new Map()}
projectDisplayNameByKey={new Map()}
scopedProjectKeys={null}
routeDraftId={null}
onNavigateToDraft={onNavigate}
onDraftContextMenu={onContextMenu}
/>
</ul>,
);
const primary = screen.getByRole("button", { name: "Fix tests, Unsent draft", exact: true });
const discard = screen.getByRole("button", { name: "Discard draft", exact: true });
expect(primary.element()).toBeInstanceOf(HTMLButtonElement);
expect(discard.element().parentElement?.closest('button, [role="button"]')).toBeNull();
await act(() => (primary.element() as HTMLElement).focus());
await userEvent.keyboard("{Enter}");
expect(onNavigate).toHaveBeenCalledExactlyOnceWith(draftId);
await userEvent.tab();
expect(discard.element()).toHaveFocus();
await act(() => (primary.element() as HTMLElement).focus());
await userEvent.keyboard("{Shift>}{F10}{/Shift}");
const row = screen.container.querySelector('[data-testid="sidebar-draft-row"]')!;
const bounds = row.getBoundingClientRect();
expect(onContextMenu).toHaveBeenCalledExactlyOnceWith(draftId, {
x: bounds.left,
y: bounds.bottom,
});
onContextMenu.mockClear();
await userEvent.keyboard("{ContextMenu}");
expect(onContextMenu).toHaveBeenCalledExactlyOnceWith(draftId, {
x: bounds.left,
y: bounds.bottom,
});
onContextMenu.mockClear();
await act(() =>
primary
.element()
.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true })),
);
expect(onContextMenu).toHaveBeenCalledExactlyOnceWith(draftId, {
x: bounds.left,
y: bounds.bottom,
});
await act(() => (discard.element() as HTMLElement).focus());
await userEvent.keyboard(" ");
expect(useComposerDraftStore.getState().getDraftSession(draftId)).toBeNull();
expect(onNavigate).toHaveBeenCalledExactlyOnceWith(draftId);
});
5 changes: 2 additions & 3 deletions apps/web/src/components/Sidebar.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -2936,9 +2936,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
<SortableContext items={sortableIds} strategy={sidebarSortingStrategy}>
<ul
ref={attachListMotionRef}
// Presentation flattens VoiceOver's list interaction boundary while preserving descendant
// controls.
role="presentation"
// Safari drops list semantics from lists 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
42 changes: 22 additions & 20 deletions apps/web/src/components/sidebar/SidebarDraftBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ import {
type ComposerThreadDraftState,
type DraftSessionState,
} from "../../composerDraftStore";
import { draftPenClassName, draftSurfaceClassName } from "./SidebarThreadRow";
import {
draftPenClassName,
draftSurfaceClassName,
SidebarRowPrimaryAction,
focusSidebarRowPrimaryAction,
SIDEBAR_ROW_SECONDARY_CONTROL_SELECTOR,
} from "./SidebarThreadRow";

const SidebarDraftRow = memo(function SidebarDraftRow(props: {
draftId: DraftId;
Expand Down Expand Up @@ -56,22 +62,21 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
});
const handleActivate = () => onNavigate(draftId);
const handleKeyDown = (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 === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
onContextMenu(draftId, { x: rect.left, y: rect.bottom });
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onNavigate(draftId);
}
if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) return;
if ((event.target as HTMLElement).closest(SIDEBAR_ROW_SECONDARY_CONTROL_SELECTOR)) return;
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
onContextMenu(draftId, { x: rect.left, y: rect.bottom });
};
const handleContextMenu = (event: ReactMouseEvent) => {
event.preventDefault();
onContextMenu(draftId, { x: event.clientX, y: event.clientY });
const bounds = event.currentTarget.getBoundingClientRect();
onContextMenu(
draftId,
event.clientX === 0 && event.clientY === 0
? { x: bounds.left, y: bounds.bottom }
: { x: event.clientX, y: event.clientY },
);
};
const handleDiscard = (event: ReactMouseEvent) => {
event.preventDefault();
Expand All @@ -81,20 +86,17 @@ 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}
onContextMenu={handleContextMenu}
onKeyDown={handleKeyDown}
>
<span className="sr-only">{preview}</span>
<SidebarRowPrimaryAction name={accessibility.label} isActive={props.isActive} />
<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
70 changes: 47 additions & 23 deletions apps/web/src/components/sidebar/SidebarThreadRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,40 @@ const dropVerbBadge: Record<SidebarDropVerb, ReactNode> = {
),
};

export const SIDEBAR_ROW_SECONDARY_CONTROL_SELECTOR =
"button:not([data-sidebar-row-primary]), a, input";

// Visible row content covers the primary button, so presses must transfer focus.
export function focusSidebarRowPrimaryAction(event: ReactMouseEvent<HTMLElement>) {
if (!event.currentTarget.contains(event.target as Node)) return;
if ((event.target as HTMLElement).closest(SIDEBAR_ROW_SECONDARY_CONTROL_SELECTOR)) return;
const primaryAction = event.currentTarget.querySelector<HTMLButtonElement>(
"[data-sidebar-row-primary]",
);
if (!primaryAction) return;
event.preventDefault();
primaryAction.focus({ preventScroll: true });
}

// Keep actions outside the named button so screen readers expose them. Row
// isolation keeps this button above its background and behind its visible content.
export function SidebarRowPrimaryAction(props: {
name: string;
isActive: boolean;
isBusy?: boolean;
}) {
return (
<button
type="button"
data-sidebar-row-primary
aria-label={props.name}
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"
/>
);
}

type SidebarThreadRowProps = {
thread: SidebarThreadSummary;
variant: "card" | "slim";
Expand Down Expand Up @@ -808,12 +842,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
event.preventDefault();
onContextMenu(threadRef, { x: event.clientX, y: event.clientY });
};
const handleKeyDown = (event: ReactKeyboardEvent) => {
if (event.target !== event.currentTarget) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onThreadActivate(threadRef);
};
const handleDoubleClick = (event: ReactMouseEvent) => {
if (
!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) ||
Expand All @@ -825,7 +853,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
) {
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 @@ -909,7 +937,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
};

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" &&
!props.isActive &&
"[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70 redesign:[&:not(:hover):not(:focus-within)_*]:text-secondary-label",
Expand Down Expand Up @@ -1010,7 +1038,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
{thread.title}
</span>
);
const accessibleTitle = isRenaming ? null : <span className="sr-only">{thread.title}</span>;
const primaryAction = (
<SidebarRowPrimaryAction
name={accessibility.label}
isActive={props.isActive}
isBusy={isRegeneratingTitle}
/>
);

const prBadgeShape = supportsMultiplePullRequests
? resolveThreadPullRequestBadge(thread.pullRequests)
Expand Down Expand Up @@ -1107,21 +1141,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
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}
<span
className={cn(
"flex shrink-0 items-center transition-opacity",
Expand Down Expand Up @@ -1278,21 +1307,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
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={cn(
"relative z-10 h-[4.875rem] px-(--sidebar-row-content-inset) py-(--sidebar-content-inset)",
Expand Down
Loading