fix(tabs): prevent trackpad taps from starting tab drag

This commit is contained in:
Weiki authored and GitHub committed 2026-09-03 18:10:26 +08:00
1 parent d765315af4
commit c28248cd67
3 files changed
+117 -22

No files matched your search

@@ -722,7 +722,7 @@ function handleTabMouseDown(event: PointerEvent, tabId: string) {
// Don't preventDefault touch pointerdowns: that would cancel the tab
// strip's native horizontal scroll, which is how touch users browse an
// overflowing tab bar.
if (event.pointerType !== "touch") event.preventDefault();
if (event.pointerType !== "touch" && (event.buttons & 1) === 1) event.preventDefault();
}
if (isManualTabOrder.value) tabDrag.startDrag(event, tabId);
}
+68 -8
View File
@@ -12,20 +12,20 @@ function createDragHarness(onDrop = vi.fn(() => true)) {
return { drag, source, onDrop };
}
function beginDrag(source: HTMLElement, x = 100, y = 20, pointerType = "mouse") {
source.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, clientX: x, clientY: y, pointerType }));
function beginDrag(source: HTMLElement, x = 100, y = 20, pointerType = "mouse", buttons = 1, pointerId = 1) {
source.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, buttons, clientX: x, clientY: y, pointerId, pointerType }));
}
function movePointer(x: number, y = 20, pointerType = "mouse") {
document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: x, clientY: y, pointerType }));
function movePointer(x: number, y = 20, pointerType = "mouse", buttons = 1, pointerId = 1) {
document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, buttons, clientX: x, clientY: y, pointerId, pointerType }));
}
function releasePointer(x = 0, y = 0) {
document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: x, clientY: y, screenX: x, screenY: y }));
function releasePointer(x = 0, y = 0, pointerId = 1) {
document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: x, clientY: y, pointerId, screenX: x, screenY: y }));
}
function cancelPointer() {
document.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true }));
function cancelPointer(pointerId = 1) {
document.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true, pointerId }));
}
function enterDropTarget(drag: ReturnType<typeof useTabDrag>, tabId = "target", x = 10) {
@@ -95,6 +95,20 @@ describe("useTabDrag", () => {
expect(drag.state.suppressClick).toBe(false);
});
it("clears completed-drag click suppression on a later trackpad tap", () => {
const { drag, source } = createDragHarness();
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
enterDropTarget(drag, "target", 90);
releasePointer();
expect(drag.state.suppressClick).toBe(true);
beginDrag(source, 100, 20, "mouse", 0);
expect(drag.state.active).toBe(false);
expect(drag.state.suppressClick).toBe(false);
});
it("ignores 18px of jitter, matching a typical touchscreen tap reported as generic mouse input", () => {
const { drag, source, onDrop } = createDragHarness();
beginDrag(source);
@@ -106,6 +120,52 @@ describe("useTabDrag", () => {
expect(onDrop).not.toHaveBeenCalled();
});
it("does not arm a drag when the primary button is not held", () => {
const { drag, source, onDrop } = createDragHarness();
beginDrag(source, 100, 20, "mouse", 0);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD, 20, "mouse", 0);
releasePointer();
expect(drag.state.active).toBe(false);
expect(onDrop).not.toHaveBeenCalled();
});
it("cancels a pending drag when the primary button is released before movement", () => {
const { drag, source, onDrop } = createDragHarness();
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD, 20, "mouse", 0);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD + 10);
expect(drag.state.active).toBe(false);
expect(onDrop).not.toHaveBeenCalled();
});
it("cancels an active drag when the primary button is no longer held", () => {
const { drag, source, onDrop } = createDragHarness();
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
expect(drag.state.active).toBe(true);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD + 10, 20, "mouse", 0);
expect(drag.state.active).toBe(false);
expect(drag.state.draggedId).toBe(null);
expect(document.body.style.cursor).toBe("");
expect(document.body.style.userSelect).toBe("");
expect(onDrop).not.toHaveBeenCalled();
});
it("ignores movement from a different pointer", () => {
const { drag, source, onDrop } = createDragHarness();
beginDrag(source, 100, 20, "mouse", 1, 7);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD, 20, "mouse", 1, 8);
expect(drag.state.active).toBe(false);
expect(onDrop).not.toHaveBeenCalled();
releasePointer(0, 0, 7);
});
it("never arms a drag for touch input, even past the horizontal threshold", () => {
const { drag, source, onDrop } = createDragHarness();
beginDrag(source, 100, 20, "touch");
+48 -13
View File
@@ -32,6 +32,7 @@ let pending: {
id: string;
x: number;
y: number;
pointerId: number;
sourceEl: HTMLElement | null;
} | null = null;
let onDropCallback: ((draggedId: string, targetId: string, position: TabDropPosition) => boolean) | null = null;
@@ -81,22 +82,39 @@ function removeGhost() {
}
}
function activateDrag(x: number, y: number) {
if (!pending) return;
activePointerId = pending.pointerId;
state.active = true;
state.draggedId = pending.id;
state.startX = pending.x;
state.startY = pending.y;
if (pending.sourceEl) {
ghostEl = createGhost(pending.sourceEl, x, y);
}
pending = null;
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
}
function onPointerMove(event: PointerEvent) {
if (!pending && !state.active) return;
const trackedPointerId = pending?.pointerId ?? activePointerId;
if (trackedPointerId !== null && trackedPointerId !== undefined && event.pointerId !== trackedPointerId) return;
// macOS reports a trackpad tap as a mouse pointer with button=0, but
// buttons=0. It is a click gesture, not a held primary-button drag.
if ((event.buttons & 1) !== 1) {
reset();
return;
}
if (pending && !state.active) {
const delta = dragAxis() === "vertical" ? event.clientY - pending.y : event.clientX - pending.x;
if (Math.abs(delta) < TAB_DRAG_HORIZONTAL_THRESHOLD) return;
state.active = true;
state.draggedId = pending.id;
state.startX = pending.x;
state.startY = pending.y;
if (pending.sourceEl) {
ghostEl = createGhost(pending.sourceEl, event.clientX, event.clientY);
}
pending = null;
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
activateDrag(event.clientX, event.clientY);
}
if (state.active) {
@@ -105,6 +123,9 @@ function onPointerMove(event: PointerEvent) {
}
function onMouseUp(event: PointerEvent) {
const trackedPointerId = pending?.pointerId ?? activePointerId;
if (trackedPointerId !== null && trackedPointerId !== undefined && event.pointerId !== trackedPointerId) return;
state.suppressClick = false;
if (state.active && state.draggedId && onDetachCallback && isOutsideDetachBounds(event.clientX, event.clientY)) {
const scale = typeof window !== "undefined" && Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1;
@@ -115,7 +136,10 @@ function onMouseUp(event: PointerEvent) {
reset();
}
function onPointerCancel() {
function onPointerCancel(event?: Event) {
const trackedPointerId = pending?.pointerId ?? activePointerId;
if (event && "pointerId" in event && trackedPointerId !== null && trackedPointerId !== undefined && (event as PointerEvent).pointerId !== trackedPointerId) return;
state.suppressClick = false;
reset();
}
@@ -132,6 +156,7 @@ function reset() {
state.dropPosition = null;
state.startX = 0;
state.startY = 0;
activePointerId = null;
pending = null;
removeGhost();
document.body.style.cursor = "";
@@ -139,6 +164,7 @@ function reset() {
}
let listenersAttached = false;
let activePointerId: number | null = null;
function ensureListeners() {
if (listenersAttached) return;
@@ -161,6 +187,10 @@ export function useTabDrag(onDrop: (draggedId: string, targetId: string, positio
function startDrag(event: PointerEvent, tabId: string) {
if (event.button !== 0) return;
// Consume click suppression on the next primary-button gesture even when
// macOS reports a trackpad tap with buttons=0 and no drag can be armed.
state.suppressClick = false;
if ((event.buttons & 1) !== 1) return;
// Touch input has no reliable equivalent of mouse jitter: a real finger's
// contact point commonly drifts well past TAB_DRAG_HORIZONTAL_THRESHOLD
// during an ordinary tap, which would misread the tap as a drag. Skip
@@ -169,9 +199,14 @@ export function useTabDrag(onDrop: (draggedId: string, targetId: string, positio
if (event.pointerType === "touch") return;
const target = event.target as HTMLElement;
if (target.closest("button, input, [data-tab-title-input]")) return;
state.suppressClick = false;
const el = (event.currentTarget as HTMLElement) || null;
pending = { id: tabId, x: event.clientX, y: event.clientY, sourceEl: el };
pending = {
id: tabId,
x: event.clientX,
y: event.clientY,
pointerId: event.pointerId,
sourceEl: el,
};
}
function updateTarget(event: MouseEvent, tabId: string) {