Skip to content

Commit 2e5b8ae

Browse files
Spikeysanjuclaude
andcommitted
fix: use document pointermove + getBoundingClientRect for touch-compatible hover detection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3bc6f75 commit 2e5b8ae

2 files changed

Lines changed: 131 additions & 16 deletions

File tree

src/lib/actions/droppable.spec.ts

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -419,47 +419,120 @@ describe('droppable', () => {
419419
});
420420
});
421421

422-
describe('Pointer events for touch/mouse fallback', () => {
423-
it('should call onDragEnter on pointerover when dragging', () => {
422+
describe('Pointer events for touch/mouse fallback (document pointermove)', () => {
423+
beforeEach(() => {
424+
// Give the node a known bounding rect: x:10, y:10 → x:110, y:110
425+
vi.spyOn(node, 'getBoundingClientRect').mockReturnValue({
426+
left: 10, top: 10, right: 110, bottom: 110,
427+
width: 100, height: 100, x: 10, y: 10,
428+
toJSON: () => ({})
429+
});
430+
});
431+
432+
function dispatchDocumentPointerMove(clientX: number, clientY: number) {
433+
const event = new PointerEvent('pointermove', { bubbles: false, cancelable: false });
434+
Object.defineProperty(event, 'clientX', { value: clientX });
435+
Object.defineProperty(event, 'clientY', { value: clientY });
436+
document.dispatchEvent(event);
437+
}
438+
439+
it('should set targetContainer and add drag-over class when pointer enters bounds', () => {
440+
const action = droppable(node, {
441+
container: 'test',
442+
attributes: { dragOverClass: 'drag-over' }
443+
});
444+
445+
dndState.isDragging = true;
446+
dispatchDocumentPointerMove(60, 60); // inside 10-110 bounds
447+
448+
expect(dndState.targetContainer).toBe('test');
449+
expect(node.classList.contains('drag-over')).toBe(true);
450+
451+
action.destroy();
452+
});
453+
454+
it('should call onDragEnter only once when pointer enters bounds', () => {
424455
const onDragEnter = vi.fn();
425456
const action = droppable(node, {
426457
container: 'test',
427458
callbacks: { onDragEnter }
428459
});
429460

430461
dndState.isDragging = true;
431-
node.dispatchEvent(new PointerEvent('pointerover', { bubbles: true }));
462+
dispatchDocumentPointerMove(60, 60); // enter
463+
dispatchDocumentPointerMove(70, 70); // still inside — should not fire again
464+
465+
expect(onDragEnter).toHaveBeenCalledTimes(1);
432466

433-
expect(onDragEnter).toHaveBeenCalled();
434467
action.destroy();
435468
});
436469

437-
it('should not call onDragEnter on pointerover when not dragging', () => {
470+
it('should not call onDragEnter when pointer is outside bounds', () => {
438471
const onDragEnter = vi.fn();
439472
const action = droppable(node, {
440473
container: 'test',
441474
callbacks: { onDragEnter }
442475
});
443476

477+
dndState.isDragging = true;
478+
dispatchDocumentPointerMove(5, 5); // outside bounds (left: 10)
479+
480+
expect(onDragEnter).not.toHaveBeenCalled();
481+
482+
action.destroy();
483+
});
484+
485+
it('should call onDragLeave and clear targetContainer when pointer leaves bounds', () => {
486+
const onDragLeave = vi.fn();
487+
const action = droppable(node, {
488+
container: 'test',
489+
callbacks: { onDragLeave }
490+
});
491+
492+
dndState.isDragging = true;
493+
dispatchDocumentPointerMove(60, 60); // enter
494+
dispatchDocumentPointerMove(5, 5); // leave
495+
496+
expect(onDragLeave).toHaveBeenCalledTimes(1);
497+
expect(dndState.targetContainer).toBeNull();
498+
expect(node.classList.contains('drag-over')).toBe(false);
499+
500+
action.destroy();
501+
});
502+
503+
it('should not fire any events when not dragging', () => {
504+
const onDragEnter = vi.fn();
505+
const onDragLeave = vi.fn();
506+
const action = droppable(node, {
507+
container: 'test',
508+
callbacks: { onDragEnter, onDragLeave }
509+
});
510+
444511
dndState.isDragging = false;
445-
node.dispatchEvent(new PointerEvent('pointerover', { bubbles: true }));
512+
dispatchDocumentPointerMove(60, 60);
446513

447514
expect(onDragEnter).not.toHaveBeenCalled();
515+
expect(onDragLeave).not.toHaveBeenCalled();
516+
448517
action.destroy();
449518
});
450519

451-
it('should call onDragLeave on pointerout when dragging', () => {
520+
it('should not call onDragLeave when leaving a different container', () => {
452521
const onDragLeave = vi.fn();
453522
const action = droppable(node, {
454523
container: 'test',
455524
callbacks: { onDragLeave }
456525
});
457526

458527
dndState.isDragging = true;
459-
dndState.targetContainer = 'test';
460-
node.dispatchEvent(new PointerEvent('pointerout', { bubbles: true }));
528+
dndState.targetContainer = 'other-container'; // something else is active
529+
dispatchDocumentPointerMove(60, 60); // enter this node
530+
dndState.targetContainer = 'other-container'; // simulate another container taking over
531+
dispatchDocumentPointerMove(5, 5); // leave this node bounds
532+
533+
// onDragLeave should not fire because targetContainer !== 'test'
534+
expect(onDragLeave).not.toHaveBeenCalled();
461535

462-
expect(onDragLeave).toHaveBeenCalled();
463536
action.destroy();
464537
});
465538
});

src/lib/actions/droppable.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ export function droppable<T>(node: HTMLElement, options: DragDropOptions<T>) {
8888
*/
8989
let indicatorClass: 'drop-before' | 'drop-after' | 'drop-left' | 'drop-right' = 'drop-before';
9090

91+
/**
92+
* Tracks whether the pointer was inside this droppable on the previous
93+
* pointermove tick. Used to fire onDragEnter/onDragLeave on transitions only.
94+
*/
95+
let wasOver = false;
96+
9197
/**
9298
* Calculates whether the drop should be positioned before or after the target.
9399
*
@@ -224,6 +230,44 @@ export function droppable<T>(node: HTMLElement, options: DragDropOptions<T>) {
224230
dndState.dropPosition = null;
225231
}
226232

233+
/**
234+
* Handles document-level pointermove for touch-compatible hover detection.
235+
*
236+
* pointerover/pointerout do not fire on elements beneath the finger during
237+
* a touch drag — the pointer stays captured to the element where touch began.
238+
* This handler uses getBoundingClientRect to detect hover by coordinates,
239+
* which works on both mouse and touch devices.
240+
*
241+
* Fires onDragEnter/onDragLeave callbacks only on the transition between
242+
* inside/outside, not on every pointermove tick.
243+
*/
244+
function handleDocumentPointerMove(event: PointerEvent) {
245+
if (options.disabled || !dndState.isDragging) return;
246+
247+
const rect = node.getBoundingClientRect();
248+
const isOver =
249+
event.clientX >= rect.left &&
250+
event.clientX <= rect.right &&
251+
event.clientY >= rect.top &&
252+
event.clientY <= rect.bottom;
253+
254+
if (isOver) {
255+
dndState.targetContainer = options.container;
256+
node.classList.add(...dragOverClass);
257+
updateDropIndicator(event.clientY, event.clientX);
258+
if (!wasOver) {
259+
options.callbacks?.onDragEnter?.(dndState as DragDropState<T>);
260+
}
261+
} else if (wasOver && dndState.targetContainer === options.container) {
262+
dndState.targetContainer = null;
263+
node.classList.remove(...dragOverClass);
264+
clearDropIndicator();
265+
options.callbacks?.onDragLeave?.(dndState as DragDropState<T>);
266+
}
267+
268+
wasOver = isOver;
269+
}
270+
227271
/**
228272
* Handles dragenter - item dragged into this drop zone.
229273
*
@@ -365,6 +409,7 @@ export function droppable<T>(node: HTMLElement, options: DragDropOptions<T>) {
365409
dragEnterCounter = 0;
366410
node.classList.remove(...dragOverClass);
367411
clearDropIndicator();
412+
wasOver = false;
368413
}
369414

370415
/**
@@ -378,6 +423,7 @@ export function droppable<T>(node: HTMLElement, options: DragDropOptions<T>) {
378423
dragEnterCounter = 0;
379424
node.classList.remove(...dragOverClass);
380425
clearDropIndicator();
426+
wasOver = false;
381427
}
382428

383429
/**
@@ -473,9 +519,7 @@ export function droppable<T>(node: HTMLElement, options: DragDropOptions<T>) {
473519
document.addEventListener('dragend', handleGlobalDragEnd);
474520

475521
// Pointer events for custom drag support
476-
node.addEventListener('pointerover', handlePointerOver);
477-
node.addEventListener('pointermove', handlePointerMove);
478-
node.addEventListener('pointerout', handlePointerOut);
522+
document.addEventListener('pointermove', handleDocumentPointerMove);
479523
node.addEventListener('pointerdrop-on-container', handlePointerDropOnContainer as EventListener);
480524

481525
// Return Svelte action lifecycle methods
@@ -506,9 +550,7 @@ export function droppable<T>(node: HTMLElement, options: DragDropOptions<T>) {
506550
);
507551
document.removeEventListener('dragenter', handleDocumentDragEnter);
508552
document.removeEventListener('dragend', handleGlobalDragEnd);
509-
node.removeEventListener('pointerover', handlePointerOver);
510-
node.removeEventListener('pointermove', handlePointerMove);
511-
node.removeEventListener('pointerout', handlePointerOut);
553+
document.removeEventListener('pointermove', handleDocumentPointerMove);
512554
node.removeEventListener(
513555
'pointerdrop-on-container',
514556
handlePointerDropOnContainer as EventListener

0 commit comments

Comments
 (0)