Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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): Event {
const event = new Event('touchmove', { bubbles: true, cancelable: true });
target.dispatchEvent(event);
return event;
}
88 changes: 68 additions & 20 deletions projects/igniteui-angular/core/src/core/touch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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(
Expand All @@ -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),
Expand All @@ -147,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 {
Expand Down Expand Up @@ -211,17 +221,20 @@ 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) => {
if (!this._tracking || event.pointerId !== this._pointerId || !this._accepts(event.pointerType)) {
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);
}
Expand All @@ -232,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);
Expand All @@ -254,15 +266,51 @@ 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) => {
// 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._resetTracking();

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.
}
}
}

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 @@ -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 = `<igx-nav-drawer [miniWidth]="drawerMiniWidth">
<ng-template igxDrawer></ng-template>
Expand Down Expand Up @@ -733,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();
});
Expand Down
Loading
Loading