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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ All notable changes for each version of this project will be documented in this

### Bug Fixes

- `IgxNavigationDrawerComponent`
- Fixed fast touch movements below the pan threshold being recognized as swipes and unexpectedly toggling the drawer.
- `IgxCheckboxComponent`
- Fixed the tick-mark icon rendering with the Indigo shape (rounded rect + custom path) inside CSS-scoped subtrees that use a different design system than the application's global theme, e.g. a `material`-themed widget nested inside an `indigo`-themed app. Both tick-mark variants are now always rendered and toggled purely via CSS (`@container style(--ig-theme: indigo)`), removing the dependency on JS-side theme detection that could go stale in nested/multi-theme scenarios (#15021).
- **Ripple**
Expand Down
147 changes: 147 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,147 @@
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();
});

it('should not emit swipe when movement stays below the pan threshold', () => {
const panStart = jasmine.createSpy('panStart');
const panMove = jasmine.createSpy('panMove');
const swipe = jasmine.createSpy('swipe');
spyOn(Date, 'now').and.returnValues(0, 1, 1);
manager = new IgxTouchManager(target, { panStart, panMove, swipe }, { panThreshold: 5 });

dispatchPointerEvent(target, 'pointerdown', 10, 10);
dispatchPointerEvent(target, 'pointermove', 13, 10);
dispatchPointerEvent(target, 'pointerup', 13, 10);

expect(panStart).not.toHaveBeenCalled();
expect(panMove).not.toHaveBeenCalled();
expect(swipe).not.toHaveBeenCalled();
});

for (const endX of [13, 16]) {
it(`should emit swipe before panEnd after a recognized pan ending at x=${endX}`, () => {
const swipe = jasmine.createSpy('swipe');
const panEnd = jasmine.createSpy('panEnd');
spyOn(Date, 'now').and.returnValues(0, 1, 2);
manager = new IgxTouchManager(target, { swipe, panEnd }, { panThreshold: 5 });

dispatchPointerEvent(target, 'pointerdown', 10, 10);
dispatchPointerEvent(target, 'pointermove', 16, 10);
dispatchPointerEvent(target, 'pointerup', endX, 10);

expect(swipe).toHaveBeenCalledTimes(1);
expect(panEnd).toHaveBeenCalledTimes(1);
expect(swipe).toHaveBeenCalledBefore(panEnd);
expectTrackingStateToBeReset(manager);
});
}

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;
}
78 changes: 52 additions & 26 deletions projects/igniteui-angular/core/src/core/touch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export interface IgxTouchManagerCallbacks {
panCancel?: (event: IgxGestureEvent) => void;
/** Fired on pointer up when the movement stays below `tapThreshold`. Suppresses `panEnd`. */
tap?: (event: IgxGestureEvent) => void;
/** Fired on pointer up for a fast, primarily horizontal gesture, before `panEnd`. */
/** Fired on pointer up for a fast, primarily horizontal recognized pan, before `panEnd`. */
swipe?: (event: IgxGestureEvent) => void;
}

Expand All @@ -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,17 +309,17 @@ 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);
const panStarted = this._panStarted;
this._resetTracking();

this._runInAngular(() => {
if (this.callbacks.tap && gesture.distance < this._tapThreshold) {
this.callbacks.tap(gesture);
return;
}

if (this.callbacks.swipe &&
if (panStarted && this.callbacks.swipe &&
gesture.velocity > this._swipeVelocityThreshold &&
Math.abs(gesture.deltaX) > Math.abs(gesture.deltaY)) {
this.callbacks.swipe(gesture);
Expand All @@ -328,10 +336,9 @@ 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));
}
};
Expand All @@ -340,26 +347,45 @@ export class IgxTouchManager {
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;
}
}
2 changes: 1 addition & 1 deletion projects/igniteui-angular/navigation-drawer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ The navigation drawer can either sit above content or be pinned alongside it and
|:----------|:----:|:------|
| `id`| string | Unique identifier of the Grid. ID required to register with provided `IgxNavigationService` allow directives to target the control from other template files. |
| `position` | string | Position of the Navigation Drawer. Can be "left"(default) or "right". Only has effect when not pinned.|
| `enableGestures`| boolean | Enables the use of touch gestures to manipulate the drawer - such as swipe/pan from edge to open, swipe toggle and pan drag. |
| `enableGestures`| boolean | Enables the use of touch gestures to manipulate the drawer - such as swipe/pan from edge to open, swipe toggle and pan drag. Swipes require a recognized horizontal pan; movement that stays below 5 px does not toggle the drawer. |
| `isOpen` | boolean | State of the drawer. |
| `pin` | boolean | When pinned the drawer is relatively positioned instead of sitting above content. May require additional layout styling. |
| `pinThreshold` | number | Minimum device width required for automatic pin to be toggled. Default is 1024, can be set to a falsy value to disable this behavior. |
Expand Down
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 TouchEvent('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
Loading
Loading