From ce774a286810f120466c56bce42d56531f32680c Mon Sep 17 00:00:00 2001 From: Maximilian Koeller Date: Thu, 11 Jun 2026 09:16:24 +0200 Subject: [PATCH] fix(header): allow sorting on mobile devices with reorderable enabled The touchstart handler was calling preventDefault() immediately, which prevented synthetic click events from firing. This blocked sorting when tapping headers on mobile devices with reorderable enabled. Removed preventDefault() from touchstart and added proper dragging state management to the draggable directive. Closes #692 --- .../datatable-draggable.directive.ts | 32 ++++++++++++++++--- .../directives/draggable.directive.spec.ts | 15 +++++++-- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/projects/ngx-datatable/src/lib/directives/datatable-draggable.directive.ts b/projects/ngx-datatable/src/lib/directives/datatable-draggable.directive.ts index e39e53c61..9f397a904 100644 --- a/projects/ngx-datatable/src/lib/directives/datatable-draggable.directive.ts +++ b/projects/ngx-datatable/src/lib/directives/datatable-draggable.directive.ts @@ -56,9 +56,11 @@ export class DatatableDraggableDirective implements OnDestroy { if (this.enabled()) { this.element.addEventListener('mousedown', this.mousedown); this.element.addEventListener('touchstart', this.touchstart); + this.element.addEventListener('contextmenu', this.contextmenu); this.removePointerListeners = () => { this.element.removeEventListener('mousedown', this.mousedown); this.element.removeEventListener('touchstart', this.touchstart); + this.element.removeEventListener('contextmenu', this.contextmenu); }; } else { this.removePointerListeners?.(); @@ -83,30 +85,44 @@ export class DatatableDraggableDirective implements OnDestroy { this.delay(this.dragStartDelay()).then(() => { this.document.addEventListener('mousemove', this.mousemove); this.starting(event.clientX, event.clientY); + this.setDragging(true); }); }; private mousemove = (event: MouseEvent): void => this.moving(event.clientX, event.clientY); + // Prevent context menu on long-press drag. Since we don't call preventDefault() on + // touchstart to allow click events (sorting), the browser would show a context menu + // after a long press. We prevent this when dragging is active. + private contextmenu = (event: MouseEvent): void => { + if (this.isDragging()) { + event.preventDefault(); + } + }; + protected readonly touchstart = (event: TouchEvent): void => { if (!this.enabled()) { return; } event.stopPropagation(); - event.preventDefault(); const touch = event.touches.item(0)!; this.touchId = touch.identifier; this.document.addEventListener('touchend', this.ending); this.delay(this.dragStartDelay()).then(() => { - this.document.addEventListener('touchmove', this.touchmove); - this.starting(touch.clientX, touch.clientY); + if (this.touchId === touch.identifier) { + this.document.addEventListener('touchmove', this.touchmove, { passive: false }); + this.starting(touch.clientX, touch.clientY); + this.setDragging(true); + } }); }; private touchmove = (event: TouchEvent): void => { - const touchMove = this.findTouch(event)!; + const touchMove = this.findTouch(event); if (touchMove) { + // Prevent scrolling and other default touch behaviors during drag + event.preventDefault(); this.moving(touchMove.clientX, touchMove.clientY); } }; @@ -139,10 +155,18 @@ export class DatatableDraggableDirective implements OnDestroy { // This function is also called if the long press was aborted before the delay. // In that case, we don't want to emit dragEnd. if (dragged) { + this.setDragging(false); this.dragEnd.emit(); } }; + private setDragging(dragging: boolean): void { + const model = this.dragModel(); + if (model) { + model.dragging = dragging; + } + } + private findTouch(event: TouchEvent): Touch | undefined { return Array.from(event.touches).find(touch => touch.identifier === this.touchId); } diff --git a/projects/ngx-datatable/src/lib/directives/draggable.directive.spec.ts b/projects/ngx-datatable/src/lib/directives/draggable.directive.spec.ts index e65984523..47b69fcb7 100644 --- a/projects/ngx-datatable/src/lib/directives/draggable.directive.spec.ts +++ b/projects/ngx-datatable/src/lib/directives/draggable.directive.spec.ts @@ -161,13 +161,16 @@ describe('DraggableDirective', () => { await fixture.whenStable(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it('should start dragging after the specified delay', async () => { vi.useFakeTimers(); await harness.touchStart(0); vi.advanceTimersByTime(100); await fixture.whenStable(); expect(dragStartSpy).toHaveBeenCalled(); - vi.useRealTimers(); }); it('should skip dragging if not pressed long enough', async () => { @@ -177,7 +180,6 @@ describe('DraggableDirective', () => { await harness.mouseUp(); expect(dragStartSpy).not.toHaveBeenCalled(); expect(dragEndSpy).not.toHaveBeenCalled(); - vi.useRealTimers(); }); it('should not start dragging if waiting for the delay', async () => { @@ -186,7 +188,14 @@ describe('DraggableDirective', () => { vi.advanceTimersByTime(50); await harness.mouseMove(100); expect(dragMoveSpy).not.toHaveBeenCalled(); - vi.useRealTimers(); + }); + + it('should not start dragging after short touch before delay', async () => { + vi.useFakeTimers(); + await harness.touchStart(0); + vi.advanceTimersByTime(50); + await harness.touchEnd(); + expect(dragStartSpy).not.toHaveBeenCalled(); }); }); });