Skip to content
Source
Sign inSign up

terminalFocus.browser.test.ts

204 lines7.1 KB

1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";
4​
5import { useRightPanelStore } from "../rightPanelStore";
6import { useTerminalUiStateStore } from "../terminalUiStateStore";
7​
8import {
9 consumePendingTerminalFocus,
10 focusTerminal,
11 focusTerminalOnNavigation,
12 focusTerminalViewport,
13 registerTerminalFocusTarget,
14 restoreFocusOnThreadEnter,
15} from "./terminalFocus";
16​
17// The drawer's collapsed shape: a 0fr grid row that clips the viewport, which
18// stays focusable, around a surface whose input refuses focus while hidden.
19function mountClippedViewport() {
20 const drawer = document.createElement("div");
21 drawer.style.cssText = "display:grid;grid-template-rows:0fr;overflow:clip";
22 const clip = document.createElement("div");
23 clip.style.cssText = "min-height:0;overflow:clip";
24 const mount = document.createElement("div");
25 mount.tabIndex = -1;
26 const input = document.createElement("textarea");
27 mount.append(input);
28 clip.append(mount);
29 drawer.append(clip);
30 document.body.append(drawer);
31 const state = { visible: false };
32 const surface = {
33 focus() {
34 if (state.visible) input.focus({ preventScroll: true });
35 },
36 };
37 const focus = () =>
38 focusTerminalViewport({ mount, surface, canOperate: true, visible: state.visible });
39 return {
40 input,
41 state,
42 focus,
43 [Symbol.dispose]: () => drawer.remove(),
44 };
46​
47describe("focusTerminalViewport", () => {
48 it("keeps a request made while the drawer is hidden until it shows", () => {
49 const ref = scopeThreadRef(EnvironmentId.make("env"), ThreadId.make("hidden-drawer"));
50 using viewport = mountClippedViewport();
51 const unregister = registerTerminalFocusTarget({
52 threadRef: ref,
53 terminalId: "a",
54 focus: viewport.focus,
55 });
56 using _unregister = { [Symbol.dispose]: unregister };
57​
58 focusTerminal(ref, "a");
59 viewport.state.visible = true;
60 consumePendingTerminalFocus(ref, "a");
61​
62 expect(document.activeElement).toBe(viewport.input);
63 });
64});
65​
66function enterOtherThread(threadId: string) {
67 restoreFocusOnThreadEnter(scopeThreadRef(EnvironmentId.make("env"), ThreadId.make(threadId)), {
68 root: document.createElement("div"),
69 focusComposer: () => {},
70 })();
72​
73function mountThread(threadId: string, environmentId = "env") {
74 const ref = scopeThreadRef(EnvironmentId.make(environmentId), ThreadId.make(threadId));
75 const root = document.createElement("div");
76 const composer = document.createElement("textarea");
77 composer.ariaLabel = "Composer";
78 composer.dataset.workspaceArea = "composer";
79 root.append(composer);
80 document.body.append(root);
81 const drawerState = { visible: true };
82 const mountTerminal = (id: string) => {
83 const input = document.createElement("textarea");
84 input.ariaLabel = id;
85 root.append(input);
86 const unregister = registerTerminalFocusTarget({
87 threadRef: ref,
88 terminalId: id,
89 element: input,
90 focus: () => {
91 if (id === "drawer" && !drawerState.visible) return false;
92 input.focus();
93 return document.activeElement === input;
94 },
95 });
96 return {
97 input,
98 [Symbol.dispose]() {
99 unregister();
100 input.remove();
101 },
102 };
103 };
104 useTerminalUiStateStore.getState().ensureTerminal(ref, "drawer", { open: true, active: true });
105 useRightPanelStore.getState().openTerminal(ref, "panel");
106 return {
107 ref,
108 root,
109 composer,
110 drawerState,
111 mountTerminal,
112 enter: () => restoreFocusOnThreadEnter(ref, { root, focusComposer: () => composer.focus() }),
113 [Symbol.dispose]: () => root.remove(),
114 };
116​
117describe("thread focus restoration", () => {
118 it.each(["composer", "drawer", "panel", "remounted composer"] as const)(
119 "restores %s focus when returning to a thread with both terminals open",
120 (control) => {
121 using thread = mountThread(`return-${control}`);
122 using drawer = thread.mountTerminal("drawer");
123 using panel = thread.mountTerminal("panel");
124 const leave = thread.enter();
125 if (control === "panel") document.body.append(panel.input);
126 const remount = control === "remounted composer";
127 const initial =
128 control === "composer" || remount
129 ? thread.composer
130 : control === "drawer"
131 ? drawer.input
132 : panel.input;
133 initial.focus();
134 expect(document.activeElement).toBe(initial);
135 if (remount) focusTerminal(thread.ref, "not-ready");
136 leave();
137 thread.root.hidden = true;
138 if (!remount) enterOtherThread("other-thread");
139 drawer[Symbol.dispose]();
140 panel[Symbol.dispose]();
141 if (remount) thread.root.remove();
142 using remountedThread = remount ? mountThread(`return-${control}`) : undefined;
143 const returnedThread = remountedThread ?? thread;
144 returnedThread.root.hidden = false;
145 using remountedDrawer = returnedThread.mountTerminal("drawer");
146 using remountedPanel = returnedThread.mountTerminal("panel");
147 if (control === "panel") document.body.append(remountedPanel.input);
148 using _leave = { [Symbol.dispose]: returnedThread.enter() };
149 const expected =
150 control === "composer" || remount
151 ? returnedThread.composer
152 : control === "drawer"
153 ? remountedDrawer.input
154 : remountedPanel.input;
155​
156 expect(document.activeElement).toBe(expected);
157 },
158 );
159​
160 it("keeps an explicit terminal navigation request ahead of remembered composer focus", () => {
161 using thread = mountThread("explicit-reveal");
162 using _drawer = thread.mountTerminal("drawer");
163 using panel = thread.mountTerminal("panel");
164 const leave = thread.enter();
165 thread.composer.focus();
166 leave();
167 enterOtherThread("before-reveal");
168​
169 focusTerminalOnNavigation(thread.ref, "panel");
170 using _leave = { [Symbol.dispose]: thread.enter() };
171​
172 expect(document.activeElement).toBe(panel.input);
173 });
174​
175 it("keeps an explicit drawer-open request while its viewport becomes visible", () => {
176 using thread = mountThread("drawer-open");
177 using drawer = thread.mountTerminal("drawer");
178 const leave = thread.enter();
179 thread.composer.focus();
180 thread.drawerState.visible = false;
181 useTerminalUiStateStore.getState().setTerminalOpen(thread.ref, false);
182 focusTerminal(thread.ref, "drawer");
183 useTerminalUiStateStore.getState().setTerminalOpen(thread.ref, true);
184 leave();
185 using _leave = { [Symbol.dispose]: thread.enter() };
186 thread.drawerState.visible = true;
187 consumePendingTerminalFocus(thread.ref, "drawer");
188​
189 expect(document.activeElement).toBe(drawer.input);
190 });
191​
192 it("falls back to the composer when the remembered drawer terminal was closed", () => {
193 using thread = mountThread("closed-remembered");
194 using drawer = thread.mountTerminal("drawer");
195 const leave = thread.enter();
196 expect(document.activeElement).toBe(drawer.input);
197 leave();
198 useTerminalUiStateStore.getState().setTerminalOpen(thread.ref, false);
199 thread.drawerState.visible = false;
200 using _leave = { [Symbol.dispose]: thread.enter() };
201​
202 expect(document.activeElement).toBe(thread.composer);
203 });
204});