Skip to content
Source
Sign inSign up

workspaceFocus.ts

440 lines18.9 KB

1import type { AnyRouter } from "@tanstack/react-router";
2import { dismissContextMenu } from "../contextMenuFallback";
3import { useRightPanelStore } from "../rightPanelStore";
4​
5const AREA_SELECTOR = "[data-workspace-area]";
6const FOCUS_BOUNDARY_SELECTOR =
7 '[role="dialog"], [role="alertdialog"], [aria-modal="true"], dialog[open], [data-slot$="-popup"]:not([data-slot="tooltip-popup"]):not([role="tooltip"])';
8const FOCUSABLE_SELECTOR =
9 'button:not(:disabled), a[href], input:not(:disabled), textarea:not(:disabled), select:not(:disabled), [contenteditable="true"], [tabindex]';
10​
11/** Props that make an element one stop in the F6 cycle; `order` sets its place in the cycle. */
12export function workspaceAreaProps(name: string, order: number, label: string) {
13 return {
14 "data-workspace-area": name,
15 "data-workspace-order": order,
16 "aria-label": label,
17 role: "region",
18 tabIndex: -1,
19 } as const;
21​
22export interface WorkspaceFocusTarget {
23 area: string;
24 selector?: string;
25 restore?: boolean;
27​
28function workspaceArea(element: Element): HTMLElement | null {
29 const area = element.closest<HTMLElement>(AREA_SELECTOR);
30 if (area) return area;
31 const root = element.getRootNode();
32 return root instanceof ShadowRoot ? workspaceArea(root.host) : null;
34​
35function isWorkspaceElementVisible(element: HTMLElement): boolean {
36 if (
37 !element.isConnected ||
38 element.closest(
39 '[hidden], [inert], [data-closed], [aria-hidden="true"], [data-chat-column-maximized-away="true"], [data-slot="sidebar"][data-state="collapsed"], [data-workspace-unavailable="true"]',
40 )
41 )
42 return false;
43 const style = getComputedStyle(element);
44 if (style.display === "none" || style.visibility === "hidden") return false;
45 const root = element.getRootNode();
46 if (
47 root instanceof ShadowRoot &&
48 root.host instanceof HTMLElement &&
49 !isWorkspaceElementVisible(root.host)
50 )
51 return false;
52 return Array.from(element.getClientRects()).some((rect) => rect.width > 0 && rect.height > 0);
54​
55function scrollingAncestors(target: HTMLElement) {
56 const scrollers: HTMLElement[] = [];
57 for (let element = target.parentElement; element; element = element.parentElement) {
58 const { overflowY } = getComputedStyle(element);
59 if (
60 (overflowY === "auto" || overflowY === "scroll") &&
61 element.scrollHeight > element.clientHeight
62 )
63 scrollers.push(element);
64 }
65 return scrollers;
67​
68function isInScrollView(target: HTMLElement) {
69 const rect = target.getBoundingClientRect();
70 return scrollingAncestors(target).every((scroller) => {
71 const box = scroller.getBoundingClientRect();
72 return rect.bottom > box.top && rect.top < box.bottom;
73 });
75​
76/**
77 * Whether focus can go back to an element without surprising the user: it is still in a
78 * visible workspace area (a closing drawer drops its area) and in view, so no list scrolls.
79 */
80export function canReturnFocusTo(element: HTMLElement) {
81 return (
82 workspaceArea(element) !== null && isWorkspaceElementVisible(element) && isInScrollView(element)
83 );
85​
86/**
87 * Brings a restored control into view, innermost scrolling list first.
88 * scrollIntoView would also scroll overflow-hidden layout containers out of place.
89 */
90function revealInScrollers(target: HTMLElement) {
91 for (const scroller of scrollingAncestors(target)) {
92 const box = scroller.getBoundingClientRect();
93 const rect = target.getBoundingClientRect();
94 if (rect.top < box.top) scroller.scrollTop -= box.top - rect.top;
95 else if (rect.bottom > box.bottom) scroller.scrollTop += rect.bottom - box.bottom;
96 }
98​
99/** Keeps area focus local, like VS Code's layout service focusPart/visible-neighbour actions. */
100export function createWorkspaceFocusController(doc: Document) {
101 const lastFocused = new Map<string, HTMLElement>();
102 let lastArea: string | undefined;
103 // An area that took focus itself from the keyboard, or the non-tabbable default (the
104 // conversation's scroller) standing in for it. Chromium's :focus-visible also matches a
105 // clicked area after any later key press, so the ring and window return read this mark.
106 let keyboardArea: HTMLElement | undefined;
107 let previousTarget: EventTarget | null = null;
108 const unmarkKeyboardArea = () => {
109 keyboardArea?.removeAttribute("data-workspace-keyboard-focus");
110 keyboardArea = undefined;
111 };
112 const standsInFor = (element: HTMLElement, area: HTMLElement) =>
113 element === area ||
114 (element.tabIndex < 0 && element.hasAttribute("data-workspace-focus-default"));
115 const markKeyboardArea = (target: HTMLElement) => {
116 if (!target.matches(":focus-visible")) return;
117 keyboardArea = target;
118 target.setAttribute("data-workspace-keyboard-focus", "");
119 };
120 const remember = (event: FocusEvent) => {
121 if (event.target !== keyboardArea) unmarkKeyboardArea();
122 // Window activation refocuses the same element; only focus arriving from elsewhere counts.
123 const arrived = event.target !== previousTarget;
124 previousTarget = event.target;
125 if (!(event.target instanceof HTMLElement)) return;
126 const area = workspaceArea(event.target);
127 if (!area?.dataset.workspaceArea) return;
128 if (arrived && standsInFor(event.target, area)) markKeyboardArea(event.target);
129 lastArea = area.dataset.workspaceArea;
130 const key = area.dataset.workspaceFocusKey ?? lastArea;
131 lastFocused.delete(key);
132 // Custom file trees retarget focusin to their nonfocusable shadow host.
133 const target = event.composedPath().find((element) => element instanceof HTMLElement);
134 lastFocused.set(key, target instanceof HTMLElement ? target : event.target);
135 // Surface history must not retain an unbounded number of detached panel trees.
136 if (lastFocused.size > 64) lastFocused.delete(lastFocused.keys().next().value!);
137 };
138 doc.addEventListener("focusin", remember);
139​
140 const focusArea = (area: HTMLElement, restore = true, nested = true) => {
141 const key = area.dataset.workspaceFocusKey ?? area.dataset.workspaceArea!;
142 const previous = restore ? lastFocused.get(key) : undefined;
143 const usable = (element: HTMLElement) =>
144 isWorkspaceElementVisible(element) && !element.matches(":disabled");
145 const belongs = (element: HTMLElement) => workspaceArea(element) === area && usable(element);
146 // A side chat's message input sits in its own area inside the panel. Opening the
147 // panel focuses it, but F6 visits that area on its own, so it moves past it here.
148 // A nested timeline stands in only for its own area, never ahead of that input.
149 const preferred = Array.from(
150 area.querySelectorAll<HTMLElement>(
151 '[data-workspace-focus-default], [data-sidebar-row][aria-current="page"], [data-settings-section][aria-current="page"], .xterm-helper-textarea, [contenteditable="true"]',
152 ),
153 ).find((element) =>
154 nested && !standsInFor(element, area) ? usable(element) : belongs(element),
155 );
156 const fallback = Array.from(area.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).find(
157 (element) => element.tabIndex >= 0 && belongs(element),
158 );
159 // Panel surfaces may unmount when hidden. Recover the same named control when
160 // it mounts again, without sharing focus history between different surface tabs.
161 const restored =
162 previous && !previous.isConnected
163 ? Array.from(area.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).find(
164 (element) =>
165 belongs(element) &&
166 element.tagName === previous.tagName &&
167 ((previous.id && element.id === previous.id) ||
168 (previous.getAttribute("aria-label") &&
169 element.getAttribute("aria-label") === previous.getAttribute("aria-label")) ||
170 (previous.matches("button") &&
171 previous.textContent?.trim() &&
172 element.textContent === previous.textContent)),
173 )
174 : undefined;
175 const surface = area.querySelector<HTMLElement>("[data-right-panel-surface-content]");
176 const surfaceControl = surface
177 ? Array.from(surface.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).find(
178 (element) => belongs(element) && element.tabIndex >= 0,
179 )
180 : undefined;
181 // Moving the timeline to an old control would also stop it following a running turn,
182 // so the conversation reuses a control only while it is still in view. A remembered
183 // area is only the fallback it was then, so controls mounted since take precedence.
184 const isConversation = area.dataset.workspaceArea === "conversation";
185 const targets = new Set([
186 previous &&
187 previous !== area &&
188 belongs(previous) &&
189 (!isConversation || isInScrollView(previous))
190 ? previous
191 : undefined,
192 isConversation ? undefined : restored,
193 preferred,
194 surfaceControl,
195 isConversation ? undefined : fallback,
196 area,
197 ]);
198 for (const target of targets) {
199 if (!target) continue;
200 target.focus({ preventScroll: true });
201 const root = target.getRootNode();
202 const active = root instanceof ShadowRoot ? root.activeElement : doc.activeElement;
203 if (active === target || (active && target.contains(active))) {
204 if (target !== area && !isConversation) revealInScrollers(target);
205 // Focusing the already focused area fires no focusin, as when F6 has nowhere else to go.
206 if (standsInFor(target, area)) markKeyboardArea(target);
207 return true;
208 }
209 }
210 return false;
211 };
212 const focus = (name: string, restore = true, selector?: string) => {
213 const areas = Array.from(doc.querySelectorAll<HTMLElement>(AREA_SELECTOR)).filter(
214 isWorkspaceElementVisible,
215 );
216 const area =
217 name === "content"
218 ? (areas.find((element) => element.dataset.workspaceArea === "composer") ??
219 areas.find(
220 (element) =>
221 element.dataset.workspaceArea === "pull-requests" || element.matches("main"),
222 ) ??
223 areas.find((element) => element.dataset.workspaceArea === "conversation") ??
224 areas.find((element) => element.dataset.workspaceArea === "panel"))
225 : areas.find((element) => element.dataset.workspaceArea === name);
226 if (!area) return false;
227 const surface = area.querySelector("[data-right-panel-surface-content]");
228 // A lazy surface's Suspense fallback is empty. Wait for its content instead
229 // of claiming focus on the tab strip before the useful controls mount.
230 if (surface && !surface.hasChildNodes()) return false;
231 if (!selector && surface?.contains(doc.activeElement)) return true;
232 if (selector) {
233 const target = area.querySelector<HTMLElement>(selector);
234 if (!target || !isWorkspaceElementVisible(target)) return false;
235 target.focus({ preventScroll: true });
236 return doc.activeElement === target;
237 }
238 return focusArea(area, restore);
239 };
240 const move = (direction: 1 | -1, fromArea?: string, closingPalette = false) => {
241 // HTML context menus close on blur, unlike modal dialogs and Base UI popups.
242 dismissContextMenu(false);
243 // A palette closes before running its command. Other popups keep their focus boundary.
244 // Inline listboxes (such as Settings search) belong to their workspace area, while
245 // portalled listboxes and menus can also supply a boundary without a popup data-slot.
246 const activePopup = doc.activeElement?.closest<HTMLElement>('[role="menu"], [role="listbox"]');
247 // Settings opens as a route modal with its own areas; navigation stays inside it.
248 const appModal = Array.from(doc.querySelectorAll<HTMLElement>("[data-app-modal]")).find(
249 isWorkspaceElementVisible,
250 );
251 const boundaries = Array.from(
252 doc.querySelectorAll<HTMLElement>(FOCUS_BOUNDARY_SELECTOR),
253 ).filter((boundary) => boundary !== appModal);
254 if (activePopup && !activePopup.closest(AREA_SELECTOR)) boundaries.push(activePopup);
255 // A modal keeps focus wherever it is; any other popup only while it holds focus,
256 // so hover popups and notifications never swallow the key.
257 if (
258 boundaries.some(
259 (boundary) =>
260 !(closingPalette && boundary.closest("[data-command-palette]")) &&
261 (boundary.matches('[aria-modal="true"]') || boundary.contains(doc.activeElement)) &&
262 isWorkspaceElementVisible(boundary),
263 )
264 )
265 return false;
266 const areas = Array.from((appModal ?? doc).querySelectorAll<HTMLElement>(AREA_SELECTOR))
267 .filter(isWorkspaceElementVisible)
268 .sort(
269 (left, right) => Number(left.dataset.workspaceOrder) - Number(right.dataset.workspaceOrder),
270 );
271 if (areas.length === 0) return false;
272 const activeArea = doc.activeElement?.closest<HTMLElement>(AREA_SELECTOR);
273 const currentId = fromArea ?? activeArea?.dataset.workspaceArea ?? lastArea;
274 const current = areas.findIndex((area) => area.dataset.workspaceArea === currentId);
275 const index =
276 current === -1
277 ? direction === 1
278 ? 0
279 : areas.length - 1
280 : (current + direction + areas.length) % areas.length;
281 const area = areas[index]!;
282 return focusArea(area, true, false);
283 };
284​
285 // Focus that drops to the body because its element was hidden, made inert, disabled, or removed
286 // returns to a remounted control, else its area, or the composer for a finished timeline prompt.
287 const recover = (event: FocusEvent) => {
288 const lost = event.target;
289 if (!(lost instanceof HTMLElement) || event.relatedTarget) return;
290 const areaName = workspaceArea(lost)?.dataset.workspaceArea;
291 const recoveryArea = areaName === "conversation" ? "composer" : areaName;
292 const label = lost.getAttribute("aria-label");
293 requestAnimationFrame(() => {
294 if (
295 doc.activeElement !== doc.body ||
296 !doc.hasFocus() ||
297 (isWorkspaceElementVisible(lost) && !lost.matches(":disabled"))
298 )
299 return;
300 const remounted = Array.from(doc.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).find(
301 (element) =>
302 element.tagName === lost.tagName &&
303 ((lost.id && element.id === lost.id) ||
304 (label && element.getAttribute("aria-label") === label)) &&
305 isWorkspaceElementVisible(element) &&
306 !element.matches(":disabled"),
307 );
308 if (remounted) remounted.focus({ preventScroll: true });
309 else if (!recoveryArea || !focus(recoveryArea)) focus("content");
310 });
311 };
312 doc.addEventListener("focusout", recover);
313​
314 // A request names a destination React has not committed yet, so it retries as the
315 // DOM changes. User input cancels it, and it gives up after two seconds.
316 let pending: WorkspaceFocusTarget | undefined;
317 let frame = 0;
318 let deadline = 0;
319 let cancelOnInput = false;
320 const observer = new MutationObserver(() => schedule());
321 const cancelRequest = () => {
322 pending = undefined;
323 cancelOnInput = false;
324 observer.disconnect();
325 cancelAnimationFrame(frame);
326 clearTimeout(deadline);
327 };
328 const schedule = () => {
329 cancelAnimationFrame(frame);
330 frame = requestAnimationFrame(() => {
331 if (pending && focus(pending.area, pending.restore, pending.selector)) cancelRequest();
332 });
333 };
334 const request = (target: WorkspaceFocusTarget) => {
335 cancelRequest();
336 pending = target;
337 // The key or click that made the request may still be propagating.
338 queueMicrotask(() => {
339 if (pending === target) cancelOnInput = true;
340 });
341 observer.observe(doc.body, {
342 childList: true,
343 subtree: true,
344 attributes: true,
345 attributeFilter: ["inert", "aria-hidden", "data-workspace-unavailable"],
346 });
347 deadline = window.setTimeout(cancelRequest, 2000);
348 schedule();
349 };
350 const onUserInput = () => {
351 if (cancelOnInput) cancelRequest();
352 };
353 const onPointerDown = () => {
354 unmarkKeyboardArea();
355 onUserInput();
356 };
357 doc.addEventListener("pointerdown", onPointerDown, true);
358 doc.addEventListener("keydown", onUserInput, true);
359​
360 return {
361 move: (direction: 1 | -1, fromArea?: string, closingPalette = false) => {
362 cancelRequest();
363 return move(direction, fromArea, closingPalette);
364 },
365 focus,
366 request,
367 dispose: () => {
368 cancelRequest();
369 unmarkKeyboardArea();
370 doc.removeEventListener("focusin", remember);
371 doc.removeEventListener("focusout", recover);
372 doc.removeEventListener("pointerdown", onPointerDown, true);
373 doc.removeEventListener("keydown", onUserInput, true);
374 },
375 };
377​
378let controller: ReturnType<typeof createWorkspaceFocusController> | undefined;
379let focusRequestVersion = 0;
380export const getWorkspaceFocusRequestVersion = () => focusRequestVersion;
381​
382/**
383 * Starts workspace focus for the app: F6 area moves, focus requests, focus-loss recovery,
384 * focus into a panel the user opens, and focus into the Pull Requests page on entry.
385 */
386export function startWorkspaceFocus(router: AnyRouter): () => void {
387 const active = createWorkspaceFocusController(document);
388 controller = active;
389 const stopPanels = useRightPanelStore.subscribe((state, previous) => {
390 if (state.userActionRevisionByThreadKey === previous.userActionRevisionByThreadKey) return;
391 for (const [threadKey, revision] of Object.entries(state.userActionRevisionByThreadKey)) {
392 if (previous.userActionRevisionByThreadKey[threadKey] === revision) continue;
393 const next = state.byThreadKey[threadKey];
394 const before = previous.byThreadKey[threadKey];
395 if (next?.isOpen && (!before?.isOpen || next.activeSurfaceId !== before.activeSurfaceId))
396 requestWorkspaceFocus({ area: "panel" });
397 }
398 });
399 const stopRoute = router.subscribe("onResolved", ({ fromLocation, toLocation }) => {
400 if (toLocation.pathname === "/pull-requests" && fromLocation?.pathname !== "/pull-requests")
401 requestWorkspaceFocus({ area: "pull-requests" });
402 });
403 return () => {
404 stopPanels();
405 stopRoute();
406 active.dispose();
407 if (controller === active) controller = undefined;
408 };
410​
411/** Moves to the next or previous visible area. `fromArea` overrides the focused area. */
412export function moveWorkspaceFocus(direction: 1 | -1, fromArea?: string): boolean {
413 return controller?.move(direction, fromArea) ?? false;
415​
416/** Explicit user actions request focus after React has committed the destination. */
417export function requestWorkspaceFocus(target: WorkspaceFocusTarget) {
418 focusRequestVersion++;
419 controller?.request(target);
421​
422export function requestWorkspaceContentFocus() {
423 // Resolve after the layout commits: closing a maximized panel makes the
424 // composer available again, while hiding a sidebar may leave only the panel.
425 requestWorkspaceFocus({ area: "content" });
427​
428let pendingPaletteDirection: 1 | -1 | undefined;
429export function queueWorkspaceFocusFromPalette(direction: 1 | -1) {
430 pendingPaletteDirection = direction;
432​
433/** Runs at the palette's normal focus restoration milestone, after its focus trap closes. */
434export function restoreWorkspaceFocusFromPalette(): boolean {
435 if (pendingPaletteDirection === undefined) return false;
436 const direction = pendingPaletteDirection;
437 pendingPaletteDirection = undefined;
438 window.requestAnimationFrame(() => controller?.move(direction, undefined, true));
439 return true;