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
20 changes: 10 additions & 10 deletions apps/web/src/components/ChatView.logic.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,20 @@ import { userEvent } from "vite-plus/test/browser";
import { shouldRefocusComposerOnWindowFocus } from "./ChatView.logic";

it("keeps keyboard focus on a sidebar row on window return, but not a clicked row", async () => {
const tree = document.createElement("div");
tree.setAttribute("role", "tree");
const row = (name: string, tabIndex: number) => {
const list = document.createElement("ul");
const row = (name: string) => {
const element = document.createElement("div");
element.setAttribute("role", "treeitem");
element.tabIndex = tabIndex;
element.setAttribute("role", "button");
element.tabIndex = 0;
element.dataset.sidebarRow = name;
element.textContent = name;
tree.append(element);
list.append(element);
return element;
};
const clicked = row("Clicked thread", -1);
const tabbed = row("Tabbed thread", 0);
document.body.append(tree);
using _tree = { [Symbol.dispose]: () => tree.remove() };
const clicked = row("Clicked thread");
const tabbed = row("Tabbed thread");
document.body.append(list);
using _list = { [Symbol.dispose]: () => list.remove() };

await userEvent.click(clicked);
expect(document.activeElement).toBe(clicked);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1166,7 +1166,7 @@ export function shouldRefocusComposerOnWindowFocus(
}
return (
activeElement.closest(
'[data-workspace-area]:focus-visible, [role="treeitem"]:focus-visible, [role="dialog"], [role="alertdialog"], [role="application"], [role="menu"], [role="listbox"], [data-slot$="-popup"], [data-terminal-owner]',
'[data-workspace-area]:focus-visible, [data-sidebar-row]:focus-visible, [role="dialog"], [role="alertdialog"], [role="application"], [role="menu"], [role="listbox"], [data-slot$="-popup"], [data-terminal-owner]',
) === null
);
}
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { liveShellEnvironmentIdsAtom } from "../state/shell";
import { attachSidebarTree, SIDEBAR_TREE_PROPS } from "../lib/sidebarTree";
import { attachSidebarTree } from "../lib/sidebarTree";
import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts";
import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection";
import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost";
Expand Down Expand Up @@ -3292,8 +3292,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
>
<SortableContext items={sortableIds} strategy={sidebarSortingStrategy}>
<ul
{...SIDEBAR_TREE_PROPS}
ref={attachTreeRef}
// Presentation flattens VoiceOver's list interaction boundary while preserving descendant
// controls.
role="presentation"
className={cn(
"relative flex flex-col gap-px",
sidebarListItems.length > 0 && "flex-1",
Expand Down Expand Up @@ -3581,8 +3583,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
<li className="list-none">
<button
type="button"
role="treeitem"
tabIndex={-1}
data-sidebar-row="settled:more"
data-sidebar-parent="shelf:settled"
disabled={olderSettledShelf.loading}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/sidebar/SidebarDraftBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
return (
<li className="list-none py-0.5">
<div
role="treeitem"
tabIndex={-1}
role="button"
tabIndex={0}
data-sidebar-row={`draft:${draftId}`}
aria-label={accessibility.label}
aria-current={accessibility.current}
Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/components/sidebar/SidebarListMarkers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,6 @@ export function SidebarSectionHeader(props: {
className={cn("relative mx-0.5 h-8", props.className)}
>
<CollapsibleSectionHeader
role="treeitem"
tabIndex={-1}
data-sidebar-row={`shelf:${shelf}`}
onClick={props.toggle.onToggle}
expanded={props.toggle.expanded}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,6 @@ export const SidebarProjectFolderHeader = memo(function SidebarProjectFolderHead
>
<button
type="button"
role="treeitem"
tabIndex={-1}
data-sidebar-row={rowId}
aria-expanded={!collapsed}
onClick={() => props.onToggle(group)}
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/components/sidebar/SidebarThreadRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1208,8 +1208,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
render={
<div
ref={rowRef}
role="treeitem"
tabIndex={-1}
role="button"
tabIndex={0}
data-sidebar-row={threadKey}
data-sidebar-parent={props.navigationParent}
aria-label={accessibility.label}
Expand Down Expand Up @@ -1404,8 +1404,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
render={
<div
ref={rowRef}
role="treeitem"
tabIndex={-1}
role="button"
tabIndex={0}
data-sidebar-row={threadKey}
data-sidebar-parent={props.navigationParent}
aria-label={accessibility.label}
Expand Down
66 changes: 20 additions & 46 deletions apps/web/src/lib/sidebarTree.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,12 @@ import { installSidebarTree } from "./sidebarTree";

function setup() {
const root = document.createElement("div");
root.setAttribute("role", "tree");
document.body.append(root);
let dispose: (() => void) | undefined;
const row = (id: string, parent?: string) => {
const element = document.createElement("button");
element.dataset.sidebarRow = id;
if (parent) element.dataset.sidebarParent = parent;
element.setAttribute("role", "treeitem");
element.textContent = id;
root.append(element);
return element;
Expand All @@ -36,7 +34,7 @@ function press(target: HTMLElement, key: string, init: KeyboardEventInit = {}) {
}

describe("sidebar keyboard navigation", () => {
it("moves focus without activating rows, clamps at the ends, and keeps one Tab stop", () => {
it("moves focus without activating rows, clamps at the ends, and keeps every row in the Tab order", () => {
using tree = setup();
const first = tree.row("first");
const second = tree.row("second");
Expand All @@ -54,10 +52,10 @@ describe("sidebar keyboard navigation", () => {
press(last, "Home");
expect(document.activeElement).toBe(first);
expect(activate).not.toHaveBeenCalled();
expect(tree.root.querySelectorAll('[tabindex="0"]')).toHaveLength(1);
expect([first.tabIndex, second.tabIndex, last.tabIndex]).toEqual([0, 0, 0]);
});

it("expands a group before entering its first child and returns to the parent", async () => {
it("expands a group before entering its first child and returns to the parent", () => {
using tree = setup();
const group = tree.row("project");
group.setAttribute("aria-expanded", "false");
Expand All @@ -72,12 +70,6 @@ describe("sidebar keyboard navigation", () => {
press(group, "ArrowRight");
expect(group.getAttribute("aria-expanded")).toBe("true");
expect(document.activeElement).toBe(group);
// The tree picks up the new child from its mutation observer.
await vi.waitFor(() =>
expect(
tree.root.querySelector('[data-sidebar-parent="project"]')?.getAttribute("aria-level"),
).toBe("2"),
);
press(group, "ArrowRight");
const child = document.activeElement as HTMLElement;
expect(child.dataset.sidebarRow).toBe("thread");
Expand All @@ -104,7 +96,7 @@ describe("sidebar keyboard navigation", () => {
expect(document.activeElement).toBe(selected);
});

it("lets Tab reach only the focused row's buttons", () => {
it("leaves every row's buttons in the Tab order", () => {
using tree = setup();
const first = tree.row("first");
const second = tree.row("second");
Expand All @@ -119,10 +111,10 @@ describe("sidebar keyboard navigation", () => {
const secondAction = action("second");
tree.install();
first.focus();
expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([0, -1]);
expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([0, 0]);
press(first, "ArrowDown");
expect(document.activeElement).toBe(second);
expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([-1, 0]);
expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([0, 0]);
});

it("keeps a rendered row under a collapsed group reachable", () => {
Expand Down Expand Up @@ -152,26 +144,6 @@ describe("sidebar keyboard navigation", () => {
expect(undo.tabIndex).toBe(0);
});

it("gives row buttons back their own tab order when the tree reinstalls", () => {
using tree = setup();
tree.row("first");
const second = tree.row("second");
const action = (owner: string) => {
const button = document.createElement("button");
button.dataset.sidebarActionFor = owner;
tree.root.append(button);
return button;
};
const firstAction = action("first");
const secondAction = action("second");
const dispose = installSidebarTree(tree.root);
second.focus();
dispose();
expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([0, 0]);
tree.install();
expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([-1, 0]);
});

it("keeps the scroll position when focus follows a row that moved away", async () => {
using tree = setup();
Object.assign(tree.root.style, { height: "100px", overflow: "auto" });
Expand All @@ -191,24 +163,26 @@ describe("sidebar keyboard navigation", () => {
expect(tree.root.scrollTop).toBe(0);
});

it("does not pull focus back after a click outside the tree", async () => {
it("does not pull focus back after a click outside the rows", async () => {
using tree = setup();
const first = tree.row("first");
tree.install();
const text = document.createElement("p");
text.textContent = "Chat text";
document.body.append(text);
using _text = { [Symbol.dispose]: () => text.remove() };
first.focus();
await userEvent.click(text);
expect(document.activeElement).toBe(document.body);
// A status change re-renders the tree while the user works elsewhere.
tree.row("second");
await vi.waitFor(() =>
expect(
tree.root.querySelector('[data-sidebar-row="second"]')?.getAttribute("aria-posinset"),
).toBe("2"),
);
expect(document.activeElement).toBe(document.body);
const spacer = document.createElement("div");
spacer.textContent = "Empty list space";
tree.root.append(spacer);
for (const target of [text, spacer]) {
first.focus();
await userEvent.click(target);
expect(document.activeElement).toBe(document.body);
// A status change re-renders the list while the user works elsewhere.
tree.row(`second-${target.tagName}`);
// The list's mutation observer callback is queued ahead of this continuation.
await Promise.resolve();
expect(document.activeElement).toBe(document.body);
}
});
});
Loading
Loading