Skip to content

Commit f726662

Browse files
authored
fix(desktop): reorder repo tabs via pointer events (mouse, touch, pen) + keyboard a11y (#121)
1 parent 9bf5ea4 commit f726662

7 files changed

Lines changed: 594 additions & 40 deletions

File tree

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
/**
2+
* RepoTabStrip.vue — pointer-based drag-to-reorder regression guards.
3+
*
4+
* Native HTML5 drag-and-drop is unreliable in the WebKit webviews that power
5+
* the packaged Tauri app, so tab reordering is driven by Pointer Events
6+
* instead (see the "Drag to reorder" comment block in the component) — one
7+
* code path for mouse, touch and pen. That hand-rolled state machine has
8+
* sharp edges a component test can pin down without a real browser: a click
9+
* near a tab's own edge must not itself register as a reorder, a drag must
10+
* only ever be committed by the button that started it, and a re-snapshot
11+
* mid-drag (scroll, resize) must not double-count the dragged tab's own live
12+
* offset.
13+
*
14+
* jsdom has no PointerEvent constructor and no setPointerCapture, so drags
15+
* are driven with plain `Event` objects carrying the same fields the
16+
* component reads off a real PointerEvent (clientX/clientY/button/pointerId)
17+
* — dispatchEvent doesn't care which Event subclass a listener was declared
18+
* against, only the `type` string. The component itself feature-detects
19+
* setPointerCapture, so it degrades to window-listener-only tracking under
20+
* jsdom exactly like it would in a webview that lacks pointer capture.
21+
*
22+
* Mounted with native `createApp` into jsdom (no @vue/test-utils dep),
23+
* mirroring MergeEditor.test / LlmTracePanel.test / LaunchpadView.test.
24+
*/
25+
26+
import { describe, it, expect, afterEach } from "vitest";
27+
import { createApp, h, nextTick, type App } from "vue";
28+
import RepoTabStrip from "../header/RepoTabStrip.vue";
29+
import type { RepoTab } from "../../composables/useRepoTabs";
30+
31+
function makeTabs(n: number): RepoTab[] {
32+
return Array.from({ length: n }, (_, i) => ({
33+
id: i + 1,
34+
path: `/repo-${i}`,
35+
name: `repo-${i}`,
36+
}));
37+
}
38+
39+
let app: App | null = null;
40+
let container: HTMLElement | null = null;
41+
42+
afterEach(() => {
43+
app?.unmount();
44+
app = null;
45+
container?.remove();
46+
container = null;
47+
});
48+
49+
interface Mounted {
50+
tabEls: HTMLElement[];
51+
reorders: Array<[number, number]>;
52+
}
53+
54+
function mount(tabs: RepoTab[]): Mounted {
55+
container = document.createElement("div");
56+
document.body.appendChild(container);
57+
const reorders: Array<[number, number]> = [];
58+
59+
app = createApp({
60+
render() {
61+
return h(RepoTabStrip, {
62+
tabs,
63+
activeTabId: tabs[0]?.id ?? null,
64+
onReorderTabs: (oldIndex: number, newIndex: number) => {
65+
reorders.push([oldIndex, newIndex]);
66+
},
67+
});
68+
},
69+
});
70+
app.mount(container);
71+
72+
const tabEls = Array.from(container.querySelectorAll<HTMLElement>(".repo-tab"));
73+
return { tabEls, reorders };
74+
}
75+
76+
/** Stubs the geometry each tab needs for the drag hit-test to run in jsdom. */
77+
function stubRects(tabEls: HTMLElement[], widths: number[], lefts?: number[]) {
78+
let left = 0;
79+
for (let i = 0; i < tabEls.length; i++) {
80+
const width = widths[i];
81+
const l = lefts ? lefts[i] : left;
82+
const rect = { left: l, right: l + width, top: 0, bottom: 30, width, height: 30 } as DOMRect;
83+
tabEls[i].getBoundingClientRect = () => rect;
84+
left += width;
85+
}
86+
}
87+
88+
let nextPointerId = 1;
89+
90+
function firePointer(
91+
target: EventTarget,
92+
type: string,
93+
opts: { clientX?: number; clientY?: number; button?: number; pointerId?: number },
94+
) {
95+
const event = new Event(type, { bubbles: true, cancelable: true });
96+
Object.defineProperty(event, "clientX", { get: () => opts.clientX ?? 0 });
97+
Object.defineProperty(event, "clientY", { get: () => opts.clientY ?? 15 });
98+
Object.defineProperty(event, "button", { get: () => opts.button ?? 0 });
99+
Object.defineProperty(event, "pointerId", { get: () => opts.pointerId ?? nextPointerId });
100+
target.dispatchEvent(event);
101+
}
102+
103+
describe("RepoTabStrip — drag to reorder", () => {
104+
it("a few pixels of wobble on a tab's right half does not reorder it", async () => {
105+
// Regression: the hit-test used to compare the raw cursor position
106+
// against each tab's center, so pressing the right half of a wide tab
107+
// put the cursor on the far side of its own center *before any
108+
// movement at all* — a subsequent few-pixel wobble (well under what
109+
// anyone would call a drag) was enough to swap it with its neighbour.
110+
const { tabEls, reorders } = mount(makeTabs(3));
111+
stubRects(tabEls, [100, 100, 100]);
112+
113+
// Press near the right edge of tab 0 (center is at x=50).
114+
firePointer(tabEls[0], "pointerdown", { clientX: 90 });
115+
// Wobble 5px right — just past the 4px drag threshold.
116+
firePointer(window, "pointermove", { clientX: 95 });
117+
firePointer(window, "pointerup", { clientX: 95 });
118+
await nextTick();
119+
120+
expect(reorders).toEqual([]);
121+
});
122+
123+
it("dragging a tab past its neighbour's center reorders it", async () => {
124+
const { tabEls, reorders } = mount(makeTabs(3));
125+
stubRects(tabEls, [100, 100, 100]);
126+
127+
// Press at the center of tab 0 and drag well past tab 1's center (150).
128+
firePointer(tabEls[0], "pointerdown", { clientX: 50 });
129+
firePointer(window, "pointermove", { clientX: 160 });
130+
firePointer(window, "pointerup", { clientX: 160 });
131+
await nextTick();
132+
133+
expect(reorders).toEqual([[0, 1]]);
134+
});
135+
136+
it("a right-click released mid-drag does not commit or cancel it", async () => {
137+
const { tabEls, reorders } = mount(makeTabs(3));
138+
stubRects(tabEls, [100, 100, 100]);
139+
140+
firePointer(tabEls[0], "pointerdown", { clientX: 50, button: 0 });
141+
firePointer(window, "pointermove", { clientX: 160 });
142+
// A right-button pointerup mid-drag must be ignored...
143+
firePointer(window, "pointerup", { clientX: 160, button: 2 });
144+
await nextTick();
145+
expect(reorders).toEqual([]);
146+
// ...the left-button release still ends the drag normally.
147+
firePointer(window, "pointerup", { clientX: 160, button: 0 });
148+
await nextTick();
149+
expect(reorders).toEqual([[0, 1]]);
150+
});
151+
152+
it("releasing far outside the strip cancels the drag instead of reordering", async () => {
153+
const { tabEls, reorders } = mount(makeTabs(3));
154+
stubRects(tabEls, [100, 100, 100]);
155+
156+
firePointer(tabEls[0], "pointerdown", { clientX: 50 });
157+
// Drag past tab 1's center but with the cursor far below the strip.
158+
firePointer(window, "pointermove", { clientX: 160, clientY: 500 });
159+
firePointer(window, "pointerup", { clientX: 160, clientY: 500 });
160+
await nextTick();
161+
162+
expect(reorders).toEqual([]);
163+
});
164+
165+
it("a window blur mid-drag cancels it", async () => {
166+
const { tabEls, reorders } = mount(makeTabs(3));
167+
stubRects(tabEls, [100, 100, 100]);
168+
169+
firePointer(tabEls[0], "pointerdown", { clientX: 50 });
170+
firePointer(window, "pointermove", { clientX: 160 });
171+
window.dispatchEvent(new Event("blur"));
172+
// The pointerup that follows (returning from another app) must not commit.
173+
firePointer(window, "pointerup", { clientX: 160 });
174+
await nextTick();
175+
176+
expect(reorders).toEqual([]);
177+
});
178+
179+
it("a pointercancel mid-drag (e.g. a touch gesture interrupted) cancels it", async () => {
180+
const { tabEls, reorders } = mount(makeTabs(3));
181+
stubRects(tabEls, [100, 100, 100]);
182+
183+
firePointer(tabEls[0], "pointerdown", { clientX: 50 });
184+
firePointer(window, "pointermove", { clientX: 160 });
185+
firePointer(window, "pointercancel", { clientX: 160 });
186+
firePointer(window, "pointerup", { clientX: 160 });
187+
await nextTick();
188+
189+
expect(reorders).toEqual([]);
190+
});
191+
192+
it("a resize mid-drag re-snapshots without double-counting the dragged tab's own offset", async () => {
193+
// Regression: re-snapshotting centers mid-drag (on scroll or resize) used
194+
// to re-measure the dragged tab's rect as-is — but that rect already
195+
// includes the live translateX a real browser would have painted, so the
196+
// offset got added to the hit-test a second time and the drop landed one
197+
// slot further than it should have.
198+
const { tabEls, reorders } = mount(makeTabs(3));
199+
// Resting centers: 50, 150, 250 (widths 100 each, lefts 0/100/200).
200+
stubRects(tabEls, [100, 100, 100]);
201+
202+
firePointer(tabEls[0], "pointerdown", { clientX: 50 });
203+
// Drag 100px right — didDrag flips true and snapshots the resting
204+
// centers; hoveredIndex is still 0 (hasn't crossed tab 1's center yet).
205+
firePointer(window, "pointermove", { clientX: 150 });
206+
207+
// Simulate the DOM now reflecting that 100px live translateX: tab 0's
208+
// rect has visually shifted right by 100, exactly as a real browser
209+
// would render it mid-drag (the other tabs don't move).
210+
stubRects(tabEls, [100, 100, 100], [100, 100, 200]);
211+
window.dispatchEvent(new Event("resize"));
212+
213+
// Move on to a total offset of 110px from the press point — enough to
214+
// cross tab 1's resting center (150) but not tab 2's (250) once the
215+
// resize re-snapshot has correctly stripped the baked-in offset back out.
216+
firePointer(window, "pointermove", { clientX: 160 });
217+
firePointer(window, "pointerup", { clientX: 160 });
218+
await nextTick();
219+
220+
expect(reorders).toEqual([[0, 1]]);
221+
});
222+
});
223+
224+
describe("RepoTabStrip — keyboard reordering", () => {
225+
function fireCtrlShiftArrow(target: EventTarget, key: "ArrowLeft" | "ArrowRight") {
226+
target.dispatchEvent(
227+
new KeyboardEvent("keydown", { key, ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true }),
228+
);
229+
}
230+
231+
it("Ctrl+Shift+ArrowRight moves the focused tab one slot right and announces it", async () => {
232+
const { tabEls, reorders } = mount(makeTabs(3));
233+
234+
fireCtrlShiftArrow(tabEls[0], "ArrowRight");
235+
await nextTick();
236+
237+
expect(reorders).toEqual([[0, 1]]);
238+
expect(container?.querySelector('[role="status"]')?.textContent).toBe("repo-0 moved to position 2 of 3");
239+
});
240+
241+
it("Ctrl+Shift+ArrowLeft moves the focused tab one slot left", async () => {
242+
const { tabEls, reorders } = mount(makeTabs(3));
243+
244+
fireCtrlShiftArrow(tabEls[1], "ArrowLeft");
245+
await nextTick();
246+
247+
expect(reorders).toEqual([[1, 0]]);
248+
});
249+
250+
it("does nothing at the boundary (first tab can't move left, last can't move right)", async () => {
251+
const { tabEls, reorders } = mount(makeTabs(3));
252+
253+
fireCtrlShiftArrow(tabEls[0], "ArrowLeft");
254+
fireCtrlShiftArrow(tabEls[2], "ArrowRight");
255+
await nextTick();
256+
257+
expect(reorders).toEqual([]);
258+
});
259+
260+
it("a plain ArrowRight (no modifier) does not reorder — only switches focus natively", async () => {
261+
const { tabEls, reorders } = mount(makeTabs(3));
262+
263+
tabEls[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }));
264+
await nextTick();
265+
266+
expect(reorders).toEqual([]);
267+
});
268+
269+
it("bare Ctrl+ArrowRight (no Shift) does not reorder", async () => {
270+
// Regression: a bare Ctrl+Arrow is macOS's default Mission Control
271+
// "switch Space" shortcut, intercepted by the OS before it would even
272+
// reach the app — Shift is required precisely to stay clear of that (and
273+
// of third-party window-manager shortcuts using the same bare combo).
274+
// This only guards our own handler's condition; it can't simulate the
275+
// OS swallowing the event outright.
276+
const { tabEls, reorders } = mount(makeTabs(3));
277+
278+
tabEls[0].dispatchEvent(
279+
new KeyboardEvent("keydown", { key: "ArrowRight", ctrlKey: true, bubbles: true, cancelable: true }),
280+
);
281+
await nextTick();
282+
283+
expect(reorders).toEqual([]);
284+
});
285+
});

0 commit comments

Comments
 (0)