Skip to content

Commit 7088a1a

Browse files
committed
fix(webapp): mode toggle escape, aria, external collapse
1 parent 599ee59 commit 7088a1a

2 files changed

Lines changed: 121 additions & 12 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// @vitest-environment jsdom
2+
import { createElement } from "react";
3+
import { createRoot, type Root } from "react-dom/client";
4+
import { act } from "react-dom/test-utils";
5+
import { afterEach, describe, expect, it, vi } from "vitest";
6+
import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider";
7+
import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider";
8+
import { ModeToggle } from "./DashboardAgentHeader";
9+
10+
function withProviders(children: React.ReactNode) {
11+
return createElement(
12+
OperatingSystemContextProvider,
13+
{ platform: "mac" },
14+
createElement(ShortcutsProvider, null, children)
15+
);
16+
}
17+
18+
let container: HTMLDivElement | undefined;
19+
let root: Root | undefined;
20+
21+
afterEach(() => {
22+
if (root) {
23+
act(() => root!.unmount());
24+
}
25+
container?.remove();
26+
container = undefined;
27+
root = undefined;
28+
});
29+
30+
function renderToggle(mode: "floating" | "rightPanel" | "fullscreen", onModeChange: () => void) {
31+
container = document.createElement("div");
32+
document.body.appendChild(container);
33+
root = createRoot(container);
34+
act(() => {
35+
root!.render(withProviders(createElement(ModeToggle, { mode, onModeChange })));
36+
});
37+
return container;
38+
}
39+
40+
// Row-reverse layout keeps the trigger as the first button in DOM order.
41+
function getTrigger(el: HTMLElement) {
42+
return el.querySelectorAll("button")[0] as HTMLButtonElement;
43+
}
44+
45+
function expandToggle(el: HTMLElement) {
46+
const trigger = getTrigger(el);
47+
act(() => {
48+
trigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
49+
});
50+
}
51+
52+
describe("ModeToggle", () => {
53+
it("collapses on Escape without changing mode, and marks the event handled", () => {
54+
const onModeChange = vi.fn();
55+
const el = renderToggle("floating", onModeChange);
56+
expandToggle(el);
57+
expect(getTrigger(el).getAttribute("aria-expanded")).toBe("true");
58+
59+
// cancelable: true, like a real native keydown; otherwise preventDefault() is a no-op.
60+
const escapeEvent = new KeyboardEvent("keydown", {
61+
key: "Escape",
62+
bubbles: true,
63+
cancelable: true,
64+
});
65+
act(() => {
66+
document.dispatchEvent(escapeEvent);
67+
});
68+
69+
expect(escapeEvent.defaultPrevented).toBe(true);
70+
expect(getTrigger(el).getAttribute("aria-expanded")).toBe("false");
71+
expect(onModeChange).not.toHaveBeenCalled();
72+
});
73+
74+
it("collapses when mode changes externally", () => {
75+
container = document.createElement("div");
76+
document.body.appendChild(container);
77+
root = createRoot(container);
78+
const onModeChange = vi.fn();
79+
act(() => {
80+
root!.render(withProviders(createElement(ModeToggle, { mode: "floating", onModeChange })));
81+
});
82+
expandToggle(container);
83+
expect(getTrigger(container).getAttribute("aria-expanded")).toBe("true");
84+
85+
act(() => {
86+
root!.render(withProviders(createElement(ModeToggle, { mode: "fullscreen", onModeChange })));
87+
});
88+
89+
expect(getTrigger(container).getAttribute("aria-expanded")).toBe("false");
90+
});
91+
});

‎apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx‎

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const NEW_CHAT_SHORTCUT: Shortcut = {
3131
enabledOnInputElements: true,
3232
};
3333

34-
function ModeToggle({
34+
export function ModeToggle({
3535
mode,
3636
onModeChange,
3737
}: {
@@ -40,9 +40,23 @@ function ModeToggle({
4040
}) {
4141
const [isExpanded, setExpanded] = useState(false);
4242
const containerRef = useRef<HTMLDivElement>(null);
43+
const triggerRef = useRef<HTMLButtonElement>(null);
4344
const currentOption = MODE_OPTIONS.find((option) => option.mode === mode) ?? MODE_OPTIONS[0];
4445
const otherOptions = MODE_OPTIONS.filter((option) => option.mode !== mode);
4546

47+
useEffect(() => {
48+
// oxlint-disable-next-line react/set-state-in-effect -- collapses when `mode` changes externally (e.g. route-driven).
49+
setExpanded(false);
50+
}, [mode]);
51+
52+
// Button doesn't forward arbitrary aria props, so set them directly on the node.
53+
useEffect(() => {
54+
const el = triggerRef.current;
55+
if (!el) return;
56+
el.setAttribute("aria-haspopup", "true");
57+
el.setAttribute("aria-expanded", String(isExpanded));
58+
}, [isExpanded]);
59+
4660
useEffect(() => {
4761
if (!isExpanded) return;
4862

@@ -52,22 +66,34 @@ function ModeToggle({
5266
}
5367
}
5468

69+
// Capture phase + preventDefault: DashboardAgentPanel's own Escape handler
70+
// (bubble phase) checks defaultPrevented before closing the whole panel.
5571
function handleKeyDown(event: KeyboardEvent) {
5672
if (event.key === "Escape") {
73+
event.preventDefault();
5774
setExpanded(false);
5875
}
5976
}
6077

6178
document.addEventListener("pointerdown", handlePointerDown);
62-
document.addEventListener("keydown", handleKeyDown);
79+
document.addEventListener("keydown", handleKeyDown, true);
6380
return () => {
6481
document.removeEventListener("pointerdown", handlePointerDown);
65-
document.removeEventListener("keydown", handleKeyDown);
82+
document.removeEventListener("keydown", handleKeyDown, true);
6683
};
6784
}, [isExpanded]);
6885

6986
return (
70-
<div ref={containerRef} className="flex items-center">
87+
<div ref={containerRef} className="flex flex-row-reverse items-center">
88+
<Button
89+
ref={triggerRef}
90+
variant="minimal/small"
91+
className="aspect-square h-6 p-1"
92+
aria-label="Change chat display mode"
93+
tooltip={currentOption.label}
94+
onClick={() => setExpanded((open) => !open)}
95+
LeadingIcon={<currentOption.Icon className="size-4 text-text-dimmed" />}
96+
/>
7197
<AnimatePresence initial={false}>
7298
{isExpanded &&
7399
otherOptions.map(({ mode: option, label, Icon }) => (
@@ -93,14 +119,6 @@ function ModeToggle({
93119
</motion.div>
94120
))}
95121
</AnimatePresence>
96-
<Button
97-
variant="minimal/small"
98-
className="aspect-square h-6 p-1"
99-
aria-label="Change chat display mode"
100-
tooltip={currentOption.label}
101-
onClick={() => setExpanded((open) => !open)}
102-
LeadingIcon={<currentOption.Icon className="size-4 text-text-dimmed" />}
103-
/>
104122
</div>
105123
);
106124
}

0 commit comments

Comments
 (0)