From f2a2893e26903e5033ed4da15e4138c01f0c0e52 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Fri, 31 Jul 2026 12:20:39 +0300 Subject: [PATCH 1/3] fix(nav-drawer): preserve single-tap actions on iOS --- .../core/src/core/touch.spec.ts | 84 +++++++++++++++++++ .../igniteui-angular/core/src/core/touch.ts | 60 +++++++++++-- .../navigation-drawer.component.spec.ts | 20 +++++ .../navigation-drawer.component.ts | 52 +++++++----- 4 files changed, 189 insertions(+), 27 deletions(-) create mode 100644 projects/igniteui-angular/core/src/core/touch.spec.ts diff --git a/projects/igniteui-angular/core/src/core/touch.spec.ts b/projects/igniteui-angular/core/src/core/touch.spec.ts new file mode 100644 index 00000000000..1614e640e55 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/touch.spec.ts @@ -0,0 +1,84 @@ +import { IgxTouchManager } from './touch'; + +describe('IgxTouchManager', () => { + let manager: IgxTouchManager; + let target: HTMLDivElement; + + beforeEach(() => { + target = document.createElement('div'); + document.body.appendChild(target); + }); + + afterEach(() => { + manager?.destroy(); + target.remove(); + }); + + it('should stop tracking when pointerDown vetoes the gesture', () => { + const panStart = jasmine.createSpy('panStart'); + const panMove = jasmine.createSpy('panMove'); + manager = new IgxTouchManager(target, { + pointerDown: () => false, + panStart, + panMove + }); + + dispatchPointerEvent(target, 'pointerdown', 10, 10); + const touchMove = dispatchTouchMove(target); + dispatchPointerEvent(target, 'pointermove', 30, 10); + + expect(touchMove.defaultPrevented).toBeFalse(); + expect(panStart).not.toHaveBeenCalled(); + expect(panMove).not.toHaveBeenCalled(); + }); + + it('should preserve native touch behavior until the pan threshold is exceeded', () => { + const panStart = jasmine.createSpy('panStart'); + const panMove = jasmine.createSpy('panMove'); + manager = new IgxTouchManager(target, { + panStart, + panMove + }, { panAxis: 'horizontal', panThreshold: 5 }); + + dispatchPointerEvent(target, 'pointerdown', 10, 10); + const initialTouchMove = dispatchTouchMove(target); + dispatchPointerEvent(target, 'pointermove', 13, 10); + const candidateTouchMove = dispatchTouchMove(target); + + expect(initialTouchMove.defaultPrevented).toBeFalse(); + expect(candidateTouchMove.defaultPrevented).toBeFalse(); + expect(panStart).not.toHaveBeenCalled(); + expect(panMove).not.toHaveBeenCalled(); + + dispatchPointerEvent(target, 'pointermove', 11, 20); + const verticalTouchMove = dispatchTouchMove(target); + + expect(verticalTouchMove.defaultPrevented).toBeFalse(); + expect(panStart).not.toHaveBeenCalled(); + expect(panMove).not.toHaveBeenCalled(); + + dispatchPointerEvent(target, 'pointermove', 16, 10); + const activePanTouchMove = dispatchTouchMove(target); + + expect(panStart).toHaveBeenCalledTimes(1); + expect(panMove).toHaveBeenCalledTimes(1); + expect(activePanTouchMove.defaultPrevented).toBeTrue(); + }); +}); + +function dispatchPointerEvent(target: EventTarget, type: string, clientX: number, clientY: number): void { + target.dispatchEvent(new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId: 1, + pointerType: 'touch', + clientX, + clientY + })); +} + +function dispatchTouchMove(target: EventTarget): Event { + const event = new Event('touchmove', { bubbles: true, cancelable: true }); + target.dispatchEvent(event); + return event; +} diff --git a/projects/igniteui-angular/core/src/core/touch.ts b/projects/igniteui-angular/core/src/core/touch.ts index 30f019cd800..24c1617878c 100644 --- a/projects/igniteui-angular/core/src/core/touch.ts +++ b/projects/igniteui-angular/core/src/core/touch.ts @@ -41,8 +41,11 @@ export interface IgxGestureEvent { * @internal */ export interface IgxTouchManagerCallbacks { - /** Fired on pointer down once the pointer type passes the configured filter, before any movement. */ - pointerDown?: (event: IgxGestureEvent) => void; + /** + * Fired on pointer down once the pointer type passes the configured filter, before any movement. + * Return `false` to stop tracking the gesture. + */ + pointerDown?: (event: IgxGestureEvent) => boolean | void; /** Fired on the first pointer move of a tracked gesture, once movement begins (mirrors Hammer's `panstart`). */ panStart?: (event: IgxGestureEvent) => void; /** Fired on each pointer move while a gesture is tracked. */ @@ -70,6 +73,10 @@ export interface IgxTouchManagerOptions { setPointerCapture?: boolean; /** Maximum movement (in px) for a pointer up to be recognized as a tap. Defaults to `0` (disabled). */ tapThreshold?: number; + /** Minimum movement (in px) before a pan starts. Defaults to `0`. */ + panThreshold?: number; + /** Axis on which movement can start a pan. Defaults to `'all'`. */ + panAxis?: 'all' | 'horizontal' | 'vertical'; /** Minimum velocity (in px/ms) for a primarily horizontal gesture to be recognized as a swipe. Defaults to `0.3`. */ swipeVelocityThreshold?: number; } @@ -112,6 +119,8 @@ export class IgxTouchManager { private readonly _pointerTypes: string[]; private readonly _setPointerCapture: boolean; private readonly _tapThreshold: number; + private readonly _panThreshold: number; + private readonly _panAxis: 'all' | 'horizontal' | 'vertical'; private readonly _swipeVelocityThreshold: number; constructor( @@ -122,6 +131,8 @@ export class IgxTouchManager { this._pointerTypes = options.pointerTypes ?? ['touch', 'pen']; this._setPointerCapture = options.setPointerCapture ?? true; this._tapThreshold = options.tapThreshold ?? 0; + this._panThreshold = options.panThreshold ?? 0; + this._panAxis = options.panAxis ?? 'all'; this._swipeVelocityThreshold = options.swipeVelocityThreshold ?? 0.3; // Behave as a noop outside of a browser (e.g. during server-side rendering), @@ -211,7 +222,9 @@ export class IgxTouchManager { } } - this.callbacks.pointerDown?.(this._createEvent(event)); + if (this.callbacks.pointerDown?.(this._createEvent(event)) === false) { + this._stopTracking(event.pointerId); + } }; private _onPointerMove = (event: PointerEvent) => { @@ -219,9 +232,10 @@ export class IgxTouchManager { return; } const gesture = this._createEvent(event); - // Defer `panStart` until movement actually begins, mirroring Hammer's `panstart`. - // A press with no movement (a tap) therefore never raises `panStart`. if (!this._panStarted) { + if (!this._canStartPan(gesture)) { + return; + } this._panStarted = true; this.callbacks.panStart?.(gesture); } @@ -260,9 +274,41 @@ export class IgxTouchManager { }; private _onTouchMove = (event: TouchEvent) => { - // Prevent scrolling only while a gesture is actively tracked. - if (this._tracking && event.cancelable) { + // Preserve native scrolling and compatibility clicks while the contact is + // only a tap candidate. Suppress scrolling after a pan is recognized. + if (this._tracking && this._panStarted && event.cancelable) { event.preventDefault(); } } + + private _canStartPan(event: IgxGestureEvent): boolean { + if (event.distance < this._panThreshold) { + return false; + } + + if (this._panAxis === 'horizontal') { + return Math.abs(event.deltaX) > Math.abs(event.deltaY); + } + + if (this._panAxis === 'vertical') { + return Math.abs(event.deltaY) > Math.abs(event.deltaX); + } + + return true; + } + + private _stopTracking(pointerId: number): void { + this._tracking = false; + this._panStarted = false; + this._pointerId = null; + this._startTarget = null; + + if (this._setPointerCapture && typeof (this.target as Element).releasePointerCapture === 'function') { + try { + (this.target as Element).releasePointerCapture(pointerId); + } catch { + // Pointer capture is best-effort and may already have been released. + } + } + } } diff --git a/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.spec.ts b/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.spec.ts index cabcf57c10d..cbb3e8b7bc9 100644 --- a/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.spec.ts +++ b/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.spec.ts @@ -440,6 +440,26 @@ describe('Navigation Drawer', () => { }); }, 10000); + it('should preserve a tap that starts inside the edge gesture zone', waitForAsync(() => { + TestBed.compileComponents().then(() => { + const fixture = TestBed.createComponent(TestComponentDIComponent); + fixture.detectChanges(); + const navDrawer = fixture.componentInstance.navDrawer; + + dispatchTouchPointerEvent(document.body, 'pointerdown', 10, 10); + dispatchTouchPointerEvent(document.body, 'pointermove', 13, 10); + const touchMove = new Event('touchmove', { bubbles: true, cancelable: true }); + document.body.dispatchEvent(touchMove); + + expect((navDrawer as any)._panning).toBeFalse(); + expect(navDrawer.drawer.classList).not.toContain('panning'); + expect(touchMove.defaultPrevented).toBeFalse(); + + dispatchTouchPointerEvent(document.body, 'pointerup', 13, 10); + fixture.destroy(); + }); + })); + it('should update edge zone with mini width', waitForAsync(() => { const template = ` diff --git a/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.ts b/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.ts index 0219ceae597..4470fb87efa 100644 --- a/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.ts +++ b/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.ts @@ -6,6 +6,7 @@ import { IgxNavigationService, IToggleView } from 'igniteui-angular/core'; import { IgxNavDrawerMiniTemplateDirective, IgxNavDrawerTemplateDirective, IgxNavDrawerItemDirective } from './navigation-drawer.directives'; import { IgxGestureEvent, IgxTouchManager, PlatformUtil } from 'igniteui-angular/core'; +const PAN_THRESHOLD = 5; let NEXT_ID = 0; /** * **Ignite UI for Angular Navigation Drawer** - @@ -674,12 +675,18 @@ export class IgxNavigationDrawerComponent implements if (this.enableGestures && !this.pin) { if (!this._gesturesAttached) { this._gestures = new IgxTouchManager(this._document, { - pointerDown: (event) => this.panStart(event), + pointerDown: (event) => this.canStartPan(event), + panStart: (event) => this.panStart(event), panMove: (event) => this.pan(event), swipe: (event) => this.swipe(event), panEnd: (event) => this.panEnd(event), panCancel: () => this.panCancel() - }, { pointerTypes: ['touch'], setPointerCapture: false }); + }, { + panAxis: 'horizontal', + panThreshold: PAN_THRESHOLD, + pointerTypes: ['touch'], + setPointerCapture: false + }); this._gesturesAttached = true; } @@ -755,29 +762,34 @@ export class IgxNavigationDrawerComponent implements } }; - private panStart = (evt: IgxGestureEvent) => { + private canStartPan = (evt: IgxGestureEvent): boolean => { if (!this.enableGestures || this.pin || evt.pointerType !== 'touch') { - return; + return false; } const startPosition = this.position === 'right' ? this.getWindowWidth() - (evt.center.x + evt.distance) : evt.center.x - evt.distance; - // cache width during animation, flag to allow further handling - if (this.isOpen || (startPosition < this.maxEdgeZone)) { - this._panning = true; - this._panStartWidth = this.getExpectedWidth(!this.isOpen); - this._panLimit = this.getExpectedWidth(this.isOpen); - - this.renderer.addClass(this.overlay, 'panning'); - this.renderer.addClass(this.drawer, 'panning'); - - if (!this.hasAnimateWidth) { - // Translate-mode pan slides the panel via `transform`, but its width is - // driven by `--ig-nav-drawer-size`, which is forced to 0 while the drawer - // is closed. Pin the real width for the duration of the gesture so the - // slide reveals the full panel instead of just its padding/border. - this.renderer.setStyle(this.drawer, 'width', `${this.getExpectedWidth(false)}px`); - } + return this.isOpen || startPosition < this.maxEdgeZone; + }; + + private panStart = (_evt: IgxGestureEvent) => { + if (!this.enableGestures || this.pin) { + return; + } + + this._panning = true; + this._panStartWidth = this.getExpectedWidth(!this.isOpen); + this._panLimit = this.getExpectedWidth(this.isOpen); + + this.renderer.addClass(this.overlay, 'panning'); + this.renderer.addClass(this.drawer, 'panning'); + + if (!this.hasAnimateWidth) { + // Translate-mode pan slides the panel via `transform`, but its width is + // driven by `--ig-nav-drawer-size`, which is forced to 0 while the drawer + // is closed. Pin the real width for the duration of the gesture so the + // slide reveals the full panel instead of just its padding/border. + this.renderer.setStyle(this.drawer, 'width', `${this.getExpectedWidth(false)}px`); } }; From 784df099752375f63448af23b2288070f9d90745 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Fri, 31 Jul 2026 13:42:34 +0300 Subject: [PATCH 2/3] chore(*): add a missing semicolon --- projects/igniteui-angular/core/src/core/touch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/igniteui-angular/core/src/core/touch.ts b/projects/igniteui-angular/core/src/core/touch.ts index 24c1617878c..4c1a65c0017 100644 --- a/projects/igniteui-angular/core/src/core/touch.ts +++ b/projects/igniteui-angular/core/src/core/touch.ts @@ -279,7 +279,7 @@ export class IgxTouchManager { if (this._tracking && this._panStarted && event.cancelable) { event.preventDefault(); } - } + }; private _canStartPan(event: IgxGestureEvent): boolean { if (event.distance < this._panThreshold) { From 1ae5d73b62727dc1f5ac50baba3d41fad29135e5 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Fri, 31 Jul 2026 14:06:24 +0300 Subject: [PATCH 3/3] refactor(touch): reset gesture tracking consistently --- .../core/src/core/touch.spec.ts | 29 +++++++++++++++ .../igniteui-angular/core/src/core/touch.ts | 36 ++++++++++--------- .../navigation-drawer.component.spec.ts | 15 ++++++-- 3 files changed, 61 insertions(+), 19 deletions(-) diff --git a/projects/igniteui-angular/core/src/core/touch.spec.ts b/projects/igniteui-angular/core/src/core/touch.spec.ts index 1614e640e55..158d44fd170 100644 --- a/projects/igniteui-angular/core/src/core/touch.spec.ts +++ b/projects/igniteui-angular/core/src/core/touch.spec.ts @@ -64,8 +64,37 @@ describe('IgxTouchManager', () => { expect(panMove).toHaveBeenCalledTimes(1); expect(activePanTouchMove.defaultPrevented).toBeTrue(); }); + + for (const eventType of ['pointerup', 'pointercancel']) { + it(`should reset tracking state on ${eventType}`, () => { + manager = new IgxTouchManager(target, {}); + + dispatchPointerEvent(target, 'pointerdown', 10, 10); + dispatchPointerEvent(target, 'pointermove', 20, 10); + dispatchPointerEvent(target, eventType, 20, 10); + + expectTrackingStateToBeReset(manager); + }); + } + + it('should reset tracking state when destroyed', () => { + manager = new IgxTouchManager(target, {}); + + dispatchPointerEvent(target, 'pointerdown', 10, 10); + dispatchPointerEvent(target, 'pointermove', 20, 10); + manager.destroy(); + + expectTrackingStateToBeReset(manager); + }); }); +function expectTrackingStateToBeReset(manager: IgxTouchManager): void { + expect((manager as any)._tracking).toBeFalse(); + expect((manager as any)._panStarted).toBeFalse(); + expect((manager as any)._pointerId).toBeNull(); + expect((manager as any)._startTarget).toBeNull(); +} + function dispatchPointerEvent(target: EventTarget, type: string, clientX: number, clientY: number): void { target.dispatchEvent(new PointerEvent(type, { bubbles: true, diff --git a/projects/igniteui-angular/core/src/core/touch.ts b/projects/igniteui-angular/core/src/core/touch.ts index 4c1a65c0017..36f66c51c69 100644 --- a/projects/igniteui-angular/core/src/core/touch.ts +++ b/projects/igniteui-angular/core/src/core/touch.ts @@ -158,15 +158,14 @@ export class IgxTouchManager { /** Detaches all listeners and stops tracking. */ public destroy(): void { - if (!this._supported) { - return; + if (this._supported) { + this.target.removeEventListener('pointerdown', this._onPointerDown); + this.target.removeEventListener('pointermove', this._onPointerMove); + this.target.removeEventListener('pointerup', this._onPointerUp); + this.target.removeEventListener('pointercancel', this._onPointerCancel); + this.target.removeEventListener('touchmove', this._onTouchMove); } - this.target.removeEventListener('pointerdown', this._onPointerDown); - this.target.removeEventListener('pointermove', this._onPointerMove); - this.target.removeEventListener('pointerup', this._onPointerUp); - this.target.removeEventListener('pointercancel', this._onPointerCancel); - this.target.removeEventListener('touchmove', this._onTouchMove); - this._tracking = false; + this._resetTracking(); } private _accepts(pointerType: string): boolean { @@ -246,9 +245,8 @@ export class IgxTouchManager { if (!this._tracking || event.pointerId !== this._pointerId || !this._accepts(event.pointerType)) { return; } - this._tracking = false; - this._pointerId = null; const gesture = this._createEvent(event); + this._resetTracking(); if (this.callbacks.tap && gesture.distance < this._tapThreshold) { this.callbacks.tap(gesture); @@ -268,9 +266,9 @@ export class IgxTouchManager { if (!this._tracking || event.pointerId !== this._pointerId) { return; } - this._tracking = false; - this._pointerId = null; - this.callbacks.panCancel?.(this._createEvent(event)); + const gesture = this._createEvent(event); + this._resetTracking(); + this.callbacks.panCancel?.(gesture); }; private _onTouchMove = (event: TouchEvent) => { @@ -298,10 +296,7 @@ export class IgxTouchManager { } private _stopTracking(pointerId: number): void { - this._tracking = false; - this._panStarted = false; - this._pointerId = null; - this._startTarget = null; + this._resetTracking(); if (this._setPointerCapture && typeof (this.target as Element).releasePointerCapture === 'function') { try { @@ -311,4 +306,11 @@ export class IgxTouchManager { } } } + + private _resetTracking(): void { + this._tracking = false; + this._panStarted = false; + this._pointerId = null; + this._startTarget = null; + } } diff --git a/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.spec.ts b/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.spec.ts index cbb3e8b7bc9..b6f7fb448c4 100644 --- a/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.spec.ts +++ b/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.spec.ts @@ -753,9 +753,20 @@ describe('Navigation Drawer', () => { expect(navDrawer.isOpen).toBeFalse(); }); - it('panStart: should set _panning flag when conditions are met', () => { + it('canStartPan: should qualify only edge touches while closed', () => { + expect((navDrawer as any).canStartPan(makeGestureInput({ center: { x: 30, y: 10 } }))).toBeTrue(); + expect((navDrawer as any).canStartPan(makeGestureInput({ center: { x: 100, y: 10 } }))).toBeFalse(); + }); + + it('canStartPan: should qualify touches anywhere while open', () => { + navDrawer.open(); + fixture.detectChanges(); + + expect((navDrawer as any).canStartPan(makeGestureInput({ center: { x: 100, y: 10 } }))).toBeTrue(); + }); + + it('panStart: should initialize panning after gesture recognition', () => { expect((navDrawer as any)._panning).toBeFalse(); - // simulate start from left edge (startPosition < maxEdgeZone) (navDrawer as any).panStart(makeGestureInput({ deltaX: 0, center: { x: 30, y: 10 }, distance: 0 })); expect((navDrawer as any)._panning).toBeTrue(); });