fix(tab): avoid accidental drag on slight tab movement
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user