Foundations
Dragging
Press, move, release: telling a drag from a click, following the pointer, and letting people cancel.
MarkdownDragging is how people move things with their hands: a thing on a canvas, the edge of a column, a marquee, the view itself. Every drag is the same gesture — press, move, release — and every one has to tell a drag from a click, follow the pointer wherever it goes, and let people change their mind.
A press that becomes a drag
Press the puck and release: a click. Press and move a little farther: a drag, which follows the pointer out of the box. Hold Shift part-way to keep to one direction; press Escape to put it back.
Press the puck and move.
Code
"use client";
import { DRAG_THRESHOLD, useDrag } from "@publictheta/ui/primitives/drag.ts";
import type { Drag } from "@publictheta/ui/primitives/drag.ts";
import { focusRingClassName } from "@publictheta/ui/lib/focus-class.ts";
import { useState } from "react";
import type { KeyboardEvent } from "react";
import { Readout } from "../../ui/controls.tsx";
const HOME = { x: 24, y: 24 };
/** How far a drag has carried the puck: with Shift, along the direction it has gone farther in. */
const travel = (drag: Drag) =>
drag.shiftKey
? Math.abs(drag.dx) >= Math.abs(drag.dy)
? { x: drag.dx, y: 0 }
: { x: 0, y: drag.dy }
: { x: drag.dx, y: drag.dy };
export default function DragPuck() {
// Where the puck rests, and how far the drag in progress has carried it.
const [at, setAt] = useState(HOME);
const [carried, setCarried] = useState<{ x: number; y: number } | null>(null);
const [said, setSaid] = useState("Press the puck and move.");
const drag = useDrag({
onPress(press) {
const threshold =
press.pointerType === "mouse" || press.pointerType === "pen" ? press.pointerType : "touch";
setSaid(
`Pressed. A drag begins ${DRAG_THRESHOLD[threshold]} pixels from here; released sooner, it is a click.`,
);
},
onMove(move) {
const by = travel(move);
setCarried(by);
setSaid(
`Dragging: ${Math.round(by.x)}, ${Math.round(by.y)}${move.shiftKey ? " · Shift: one direction" : ""}`,
);
},
onEnd(end) {
const by = travel(end);
setAt({ x: at.x + by.x, y: at.y + by.y });
setCarried(null);
setSaid("Released: the drag is one change.");
},
onCancel() {
setCarried(null);
setSaid("Cancelled: back where it was.");
},
});
// A drag is never the only way: the arrow keys move the puck too.
function onKeyDown(event: KeyboardEvent): void {
const step = event.shiftKey ? 10 : 1;
const by = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[
event.key
];
if (by === undefined) return;
event.preventDefault();
setAt({ x: at.x + by[0]!, y: at.y + by[1]! });
setSaid("Moved by the keyboard.");
}
return (
<div className="w-full">
<div className="relative h-56 w-full overflow-clip rounded-lg border border-border-muted bg-surface">
{carried !== null && (
<div
aria-hidden
className="absolute size-14 rounded-full border border-dashed border-border"
style={{ left: at.x, top: at.y }}
/>
)}
<button
type="button"
aria-label="Puck"
{...drag.props}
onKeyDown={onKeyDown}
onClick={() => setSaid("Clicked: the press never became a drag.")}
// A finger's movement is the drag's, not the page's scroll.
className={`absolute size-14 touch-none rounded-full bg-selected text-on-selected select-none ${focusRingClassName}`}
style={{ left: at.x + (carried?.x ?? 0), top: at.y + (carried?.y ?? 0) }}
/>
</div>
<Readout>{said}</Readout>
</div>
);
}Best practices
Let a click stay a click. A press becomes a drag only after it has travelled a little way. A hand is not perfectly still when it presses; without this, every click would nudge what it clicked.
Fit the distance to the pointer. A mouse can be held to a few pixels; a pen slips a little as it lands; a fingertip covers many pixels and rolls as it presses. Wait for about 3 pixels from a mouse, 6 from a pen and 10 from a finger. Measure in a straight line from the press, so that a diagonal drag begins as soon as a level one.
Follow the pointer everywhere. Once a drag has begun it continues wherever the pointer goes — over other things, out of the area it began in — and ends where the pointer is released.
Measure from the press. Work out what a drag has done from how far the pointer is from where it was pressed, not from the last step it took. Steps accumulate error; a distance does not, and it lets a drag be put back exactly.
Read the modifier keys as they are now. A key held changes what a drag means — constrain, copy, move freely — and people press and release such keys in the middle of a drag. Apply the change at once, even while the pointer rests.
Always let people cancel. Escape abandons a drag and puts everything back as it was. So does anything that takes the pointer away: the system claiming a touch, a menu opening, the window losing the pointer.
Keep a cancelled drag from doing anything else. The Escape that cancels a drag does not also close the dialog the drag was in. The release that follows a drag — finished or cancelled — is not a click on what is under it.
One pointer, one drag. A second finger or pointer pressed during a drag does not take it over. The secondary button never begins a drag: it is for a menu.
Keep the page still. A drag does not select the text it passes over, start the browser's own drag of an image or a link, or scroll the page under a finger. Say which directions, if any, a finger may still scroll in.
Report a drag as one change. However many steps it took, a drag is one action to undo. Show its progress as it goes; record it when it ends.
Conventions
| Intent | Pointer | Keyboard | Touch |
|---|---|---|---|
| Begin | Press and move 3 pixels (a pen: 6) | — | Press and move 10 |
| Cancel | — | Escape | — |
| Change the meaning | Hold or release a modifier key | — | — |
| End | Release | — | Lift |
Whatever can be dragged can also be done from the keyboard, by other means: a drag is never the only way.
See also Snapping, Resizing and Pan and zoom.
Behaviour
Threshold
A press is a drag only once it has travelled past the threshold, in any direction
expect(pastThreshold(3, 0, 3)).toBe(false);
expect(pastThreshold(0, -3.1, 3)).toBe(true);
// Three across and three down is more than four away.
expect(pastThreshold(3, 3, 4)).toBe(true);
expect(dragDistance(3, 4)).toBe(5);The threshold is 3 pixels for a mouse, 6 for a pen and 10 for a finger
expect(DRAG_THRESHOLD).toEqual({ mouse: 3, pen: 6, touch: 10 });
expect(["mouse", "pen", "touch"].map(dragThreshold)).toEqual([3, 6, 10]);A pointer of an unknown kind is held to the coarsest threshold
expect(dragThreshold("")).toBe(10);A press released where it was made is a click, not a drag
const log: string[] = [];
const onClick = vi.fn();
await render(
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- hears the click a drag would swallow
<div onClick={onClick}>
<Pad log={log} />
</div>,
);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(2, 0, 1);
await commands.mouseUp();
expect(log).toEqual([]);
expect(onClick).toHaveBeenCalledOnce();A press that moves past the threshold becomes a drag, and says where it began
const onStart = vi.fn();
await render(<Pad onStart={onStart} />);
const { pad } = watch();
// Where the press itself said it was: an emulated phone does not put the
// mouse on the exact pixel asked for.
const pressed = { x: Number.NaN, y: Number.NaN };
pad.addEventListener(
"pointerdown",
(event) => {
pressed.x = event.clientX;
pressed.y = event.clientY;
},
{ capture: true },
);
const box = pad.getBoundingClientRect();
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(8, 0, 1);
expect(onStart).toHaveBeenCalledOnce();
const start = onStart.mock.calls[0]![0] as Drag;
expect(start.pointerType).toBe("mouse");
expect(start.startX).toBe(pressed.x);
expect(start.startY).toBe(pressed.y);
expect(Math.abs(pressed.x - (box.left + box.width / 2))).toBeLessThan(2);
expect(Math.abs(pressed.y - (box.top + box.height / 2))).toBeLessThan(2);
await commands.mouseUp();A finger must travel farther than a mouse before it dragsChromium · touch
Not yet written.
The drag
Each move reports the whole distance from the press
const log: string[] = [];
await render(<Pad log={log} />);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(10, 0, 1);
await commands.mouseMoveBy(10, 5, 1);
await commands.mouseMoveBy(-30, 0, 1);
expect(log).toEqual(["start 10,0", "move 10,0", "move 20,5", "move -10,5"]);
await commands.mouseUp();The element captures the pointer once the drag begins, and not before
await render(<Pad />);
const { pad, pointer } = watch();
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(2, 0, 1);
expect(pad.hasPointerCapture(pointer.id)).toBe(false);
await commands.mouseMoveBy(10, 0, 1);
expect(pad.hasPointerCapture(pointer.id)).toBe(true);
await commands.mouseUp();The drag goes on outside the element, and ends where the pointer is released
const log: string[] = [];
await render(<Pad log={log} />);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(0, 200, 4);
await commands.mouseUp();
expect(log.at(-1)).toBe("end 0,200");The modifier keys are reported as they are at each move
const onMove = vi.fn();
await render(<Pad onMove={onMove} />);
const { pad, pointer } = watch();
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(10, 0, 1);
expect((onMove.mock.lastCall![0] as Drag).shiftKey).toBe(false);
// A move that says Shift is held, with no key heard: the keys were pressed in another window.
const { x, y } = onMove.mock.lastCall![0] as Drag;
pad.dispatchEvent(
new PointerEvent("pointermove", {
pointerId: pointer.id,
bubbles: true,
clientX: x + 5,
clientY: y,
shiftKey: true,
}),
);
expect(onMove.mock.lastCall![0]).toMatchObject({ shiftKey: true, altKey: false });
await commands.mouseMoveBy(10, 0, 1);
expect((onMove.mock.lastCall![0] as Drag).shiftKey).toBe(false);
await commands.mouseUp();A modifier pressed or released while the pointer rests is reported at once
const onMove = vi.fn();
await render(<Pad onMove={onMove} />);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(10, 0, 1);
onMove.mockClear();
await commands.keyDown("Alt");
expect(onMove).toHaveBeenCalledOnce();
expect(onMove.mock.lastCall![0]).toMatchObject({ altKey: true, dx: expect.closeTo(10, 1), dy: 0 });
await commands.keyUp("Alt");
expect(onMove.mock.lastCall![0]).toMatchObject({ altKey: false, dx: expect.closeTo(10, 1) });
await commands.mouseUp();A drag does not end as a click
const onClick = vi.fn();
await render(
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- hears the click a drag would swallow
<div onClick={onClick}>
<Pad />
</div>,
);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(10, 0, 2);
await commands.mouseUp();
expect(onClick).not.toHaveBeenCalled();
// And the click after that one is heard again.
await commands.clickSelector(PAD);
expect(onClick).toHaveBeenCalledOnce();After a drag that no click follows, the next click is still heard
const log: string[] = [];
const onClick = vi.fn();
await render(
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- hears the click a drag would swallow
<div onClick={onClick}>
<Pad log={log} />
</div>,
);
const pad = document.querySelector<HTMLElement>(PAD)!;
// A drag made of events alone, as a finger's is when the system sends no click after it.
const finger = { pointerId: 41, bubbles: true, pointerType: "touch", clientX: 20, clientY: 20 };
pad.dispatchEvent(new PointerEvent("pointerdown", finger));
pad.dispatchEvent(new PointerEvent("pointermove", { ...finger, clientX: 60 }));
pad.dispatchEvent(new PointerEvent("pointerup", { ...finger, clientX: 60 }));
expect(log.at(-1)).toBe("end 40,0");
await new Promise((resolve) => setTimeout(resolve, 20));
await commands.clickSelector(PAD);
expect(onClick).toHaveBeenCalledOnce();Text is not selected by a drag across it
await render(<Pad />);
await commands.mouseDownAt(PAD, { x: 0.02, y: 0.08 });
await commands.mouseMoveBy(150, 0, 6);
await commands.mouseUp();
expect(getSelection()?.toString()).toBe("");The browser's own drag of an image or a link does not start
const log: string[] = [];
function Link() {
const drag = useDrag({ onEnd: (event) => log.push(line("end", event)) });
return (
<div data-testid="pad" {...drag.props} style={{ touchAction: "none" }}>
<a href="#somewhere" style={{ display: "block", width: 200, height: 60 }}>
A link
</a>
</div>
);
}
await render(<Link />);
// What an engine sends before it begins a drag of its own; refused, it begins none.
const begins = () => {
const event = new DragEvent("dragstart", { bubbles: true, cancelable: true });
document.querySelector("a")!.dispatchEvent(event);
return !event.defaultPrevented;
};
await commands.mouseDownAt(PAD);
expect(begins()).toBe(false);
await commands.mouseMoveBy(40, 30, 6);
expect(begins()).toBe(false);
await commands.mouseUp();
expect(log).toEqual(["end 40,30"]);
// Once the press is over, the link is a link again.
expect(begins()).toBe(true);Cancelling
Escape cancels the drag, which reports no distance: where to put things back
const log: string[] = [];
await render(<Pad log={log} />);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(30, 10, 2);
await commands.keyDown("Escape");
await commands.keyUp("Escape");
expect(log.at(-1)).toBe("cancel 0,0");
// What the pointer does afterwards is no longer a drag.
await commands.mouseMoveBy(30, 10, 2);
await commands.mouseUp();
expect(log.at(-1)).toBe("cancel 0,0");The release that follows a cancelled drag is not a click; the next press's is
const onClick = vi.fn();
await render(
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- hears the click a drag would swallow
<div onClick={onClick}>
<Pad />
</div>,
);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(10, 0, 2);
await commands.keyDown("Escape");
await commands.keyUp("Escape");
await commands.mouseUp();
expect(onClick).not.toHaveBeenCalled();
await commands.clickSelector(PAD);
expect(onClick).toHaveBeenCalledOnce();
// Cancelled by the system, no release comes: the click after the next press is heard.
const pad = document.querySelector<HTMLElement>(PAD)!;
const finger = { pointerId: 43, bubbles: true, pointerType: "touch", clientX: 20, clientY: 20 };
pad.dispatchEvent(new PointerEvent("pointerdown", finger));
pad.dispatchEvent(new PointerEvent("pointermove", { ...finger, clientX: 60 }));
pad.dispatchEvent(new PointerEvent("pointercancel", finger));
onClick.mockClear();
await commands.clickSelector(PAD);
expect(onClick).toHaveBeenCalledOnce();The Escape that cancels a drag is not heard by anything else
// Stands for a dialog around the element, which Escape would close.
const onKeyDown = vi.fn();
const stop = new AbortController();
document.addEventListener("keydown", onKeyDown, { signal: stop.signal });
await render(<Pad />);
await commands.clickSelector(PAD);
await commands.keyDown("Escape");
await commands.keyUp("Escape");
expect(onKeyDown).toHaveBeenCalledOnce();
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(30, 0, 2);
await commands.keyDown("Escape");
await commands.keyUp("Escape");
expect(onKeyDown).toHaveBeenCalledOnce();
await commands.mouseUp();
stop.abort();The system taking the pointer away cancels the drag
const log: string[] = [];
await render(<Pad log={log} />);
const { pad, pointer } = watch();
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(30, 0, 2);
pad.dispatchEvent(new PointerEvent("pointercancel", { pointerId: pointer.id, bubbles: true }));
expect(log.at(-1)).toBe("cancel 0,0");
await commands.mouseUp();Losing the capture cancels the drag
const log: string[] = [];
await render(<Pad log={log} />);
const { pad, pointer } = watch();
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(30, 0, 2);
pad.releasePointerCapture(pointer.id);
// The capture is given up when the pointer is next heard from.
await commands.mouseMoveBy(1, 0, 1);
await expect.poll(() => log.at(-1)).toBe("cancel 0,0");
await commands.mouseUp();A context menu cancels the drag
const log: string[] = [];
await render(<Pad log={log} />);
const { pad } = watch();
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(30, 0, 2);
pad.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
expect(log.at(-1)).toBe("cancel 0,0");
await commands.mouseUp();A context menu that is refused opens nothing, and cancels nothing
const log: string[] = [];
await render(<Pad log={log} />);
const { pad } = watch();
// As where a finger's hold means something of the element's own.
pad.addEventListener("contextmenu", (event) => event.preventDefault());
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(30, 0, 2);
pad.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
await commands.mouseMoveBy(10, 0, 1);
await commands.mouseUp();
expect(log.slice(-2)).toEqual(["move 40,0", "end 40,0"]);Pointers and buttons
A second pointer does not take over a drag in progress
const log: string[] = [];
await render(<Pad log={log} />);
const { pad, pointer } = watch();
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(20, 0, 1);
const first = pointer.id;
const other = { pointerId: first + 7, bubbles: true, pointerType: "touch", clientX: 5, clientY: 5 };
pad.dispatchEvent(new PointerEvent("pointerdown", other));
pad.dispatchEvent(new PointerEvent("pointermove", { ...other, clientX: 400, clientY: 400 }));
pad.dispatchEvent(new PointerEvent("pointerup", { ...other, clientX: 400, clientY: 400 }));
expect(log.at(-1)).toBe("move 20,0");
await commands.mouseMoveBy(5, 0, 1);
await commands.mouseUp();
expect(log.slice(-2)).toEqual(["move 25,0", "end 25,0"]);The secondary button never drags
const log: string[] = [];
await render(<Pad log={log} buttons={[0, 1, 2]} />);
// Kept from the document: where a menu opens on release, nothing else would stop the drag before it.
document.querySelector(PAD)!.addEventListener(
"contextmenu",
(event) => {
event.preventDefault();
event.stopPropagation();
},
{ once: true },
);
await commands.mouseDownAt(PAD, { button: "right" });
await commands.mouseMoveBy(40, 0, 3);
await commands.mouseUp("right");
expect(log).toEqual([]);The middle button drags only where it is asked to
const log: string[] = [];
const screen = await render(<Pad log={log} />);
await commands.mouseDownAt(PAD, { button: "middle" });
await commands.mouseMoveBy(40, 0, 3);
await commands.mouseUp("middle");
expect(log).toEqual([]);
await screen.rerender(<Pad log={log} buttons={[1]} />);
await commands.mouseDownAt(PAD, { button: "middle" });
await commands.mouseMoveBy(40, 0, 3);
await commands.mouseUp("middle");
expect(log.at(-1)).toBe("end 40,0");API
onPress hears every press that could drag, and declines one by returning false
const log: string[] = [];
const onPress = vi.fn(() => false);
await render(<Pad log={log} onPress={onPress} />);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(40, 0, 3);
await commands.mouseUp();
expect(onPress).toHaveBeenCalledOnce();
expect(onPress.mock.calls[0]).toMatchObject([{ button: 0, pointerType: "mouse", shiftKey: false }]);
expect(log).toEqual([]);disabled leaves every press alone
const log: string[] = [];
await render(<Pad log={log} disabled />);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(40, 0, 3);
await commands.mouseUp();
expect(log).toEqual([]);threshold sets how far a press travels before it drags
const log: string[] = [];
await render(<Pad log={log} threshold={30} />);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(20, 0, 2);
expect(log).toEqual([]);
await commands.mouseMoveBy(20, 0, 1);
expect(log[0]).toBe("start 40,0");
await commands.mouseUp();cancel abandons the drag in progress
const log: string[] = [];
const held = { cancel: () => {} };
function Cancellable() {
const drag = useDrag({ onCancel: (event) => log.push(line("cancel", event)) });
useEffect(() => {
held.cancel = drag.cancel;
});
return (
<div data-testid="pad" {...drag.props} style={{ width: 200, height: 100, touchAction: "none" }} />
);
}
await render(<Cancellable />);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(20, 0, 2);
held.cancel();
expect(log).toEqual(["cancel 0,0"]);
await commands.mouseUp();Unmounting in the middle of a drag leaves no listener behind, and reports nothing more
const log: string[] = [];
const screen = await render(<Pad log={log} />);
const selecting = () => {
const event = new Event("selectstart", { bubbles: true, cancelable: true });
document.dispatchEvent(event);
return event.defaultPrevented;
};
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(20, 0, 1);
expect(selecting()).toBe(true);
await screen.unmount();
const before = log.length;
expect(selecting()).toBe(false);
await commands.mouseMoveBy(20, 0, 2);
await commands.mouseUp();
expect(log.length).toBe(before);React
Install
npx shadcn@latest add https://ui.publictheta.com/r/drag.jsonSource
src/primitives/drag.ts
/**
* The drag every direct manipulation shares: moving a thing, resizing it,
* drawing a marquee, pulling a view about. A press becomes a drag only once it
* has travelled past a threshold, so that a click stays a click.
*
* - The threshold is a distance on the screen, finer for a mouse (3 pixels)
* than for a pen (6) or a finger (10), which cannot be put down as exactly.
* - Once a drag begins the element captures the pointer: the drag goes on
* wherever the pointer travels, and ends wherever it is released.
* - Each report carries the whole distance travelled since the press, and the
* modifier keys as they are at that moment. A modifier pressed or released
* while the pointer rests is reported at once, as a move of no distance.
* - Escape cancels, and so do the system taking the pointer away, the element
* losing its capture, and a context menu opening. A cancelled drag is reported with
* no distance: where to put things back.
* - One pointer drags. A second pointer pressed meanwhile is not heard, and
* the secondary button never drags.
* - While the press lasts, text is not selected and the browser's own drag of
* an image or a link does not start; and a drag does not end as a click,
* whether it was finished or cancelled.
*
* The element must have `touch-action: none` (or `pan-x` / `pan-y` to keep one
* axis for scrolling): without it a browser takes a finger's movement for
* itself, as a scroll, and cancels the drag.
*/
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
/** How far a press travels, in screen pixels, before it is a drag. */
export const DRAG_THRESHOLD = { mouse: 3, pen: 6, touch: 10 } as const;
/** The threshold for a kind of pointer; one that is not known is held to the coarsest. */
export function dragThreshold(pointerType: string): number {
return pointerType === "mouse" || pointerType === "pen"
? DRAG_THRESHOLD[pointerType]
: DRAG_THRESHOLD.touch;
}
/** How far a pointer is from where it was pressed, in a straight line. */
export function dragDistance(dx: number, dy: number): number {
return Math.hypot(dx, dy);
}
/** Whether a press that has travelled this far is a drag: only once it is past the threshold. */
export function pastThreshold(dx: number, dy: number, threshold: number): boolean {
return dx * dx + dy * dy > threshold * threshold;
}
export interface DragModifiers {
readonly shiftKey: boolean;
readonly altKey: boolean;
readonly ctrlKey: boolean;
readonly metaKey: boolean;
}
/** A press that may become a drag. */
export interface DragPress extends DragModifiers {
/** Where the press is, in the viewport's coordinates. */
readonly x: number;
readonly y: number;
readonly pointerType: string;
readonly button: number;
/** What was pressed, and the element whose drag this is. */
readonly target: EventTarget | null;
readonly currentTarget: HTMLElement;
}
/** A drag, at one moment of it. */
export interface Drag extends DragModifiers {
/** Where the pointer is, in the viewport's coordinates. */
readonly x: number;
readonly y: number;
readonly startX: number;
readonly startY: number;
/** The whole distance from the press, on the screen. */
readonly dx: number;
readonly dy: number;
readonly pointerType: string;
readonly button: number;
}
export interface DragOptions {
readonly disabled?: boolean;
/** Screen pixels before a press is a drag; by default, what the pointer's kind asks for. */
readonly threshold?: number | ((pointerType: string) => number);
/** The buttons that drag: the primary one by default. The secondary button never does. */
readonly buttons?: readonly number[];
/** A press that could become a drag. Return `false` to have nothing to do with it. */
readonly onPress?: (press: DragPress) => boolean | void;
readonly onStart?: (drag: Drag) => void;
readonly onMove?: (drag: Drag) => void;
readonly onEnd?: (drag: Drag) => void;
/** The drag was abandoned: `dx` and `dy` are 0, and `x` and `y` where it began. */
readonly onCancel?: (drag: Drag) => void;
}
export interface DragProps {
readonly onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;
readonly onLostPointerCapture: (event: ReactPointerEvent<HTMLElement>) => void;
}
export interface DragResult {
/** To spread on the element that is dragged from. */
readonly props: DragProps;
/** Abandon the press or the drag in progress, if there is one. */
readonly cancel: () => void;
/** Whether a drag — not merely a press — is in progress. */
readonly dragging: () => boolean;
}
interface Gesture {
readonly pointerId: number;
readonly element: HTMLElement;
readonly startX: number;
readonly startY: number;
readonly pointerType: string;
readonly button: number;
readonly threshold: number;
x: number;
y: number;
modifiers: DragModifiers;
dragging: boolean;
/** Takes the document's listeners away. */
detach: () => void;
}
const PRIMARY: readonly number[] = [0];
const SECONDARY = 2;
const MODIFIER_KEYS = new Set(["Shift", "Alt", "Control", "Meta"]);
function modifiersOf(event: DragModifiers): DragModifiers {
return { shiftKey: event.shiftKey, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey };
}
function report(gesture: Gesture): Drag {
return {
...gesture.modifiers,
x: gesture.x,
y: gesture.y,
startX: gesture.startX,
startY: gesture.startY,
dx: gesture.x - gesture.startX,
dy: gesture.y - gesture.startY,
pointerType: gesture.pointerType,
button: gesture.button,
};
}
/** The click that a browser sends after a release is not let through: the drag was the gesture. */
function swallow(event: MouseEvent): void {
event.stopPropagation();
event.preventDefault();
}
function swallowClick(document: Document): void {
document.addEventListener("click", swallow, { capture: true, once: true });
// The click, if one comes, comes before any timer.
setTimeout(() => document.removeEventListener("click", swallow, { capture: true }), 0);
}
export function useDrag(options: DragOptions): DragResult {
// Read when an event arrives, so handlers written inline are always the latest.
const latest = useRef(options);
useEffect(() => {
latest.current = options;
});
const gesture = useRef<Gesture | null>(null);
const finish = useCallback((how: "end" | "cancel") => {
const current = gesture.current;
if (current === null) return;
gesture.current = null;
current.detach();
if (!current.dragging) return;
if (current.element.hasPointerCapture(current.pointerId)) {
current.element.releasePointerCapture(current.pointerId);
}
if (how === "end") {
swallowClick(current.element.ownerDocument);
latest.current.onEnd?.(report(current));
return;
}
// The button may still be down: the click its release sends is no more a click than the drag's would be.
const document = current.element.ownerDocument;
const stop = new AbortController();
document.addEventListener(
"click",
(event) => {
swallow(event);
stop.abort();
},
{ capture: true, signal: stop.signal },
);
// A click after a new press is that press's own.
document.addEventListener("pointerdown", () => stop.abort(), { capture: true, signal: stop.signal });
current.x = current.startX;
current.y = current.startY;
latest.current.onCancel?.(report(current));
}, []);
// Unmounted in the middle of a drag: nothing is reported to what is gone, and no listener stays.
useEffect(
() => () => {
gesture.current?.detach();
gesture.current = null;
},
[],
);
const onPointerDown = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {
const { disabled = false, buttons = PRIMARY, threshold, onPress } = latest.current;
// A second pointer does not take over the first one's drag.
if (gesture.current !== null || disabled) return;
if (event.button === SECONDARY || !buttons.includes(event.button)) return;
const element = event.currentTarget;
const press: DragPress = {
...modifiersOf(event),
x: event.clientX,
y: event.clientY,
pointerType: event.pointerType,
button: event.button,
target: event.target,
currentTarget: element,
};
if (onPress?.(press) === false) return;
const document = element.ownerDocument;
const { pointerId } = event;
const controller = new AbortController();
const current: Gesture = {
pointerId,
element,
startX: event.clientX,
startY: event.clientY,
pointerType: event.pointerType,
button: event.button,
threshold:
typeof threshold === "function"
? threshold(event.pointerType)
: (threshold ?? dragThreshold(event.pointerType)),
x: event.clientX,
y: event.clientY,
modifiers: modifiersOf(event),
dragging: false,
detach: () => controller.abort(),
};
gesture.current = current;
const listen = <K extends keyof DocumentEventMap>(
type: K,
listener: (event: DocumentEventMap[K]) => void,
capture = false,
) => document.addEventListener(type, listener, { capture, signal: controller.signal });
// On the document, not the element: before the drag begins nothing is
// captured, and a press near an edge leaves the element within the threshold.
listen("pointermove", (move) => {
if (move.pointerId !== pointerId) return;
current.x = move.clientX;
current.y = move.clientY;
current.modifiers = modifiersOf(move);
if (!current.dragging) {
if (!pastThreshold(current.x - current.startX, current.y - current.startY, current.threshold))
return;
current.dragging = true;
// A pointer made by a script, or already gone, cannot be captured; the document still hears it.
try {
element.setPointerCapture(pointerId);
} catch {
// Nothing to do: the drag goes on without the capture.
}
latest.current.onStart?.(report(current));
}
latest.current.onMove?.(report(current));
});
listen("pointerup", (up) => {
if (up.pointerId !== pointerId) return;
current.x = up.clientX;
current.y = up.clientY;
current.modifiers = modifiersOf(up);
finish("end");
});
listen("pointercancel", (cancel) => {
if (cancel.pointerId === pointerId) finish("cancel");
});
// A menu that was refused — a finger's hold, where the hold means something else — opens nothing, and ends nothing.
listen("contextmenu", (menu) => {
if (!menu.defaultPrevented) finish("cancel");
});
const onKey = (key: KeyboardEvent) => {
if (key.type === "keydown" && key.key === "Escape") {
// Escape is the drag's own while there is one: it does not also close or clear what is around it.
if (current.dragging) {
key.preventDefault();
key.stopPropagation();
}
finish("cancel");
return;
}
if (!current.dragging || !MODIFIER_KEYS.has(key.key)) return;
current.modifiers = modifiersOf(key);
latest.current.onMove?.(report(current));
};
listen("keydown", onKey, true);
listen("keyup", onKey, true);
listen("selectstart", (select) => select.preventDefault());
listen("dragstart", (native) => native.preventDefault());
},
[finish],
);
const onLostPointerCapture = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {
// After a release the gesture is already over, and this is only the capture going with it.
if (gesture.current?.pointerId === event.pointerId && gesture.current.dragging) finish("cancel");
},
[finish],
);
return useMemo(
() => ({
props: { onPointerDown, onLostPointerCapture },
cancel: () => finish("cancel"),
dragging: () => gesture.current?.dragging === true,
}),
[onPointerDown, onLostPointerCapture, finish],
);
}