fix(tab): avoid accidental drag on slight tab movement

This commit is contained in:
zipg authored and GitHub committed 2026-08-09 13:57:02 +08:00
1 parent d72d4beed3
commit 5c343ab416
5 files changed
+132 -18

No files matched your search

@@ -0,0 +1,93 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { TAB_DRAG_HORIZONTAL_THRESHOLD, useTabDrag } from "@/composables/useTabDrag";
function createDragHarness(onDrop = vi.fn(() => true)) {
const drag = useTabDrag(onDrop);
const source = document.createElement("div");
source.innerHTML = '<span class="truncate">Source</span>';
source.addEventListener("mousedown", (event) => drag.startDrag(event, "source"));
document.body.appendChild(source);
return { drag, source, onDrop };
}
function beginDrag(source: HTMLElement, x = 100, y = 20) {
source.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: x, clientY: y }));
}
function movePointer(x: number, y = 20) {
document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: x, clientY: y }));
}
function releasePointer() {
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
}
function enterDropTarget(drag: ReturnType<typeof useTabDrag>, tabId = "target", x = 10) {
const target = document.createElement("div");
target.getBoundingClientRect = () => ({ x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 28, width: 100, height: 28, toJSON: () => ({}) });
target.addEventListener("mousemove", (event) => drag.updateTarget(event, tabId));
document.body.appendChild(target);
target.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: x, clientY: 20 }));
}
afterEach(() => {
releasePointer();
document.body.innerHTML = "";
document.body.style.cursor = "";
document.body.style.userSelect = "";
});
describe("useTabDrag", () => {
it("ignores sub-threshold horizontal movement and vertical pointer jitter", () => {
const { drag, source, onDrop } = createDragHarness();
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD - 1, 120);
expect(drag.state.active).toBe(false);
expect(drag.state.suppressClick).toBe(false);
expect(onDrop).not.toHaveBeenCalled();
});
it("does not suppress the click when a drag gesture has no valid drop target", () => {
const { drag, source, onDrop } = createDragHarness();
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
expect(drag.state.active).toBe(true);
releasePointer();
expect(onDrop).not.toHaveBeenCalled();
expect(drag.state.suppressClick).toBe(false);
});
it("does not suppress the click when the drop callback reports no reorder", () => {
const onDrop = vi.fn(() => false);
const { drag, source } = createDragHarness(onDrop);
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
enterDropTarget(drag);
releasePointer();
expect(onDrop).toHaveBeenCalledWith("source", "target", "before");
expect(drag.state.suppressClick).toBe(false);
});
it("suppresses the click only after a completed reorder", () => {
const { drag, source, onDrop } = createDragHarness();
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
enterDropTarget(drag, "target", 90);
releasePointer();
expect(onDrop).toHaveBeenCalledWith("source", "target", "after");
expect(drag.state.suppressClick).toBe(true);
beginDrag(source);
expect(drag.state.suppressClick).toBe(false);
});
});
+9 -10
View File
@@ -7,19 +7,19 @@ interface TabDragState {
draggedId: string | null;
targetId: string | null;
dropPosition: TabDropPosition | null;
wasDragged: boolean;
suppressClick: boolean;
startX: number;
startY: number;
}
const DRAG_THRESHOLD = 5;
export const TAB_DRAG_HORIZONTAL_THRESHOLD = 12;
const state = reactive<TabDragState>({
active: false,
draggedId: null,
targetId: null,
dropPosition: null,
wasDragged: false,
suppressClick: false,
startX: 0,
startY: 0,
});
@@ -30,7 +30,7 @@ let pending: {
y: number;
sourceEl: HTMLElement | null;
} | null = null;
let onDropCallback: ((draggedId: string, targetId: string, position: TabDropPosition) => void) | null = null;
let onDropCallback: ((draggedId: string, targetId: string, position: TabDropPosition) => boolean) | null = null;
let ghostEl: HTMLElement | null = null;
function createGhost(sourceEl: HTMLElement, x: number, y: number) {
@@ -79,10 +79,8 @@ function onMouseMove(event: MouseEvent) {
if (pending && !state.active) {
const dx = event.clientX - pending.x;
const dy = event.clientY - pending.y;
if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
if (Math.abs(dx) < TAB_DRAG_HORIZONTAL_THRESHOLD) return;
state.active = true;
state.wasDragged = true;
state.draggedId = pending.id;
state.startX = pending.x;
state.startY = pending.y;
@@ -100,8 +98,9 @@ function onMouseMove(event: MouseEvent) {
}
function onMouseUp() {
state.suppressClick = false;
if (state.active && state.draggedId && state.targetId && state.dropPosition && onDropCallback) {
onDropCallback(state.draggedId, state.targetId, state.dropPosition);
state.suppressClick = onDropCallback(state.draggedId, state.targetId, state.dropPosition);
}
reset();
}
@@ -128,7 +127,7 @@ function ensureListeners() {
listenersAttached = true;
}
export function useTabDrag(onDrop: (draggedId: string, targetId: string, position: TabDropPosition) => void) {
export function useTabDrag(onDrop: (draggedId: string, targetId: string, position: TabDropPosition) => boolean) {
ensureListeners();
onDropCallback = onDrop;
@@ -136,7 +135,7 @@ export function useTabDrag(onDrop: (draggedId: string, targetId: string, positio
if (event.button !== 0) return;
const target = event.target as HTMLElement;
if (target.closest("button, input, [data-tab-title-input]")) return;
state.wasDragged = false;
state.suppressClick = false;
const el = (event.currentTarget as HTMLElement) || null;
pending = { id: tabId, x: event.clientX, y: event.clientY, sourceEl: el };
}