Skip to content
Source
Sign inSign up

workspaceFocus.browser.test.ts

404 lines16.4 KB

1import { describe, expect, it, vi } from "vite-plus/test";
2import { userEvent } from "vite-plus/test/browser";
3import appCss from "~/index.css?inline";
4import { createWorkspaceFocusController } from "./workspaceFocus";
5​
6function setup() {
7 const root = document.createElement("div");
8 document.body.append(root);
9 const controller = createWorkspaceFocusController(document);
10 const area = (name: string, order: number, parent: HTMLElement = root) => {
11 const element = document.createElement("section");
12 element.dataset.workspaceArea = name;
13 element.dataset.workspaceOrder = String(order);
14 element.tabIndex = -1;
15 const button = document.createElement("button");
16 button.textContent = name;
17 element.append(button);
18 parent.append(element);
19 return { element, button };
20 };
21 const append = (html: string) => {
22 root.insertAdjacentHTML("beforeend", html);
23 return root.lastElementChild as HTMLElement;
24 };
25 return {
26 area,
27 append,
28 controller,
29 [Symbol.dispose]() {
30 controller.dispose();
31 root.remove();
32 },
33 };
35​
36/** Mirrors MessagesTimeline's scroller, which takes focus so the keyboard scrolls it. */
37function conversationScroller(height: number) {
38 const timeline = document.createElement("div");
39 timeline.style.cssText = `height: ${height}px; overflow-y: auto`;
40 timeline.tabIndex = -1;
41 timeline.dataset.workspaceFocusDefault = "";
42 return timeline;
44​
45describe("workspace area focus", () => {
46 it("cycles in workspace order in both directions, independent of DOM order", () => {
47 using fixture = setup();
48 const panel = fixture.area("panel", 3);
49 const sidebar = fixture.area("sidebar", 0);
50 const conversation = fixture.area("conversation", 1);
51 const composer = fixture.area("composer", 2);
52 sidebar.button.focus();
53 fixture.controller.move(1);
54 // Without a timeline (an empty thread), the region takes focus rather than its first button.
55 expect(document.activeElement).toBe(conversation.element);
56 fixture.controller.move(1);
57 expect(document.activeElement).toBe(composer.button);
58 fixture.controller.move(1);
59 expect(document.activeElement).toBe(panel.button);
60 fixture.controller.move(1);
61 expect(document.activeElement).toBe(sidebar.button);
62 fixture.controller.move(-1);
63 expect(document.activeElement).toBe(panel.button);
64 });
65​
66 it("skips hidden, inert, and maximized-away areas", () => {
67 using fixture = setup();
68 const sidebar = fixture.area("sidebar", 0);
69 const conversation = fixture.area("conversation", 1);
70 const composer = fixture.area("composer", 2);
71 const panel = fixture.area("panel", 3);
72 const terminal = fixture.area("terminal", 4);
73 conversation.element.dataset.chatColumnMaximizedAway = "true";
74 composer.element.hidden = true;
75 panel.element.dataset.workspaceUnavailable = "true";
76 terminal.element.inert = true;
77 sidebar.button.focus();
78 expect(fixture.controller.move(1)).toBe(true);
79 expect(document.activeElement).toBe(sidebar.button);
80 });
81​
82 it("restores the last focused control and falls back when it is removed", () => {
83 using fixture = setup();
84 fixture.area("sidebar", 0);
85 const composer = fixture.area("composer", 2);
86 const remembered = document.createElement("button");
87 remembered.textContent = "remembered";
88 composer.element.append(remembered);
89 remembered.focus();
90 fixture.controller.move(-1);
91 fixture.controller.move(1);
92 expect(document.activeElement).toBe(remembered);
93 fixture.controller.move(-1);
94 remembered.remove();
95 fixture.controller.move(1);
96 expect(document.activeElement).toBe(composer.button);
97 });
98​
99 it("keeps focus inside a dialog or an open popup", () => {
100 using fixture = setup();
101 fixture.area("sidebar", 0);
102 fixture.area("panel", 3);
103 const dialog = fixture.append('<div role="dialog" tabindex="-1">Confirm</div>');
104 dialog.focus();
105 expect(fixture.controller.move(1)).toBe(false);
106 expect(document.activeElement).toBe(dialog);
107 dialog.remove();
108​
109 const popup = fixture.append(
110 '<div role="listbox" data-slot="select-popup"><button role="option">Choice</button></div>',
111 );
112 const choice = popup.querySelector("button")!;
113 choice.focus();
114 expect(fixture.controller.move(1)).toBe(false);
115 expect(document.activeElement).toBe(choice);
116 });
117​
118 it("keeps moving while a non-modal notification is visible", () => {
119 using fixture = setup();
120 const composer = fixture.area("composer", 2);
121 const panel = fixture.area("panel", 4);
122 fixture.append(
123 '<div data-slot="toast-viewport"><div role="dialog" aria-modal="false" tabindex="0">Update available</div></div>',
124 );
125 composer.button.focus();
126 expect(fixture.controller.move(1)).toBe(true);
127 expect(document.activeElement).toBe(panel.button);
128 });
129​
130 it("moves only between the areas of an open Settings modal", () => {
131 using fixture = setup();
132 fixture.area("sidebar", 0);
133 fixture.area("composer", 2);
134 const modal = fixture.append('<div role="dialog" data-app-modal=""></div>');
135 const nav = fixture.area("settings-nav", 0, modal);
136 const content = fixture.area("settings", 1, modal);
137 nav.button.focus();
138 expect(fixture.controller.move(1)).toBe(true);
139 expect(document.activeElement).toBe(content.button);
140 expect(fixture.controller.move(1)).toBe(true);
141 expect(document.activeElement).toBe(nav.button);
142 });
143​
144 it("enters the Settings sections on the open section", () => {
145 using fixture = setup();
146 const nav = fixture.area("settings-nav", 0);
147 const content = fixture.area("settings", 1);
148 nav.element.insertAdjacentHTML(
149 "beforeend",
150 '<button data-settings-section="/settings/general">General</button><button data-settings-section="/settings/chat" aria-current="page">Chat</button>',
151 );
152 content.button.focus();
153 fixture.controller.move(-1);
154 expect(document.activeElement).toBe(nav.element.querySelector('[aria-current="page"]'));
155 });
156​
157 it("keeps moving while a shortcut recorder is on the page but not focused", () => {
158 using fixture = setup();
159 const nav = fixture.area("settings-nav", 0);
160 const content = fixture.area("settings", 1);
161 fixture.append('<button data-keybinding-capture="">Record shortcut</button>');
162 nav.button.focus();
163 expect(fixture.controller.move(1)).toBe(true);
164 expect(document.activeElement).toBe(content.button);
165 });
166​
167 it("brings a restored control back into view inside its own list", () => {
168 using fixture = setup();
169 const sidebar = fixture.area("sidebar", 0);
170 const list = document.createElement("div");
171 list.style.cssText = "height: 50px; overflow-y: auto";
172 const spacer = document.createElement("div");
173 spacer.style.height = "400px";
174 const row = document.createElement("button");
175 row.textContent = "Last thread";
176 list.append(spacer, row);
177 sidebar.element.append(list);
178 const composer = fixture.area("composer", 2);
179 row.focus();
180 fixture.controller.move(1);
181 expect(document.activeElement).toBe(composer.button);
182 list.scrollTop = 0;
183 fixture.controller.move(-1);
184 expect(document.activeElement).toBe(row);
185 expect(list.scrollTop).toBeGreaterThan(0);
186 });
187​
188 it("reveals a restored control through nested scrolling lists", () => {
189 using fixture = setup();
190 const settings = fixture.area("settings", 1);
191 const page = document.createElement("div");
192 page.style.cssText = "height: 80px; overflow-y: auto";
193 const inner = document.createElement("div");
194 inner.style.cssText = "height: 60px; overflow-y: auto";
195 const field = document.createElement("button");
196 field.textContent = "Provider field";
197 inner.append(Object.assign(document.createElement("div"), { style: "height: 200px" }), field);
198 page.append(inner, Object.assign(document.createElement("div"), { style: "height: 400px" }));
199 settings.element.append(page);
200 const nav = fixture.area("settings-nav", 0);
201 field.focus();
202 fixture.controller.move(-1);
203 expect(document.activeElement).toBe(nav.button);
204 inner.scrollTop = 0;
205 page.scrollTop = 300;
206 fixture.controller.move(1);
207 expect(document.activeElement).toBe(field);
208 const box = page.getBoundingClientRect();
209 const rect = field.getBoundingClientRect();
210 expect(rect.bottom > box.top && rect.top < box.bottom).toBe(true);
211 });
212​
213 it("does not scroll the conversation to an old control", () => {
214 using fixture = setup();
215 const conversation = fixture.area("conversation", 1);
216 const timeline = conversationScroller(50);
217 const copy = document.createElement("button");
218 copy.textContent = "Copy";
219 timeline.append(copy, Object.assign(document.createElement("div"), { style: "height: 400px" }));
220 conversation.element.append(timeline);
221 const composer = fixture.area("composer", 2);
222 copy.focus();
223 fixture.controller.move(1);
224 expect(document.activeElement).toBe(composer.button);
225 // Live follow moved the timeline to the newest output.
226 timeline.scrollTop = 300;
227 fixture.controller.move(-1);
228 expect(document.activeElement).toBe(timeline);
229 expect(timeline.scrollTop).toBe(300);
230 });
231​
232 it("scrolls the conversation with the keyboard once an empty thread gets a timeline", async () => {
233 using fixture = setup();
234 const conversation = fixture.area("conversation", 1);
235 conversation.button.remove();
236 conversation.element.style.height = "200px";
237 const composer = fixture.area("composer", 2);
238 composer.button.focus();
239 fixture.controller.move(-1);
240 expect(document.activeElement).toBe(conversation.element);
241 fixture.controller.move(1);
242 const timeline = conversationScroller(200);
243 timeline.append(Object.assign(document.createElement("div"), { style: "height: 2000px" }));
244 conversation.element.append(timeline);
245 fixture.controller.move(-1);
246 await userEvent.keyboard("{ArrowDown}{PageDown}");
247 await vi.waitFor(() => expect(timeline.scrollTop).toBeGreaterThan(0));
248 });
249​
250 it.each([
251 ["panel", "panel", "removed"],
252 ["conversation", "composer", "removed"],
253 ["conversation", "composer", "disabled"],
254 ])("recovers focus from a %s control to the %s when %s", async (source, destination, change) => {
255 using fixture = setup();
256 const composer = fixture.area("composer", 2);
257 const area = fixture.area(source, 3);
258 const control = document.createElement("button");
259 control.id = "pending-request";
260 control.textContent = "Submit";
261 area.element.append(control);
262 control.focus();
263 if (change === "disabled") control.disabled = true;
264 else control.remove();
265 const expected = destination === "composer" ? composer.button : area.button;
266 await vi.waitFor(() => expect(document.activeElement).toBe(expected));
267 });
268​
269 it("falls back to the main content when the whole area disappears", async () => {
270 using fixture = setup();
271 const composer = fixture.area("composer", 2);
272 const details = fixture.area("details", 3);
273 details.button.focus();
274 details.element.hidden = true;
275 await vi.waitFor(() => expect(document.activeElement).toBe(composer.button));
276 });
277​
278 it("keeps focus on the control a panel tab switch came from", () => {
279 using fixture = setup();
280 const panel = fixture.area("panel", 3);
281 const content = document.createElement("div");
282 content.setAttribute("data-right-panel-surface-content", "");
283 const row = (name: string) => {
284 const button = document.createElement("button");
285 button.textContent = name;
286 content.append(button);
287 return button;
288 };
289 const earlierRow = row("reference-analysis.md");
290 const clickedRow = row("print-guide.md");
291 panel.element.append(content);
292 panel.element.dataset.workspaceFocusKey = "panel:file:print-guide.md";
293 earlierRow.focus();
294 panel.element.dataset.workspaceFocusKey = "panel:file:reference-analysis.md";
295 clickedRow.focus();
296 panel.element.dataset.workspaceFocusKey = "panel:file:print-guide.md";
297 expect(fixture.controller.focus("panel")).toBe(true);
298 expect(document.activeElement).toBe(clickedRow);
299 });
300​
301 it("focuses a side chat's message input when the panel opens on it", () => {
302 using fixture = setup();
303 const panel = fixture.area("panel", 3);
304 const content = document.createElement("div");
305 content.setAttribute("data-right-panel-surface-content", "");
306 const bringBack = document.createElement("button");
307 bringBack.textContent = "Bring back";
308 content.append(bringBack);
309 panel.element.append(content);
310 const timeline = conversationScroller(50);
311 timeline.append(Object.assign(document.createElement("div"), { style: "height: 400px" }));
312 fixture.area("conversation", 1, content).element.append(timeline);
313 const composer = fixture.area("composer", 2, content);
314 const editor = document.createElement("div");
315 editor.contentEditable = "true";
316 composer.element.append(editor);
317 expect(fixture.controller.focus("panel")).toBe(true);
318 expect(document.activeElement).toBe(editor);
319 // F6 from the message input moves on to the panel's own controls.
320 expect(fixture.controller.move(1)).toBe(true);
321 expect(document.activeElement).toBe(bringBack);
322 });
323​
324 it("waits for a requested area to mount", async () => {
325 using fixture = setup();
326 fixture.area("composer", 2);
327 fixture.controller.request({ area: "pull-requests" });
328 const list = fixture.area("pull-requests", 1);
329 await vi.waitFor(() => expect(document.activeElement).toBe(list.button));
330 });
331​
332 it("keeps focus on a control that React remounted elsewhere", async () => {
333 using fixture = setup();
334 fixture.area("composer", 2);
335 const header = fixture.append("<header></header>");
336 const toggle = document.createElement("button");
337 toggle.setAttribute("aria-label", "Toggle right panel");
338 header.append(toggle);
339 toggle.focus();
340 const moved = toggle.cloneNode() as HTMLButtonElement;
341 toggle.remove();
342 header.append(moved);
343 await vi.waitFor(() => expect(document.activeElement).toBe(moved));
344 });
345​
346 it("keeps moving while a hover popup without focus is open", () => {
347 using fixture = setup();
348 const composer = fixture.area("composer", 2);
349 const terminal = fixture.area("terminal", 4);
350 fixture.append('<div data-slot="popover-popup">New split terminal</div>');
351 composer.button.focus();
352 expect(fixture.controller.move(1)).toBe(true);
353 expect(document.activeElement).toBe(terminal.button);
354 });
355​
356 it("rings an area reached with F6, but not a clicked one after a bare key press", async () => {
357 using fixture = setup();
358 const style = document.createElement("style");
359 style.textContent = appCss;
360 document.head.append(style);
361 using _style = { [Symbol.dispose]: () => style.remove() };
362 const sidebar = fixture.area("sidebar", 0);
363 const home = fixture.append(
364 '<main data-workspace-area="home" data-workspace-order="1" tabindex="-1" style="height: 200px"></main>',
365 );
366 const outline = () => getComputedStyle(home).outlineStyle;
367​
368 await userEvent.click(home);
369 expect(document.activeElement).toBe(home);
370 expect(outline()).toBe("none");
371 // A window switch can deliver a lone modifier key without moving focus.
372 await userEvent.keyboard("{Shift}");
373 expect(outline(), "a clicked area shows the focus ring after a bare key press").toBe("none");
374 // Window activation refocuses the element that had focus, with nothing focused in between.
375 home.blur();
376 home.focus();
377 expect(outline(), "a clicked area rings when the window regains focus").toBe("none");
378​
379 await userEvent.keyboard("{F6}");
380 fixture.controller.move(-1);
381 expect(document.activeElement).toBe(sidebar.button);
382 await userEvent.keyboard("{F6}");
383 fixture.controller.move(1);
384 expect(document.activeElement).toBe(home);
385 expect(outline()).toBe("solid");
386​
387 // A dialog opened from the keyboard returns focus to the area when it closes.
388 await userEvent.keyboard("{Enter}");
389 sidebar.button.focus();
390 await userEvent.keyboard("{Escape}");
391 home.focus();
392 expect(outline(), "an area loses its ring when keyboard focus returns to it").toBe("solid");
393​
394 await userEvent.click(home);
395 expect(outline(), "clicking the ringed area removes its ring").toBe("none");
396​
397 // With no other area visible, F6 lands on the area that already has focus.
398 sidebar.element.hidden = true;
399 await userEvent.keyboard("{F6}");
400 fixture.controller.move(1);
401 expect(document.activeElement).toBe(home);
402 expect(outline(), "F6 onto the only, already focused area shows no ring").toBe("solid");
403 });
404});