Skip to content
Open
113 changes: 113 additions & 0 deletions projects/igniteui-angular/core/src/core/touch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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();
});

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,
cancelable: true,
pointerId: 1,
pointerType: 'touch',
clientX,
clientY
}));
}

function dispatchTouchMove(target: EventTarget): TouchEvent {
const event = new TouchEvent('touchmove', { bubbles: true, cancelable: true });
target.dispatchEvent(event);
return event;
}
75 changes: 50 additions & 25 deletions projects/igniteui-angular/core/src/core/touch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,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;
/**
Expand Down Expand Up @@ -134,6 +138,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;
private readonly _canStart: ((event: PointerEvent) => boolean) | null;
private readonly _ngZone: NgZone | null;
Expand All @@ -146,6 +152,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;
this._canStart = options.canStart ?? null;
this._ngZone = options.ngZone ?? null;
Expand Down Expand Up @@ -175,15 +183,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 {
Expand Down Expand Up @@ -280,9 +287,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;
if (this.callbacks.panStart) {
this._runInAngular(() => this.callbacks.panStart?.(gesture));
Expand All @@ -301,9 +309,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();

this._runInAngular(() => {
if (this.callbacks.tap && gesture.distance < this._tapThreshold) {
Expand All @@ -328,38 +335,56 @@ export class IgxTouchManager {
if (!this._tracking || event.pointerId !== this._pointerId) {
return;
}
this._tracking = false;
this._pointerId = null;
const gesture = this._createEvent(event);
this._resetTracking();
if (this.callbacks.panCancel) {
const gesture = this._createEvent(event);
this._runInAngular(() => this.callbacks.panCancel?.(gesture));
}
};

private _onTouchMove = (event: Event) => {
if (!(event instanceof TouchEvent)) {
return;
}
// 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;
}

/** Stops tracking the current gesture and best-effort releases the pointer capture. */
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 {
(this.target as Element).releasePointerCapture(pointerId);
} catch {
// `releasePointerCapture` can throw when the pointer is no longer captured.
// Releasing is a best-effort cleanup, so ignore it.
// Pointer capture is best-effort and may already have been released.
}
}
}

private _resetTracking(): void {
this._tracking = false;
this._panStarted = false;
this._pointerId = null;
this._startTarget = null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,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 = `<igx-nav-drawer [miniWidth]="drawerMiniWidth">
<ng-template igxDrawer></ng-template>
Expand Down Expand Up @@ -732,9 +752,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();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,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** -
Expand Down Expand Up @@ -686,12 +687,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;
}
Expand Down Expand Up @@ -767,36 +774,35 @@ export class IgxNavigationDrawerComponent implements
}
};

private panStart = (evt: IgxGestureEvent): boolean => {
private canStartPan = (evt: IgxGestureEvent): boolean => {
if (!this.enableGestures || this.pin || evt.pointerType !== 'touch') {
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, 'igx-nav-drawer__overlay--panning');
this.renderer.addClass(this.drawer, 'igx-nav-drawer__aside--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 true;
return this.isOpen || startPosition < this.maxEdgeZone;
};

private panStart = (_evt: IgxGestureEvent) => {
if (!this.enableGestures || this.pin) {
return;
}

// The touch did not start in the edge zone (and the drawer is closed), so this
// gesture should be ignored. Returning `false` lets the touch manager stop
// tracking immediately and not interfere with normal page scrolling.
return false;
this._panning = true;
this._panStartWidth = this.getExpectedWidth(!this.isOpen);
this._panLimit = this.getExpectedWidth(this.isOpen);

this.renderer.addClass(this.overlay, 'igx-nav-drawer__overlay--panning');
this.renderer.addClass(this.drawer, 'igx-nav-drawer__aside--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`);
}
};

private pan = (evt: IgxGestureEvent) => {
Expand Down
Loading