fix(tabs): prevent trackpad taps from starting tab drag
This commit is contained in:
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
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user