feat(core): add gesture system (#1287)

This commit is contained in:
rahim
2026-04-10 01:52:08 -07:00
committed by GitHub
parent b9765d9320
commit 08ebaed23f
9 changed files with 968 additions and 0 deletions
@@ -0,0 +1,146 @@
import { listen } from '@videojs/utils/dom';
import type { GestureBinding, GestureMatchResult, GestureRecognizer, GestureRegion, GestureType } from './gesture';
import { resolveRegion } from './region';
const TAP_THRESHOLD = 250;
export class GestureCoordinator {
#target: HTMLElement;
#bindings: GestureBinding[] = [];
#recognizers = new Set<GestureRecognizer>();
#disconnect: AbortController | null = null;
constructor(target: HTMLElement) {
this.#target = target;
}
add(binding: GestureBinding): () => void {
this.#bindings.push(binding);
this.#recognizers.add(binding.recognizer);
this.#connect();
let removed = false;
return () => {
if (removed) return;
removed = true;
const idx = this.#bindings.indexOf(binding);
if (idx !== -1) this.#bindings.splice(idx, 1);
this.#maybeDisconnect();
};
}
// --- Private ---
#connect(): void {
if (this.#disconnect) return;
this.#disconnect = new AbortController();
const { signal } = this.#disconnect;
let pointerDownTime = 0;
listen(
this.#target,
'pointerdown',
() => {
pointerDownTime = Date.now();
},
{ signal }
);
listen(
this.#target,
'pointerup',
(event) => {
if (Date.now() - pointerDownTime > TAP_THRESHOLD) return;
const pointerType = event.pointerType;
const clientX = event.clientX;
const target = this.#target;
const bindings = this.#bindings;
const matches: GestureMatchResult = {
resolve: (type) => matchBindings(bindings, type, pointerType, clientX, target),
};
for (const recognizer of this.#recognizers) {
recognizer.handleUp(matches, event);
}
},
{ signal }
);
}
#maybeDisconnect(): void {
if (this.#bindings.length > 0) return;
for (const recognizer of this.#recognizers) {
recognizer.reset();
}
this.#recognizers.clear();
this.#disconnect?.abort();
this.#disconnect = null;
}
}
const coordinators = new WeakMap<HTMLElement, GestureCoordinator>();
/** Look up the gesture coordinator for a target element, if one exists. */
export function findGestureCoordinator(target: HTMLElement): GestureCoordinator | undefined {
return coordinators.get(target);
}
export function getGestureCoordinator(target: HTMLElement): GestureCoordinator {
let coordinator = coordinators.get(target);
if (!coordinator) {
coordinator = new GestureCoordinator(target);
coordinators.set(target, coordinator);
}
return coordinator;
}
// --- Matching ---
function matchBindings(
bindings: GestureBinding[],
type: GestureType,
pointerType: string,
clientX: number,
target: HTMLElement
): GestureBinding[] {
const rect = target.getBoundingClientRect();
const activeRegions = getActiveRegions(bindings, type, pointerType);
const region = activeRegions.size > 0 ? resolveRegion(clientX, rect, activeRegions) : null;
const matches: GestureBinding[] = [];
for (const binding of bindings) {
if (binding.disabled) continue;
if (binding.type !== type) continue;
if (binding.pointer && binding.pointer !== pointerType) continue;
if (binding.region) {
if (binding.region !== region) continue;
} else if (region !== null) {
continue;
}
matches.push(binding);
}
return matches;
}
function getActiveRegions(bindings: GestureBinding[], type: GestureType, pointerType: string): Set<GestureRegion> {
const regions = new Set<GestureRegion>();
for (const binding of bindings) {
if (binding.disabled) continue;
if (binding.type !== type) continue;
if (binding.pointer && binding.pointer !== pointerType) continue;
if (binding.region) regions.add(binding.region);
}
return regions;
}
@@ -0,0 +1,64 @@
import { getGestureCoordinator } from './coordinator';
import type { GestureOptions } from './gesture';
import { TapRecognizer } from './tap';
const recognizers = new WeakMap<HTMLElement, TapRecognizer>();
function getRecognizer(target: HTMLElement): TapRecognizer {
let recognizer = recognizers.get(target);
if (recognizer) return recognizer;
recognizer = new TapRecognizer();
recognizers.set(target, recognizer);
return recognizer;
}
/**
* Register a tap gesture on a target element.
*
* @example
* ```ts
* const cleanup = createTapGesture(container, (event) => {
* store.paused ? store.play() : store.pause();
* }, { pointer: 'mouse' });
* ```
*/
export function createTapGesture(
target: HTMLElement,
onActivate: (event: PointerEvent) => void,
options?: GestureOptions
): () => void {
return getGestureCoordinator(target).add({
type: 'tap',
recognizer: getRecognizer(target),
onActivate,
pointer: options?.pointer,
region: options?.region,
disabled: options?.disabled,
});
}
/**
* Register a doubletap gesture on a target element.
*
* @example
* ```ts
* const cleanup = createDoubleTapGesture(container, (event) => {
* store.fullscreen ? store.exitFullscreen() : store.requestFullscreen();
* }, { region: 'center' });
* ```
*/
export function createDoubleTapGesture(
target: HTMLElement,
onActivate: (event: PointerEvent) => void,
options?: GestureOptions
): () => void {
return getGestureCoordinator(target).add({
type: 'doubletap',
recognizer: getRecognizer(target),
onActivate,
pointer: options?.pointer,
region: options?.region,
disabled: options?.disabled,
});
}
+31
View File
@@ -0,0 +1,31 @@
export type GesturePointerType = 'mouse' | 'touch' | 'pen';
export type GestureType = 'tap' | 'doubletap';
export type GestureRegion = 'left' | 'center' | 'right';
export interface GestureOptions {
pointer?: GesturePointerType | undefined;
region?: GestureRegion | undefined;
disabled?: boolean | undefined;
}
export interface GestureBinding {
type: GestureType;
recognizer: GestureRecognizer;
onActivate: (event: PointerEvent) => void;
pointer?: GesturePointerType | undefined;
region?: GestureRegion | undefined;
disabled?: boolean | undefined;
}
export interface GestureRecognizer {
/** Handle a confirmed quick pointer-up and decide when to fire matched bindings. */
handleUp(matches: GestureMatchResult, event: PointerEvent): void;
reset(): void;
}
export interface GestureMatchResult {
/** Resolve current matches for a gesture type (reads fresh rect, re-filters bindings). */
resolve(type: GestureType): GestureBinding[];
}
+51
View File
@@ -0,0 +1,51 @@
import type { GestureRegion } from './gesture';
/**
* Determine which named region a pointer position falls into.
*
* Regions divide the container width equally based on how many are active:
* - `left` + `right` → halves (50% / 50%)
* - `left` + `center` + `right` → thirds (33% / 34% / 33%)
*
* Single region: `left` covers the left half, `right` the right half,
* and `center` covers the full surface. Partial two-region combos
* (e.g. `left` + `center`) use the same natural zones — positions outside
* all active zones return `null` so full-surface gestures can handle them.
*/
export function resolveRegion(
clientX: number,
containerRect: DOMRect,
activeRegions: ReadonlySet<GestureRegion>
): GestureRegion | null {
if (activeRegions.size === 0) return null;
const relativeX = clientX - containerRect.left;
const width = containerRect.width;
if (width === 0) return null;
const ratio = relativeX / width;
if (activeRegions.size === 2 && activeRegions.has('left') && activeRegions.has('right')) {
return ratio < 0.5 ? 'left' : 'right';
}
if (activeRegions.size === 3) {
if (ratio < 1 / 3) return 'left';
if (ratio < 2 / 3) return 'center';
return 'right';
}
// Single region or partial combinations — each region covers its natural zone.
// Pointer outside all active zones returns null (full-surface gesture handles it).
// Note: with `left` + `center`, left covers 050% and center covers 3366%.
// Overlap at 3350% resolves to `left` (checked first).
if (activeRegions.has('left') && ratio < 0.5) return 'left';
if (activeRegions.has('right') && ratio >= 0.5) return 'right';
if (activeRegions.has('center')) {
if (activeRegions.size === 1) return 'center';
if (ratio >= 1 / 3 && ratio < 2 / 3) return 'center';
}
return null;
}
+59
View File
@@ -0,0 +1,59 @@
import type { GestureMatchResult, GestureRecognizer } from './gesture';
const DOUBLETAP_WINDOW = 300;
/**
* Recognizes tap vs doubletap from quick pointer-up events.
*
* Stateful recognizer — tracks tap count and doubletap timing.
* The coordinator handles pointer-down timing (tap threshold) and
* calls `handleUp()` only for quick taps that passed the threshold check.
*/
export class TapRecognizer implements GestureRecognizer {
#lastTapTime = 0;
#tapTimer: ReturnType<typeof setTimeout> | null = null;
handleUp(matches: GestureMatchResult, event: PointerEvent): void {
const hasDoubletap = matches.resolve('doubletap').length > 0;
if (hasDoubletap) {
const now = Date.now();
if (now - this.#lastTapTime < DOUBLETAP_WINDOW) {
// Second tap within window — doubletap.
this.#clearTimer();
this.#lastTapTime = 0;
// Re-resolve at fire time so removed bindings between taps are respected.
matches.resolve('doubletap')[0]?.onActivate(event);
return;
}
// First tap — defer to allow doubletap window.
this.#lastTapTime = now;
this.#clearTimer();
this.#tapTimer = setTimeout(() => {
this.#tapTimer = null;
this.#lastTapTime = 0;
// Re-resolve at fire time so cleanup between pointerup and timeout is respected.
matches.resolve('tap')[0]?.onActivate(event);
}, DOUBLETAP_WINDOW);
return;
}
// No doubletap bindings — fire tap immediately.
matches.resolve('tap')[0]?.onActivate(event);
}
#clearTimer(): void {
if (this.#tapTimer !== null) {
clearTimeout(this.#tapTimer);
this.#tapTimer = null;
}
}
reset(): void {
this.#clearTimer();
this.#lastTapTime = 0;
}
}
@@ -0,0 +1,388 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createDoubleTapGesture, createTapGesture } from '../create-tap-gesture';
const DOUBLETAP_WINDOW = 300;
function setup() {
const container = document.createElement('div');
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
left: 0,
right: 300,
width: 300,
top: 0,
bottom: 200,
height: 200,
x: 0,
y: 0,
toJSON: () => {},
});
return container;
}
describe('createTapGesture', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('fires on quick pointer down + up', () => {
const container = setup();
const handler = vi.fn();
createTapGesture(container, handler);
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
expect(handler).toHaveBeenCalledOnce();
});
it('does not fire on long press', () => {
const container = setup();
const handler = vi.fn();
createTapGesture(container, handler);
pointerDown(container);
vi.advanceTimersByTime(300);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
expect(handler).not.toHaveBeenCalled();
});
it('does not fire when disabled', () => {
const container = setup();
const handler = vi.fn();
createTapGesture(container, handler, { disabled: true });
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
expect(handler).not.toHaveBeenCalled();
});
it('fires immediately when no doubletap bindings exist', () => {
const container = setup();
const handler = vi.fn();
createTapGesture(container, handler);
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
expect(handler).toHaveBeenCalledOnce();
});
it('cleanup removes binding', () => {
const container = setup();
const handler = vi.fn();
const cleanup = createTapGesture(container, handler);
cleanup();
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
expect(handler).not.toHaveBeenCalled();
});
it('double cleanup is safe', () => {
const container = setup();
const cleanup = createTapGesture(container, vi.fn());
cleanup();
cleanup();
});
it('first registered binding wins when multiple match', () => {
const container = setup();
const first = vi.fn();
const second = vi.fn();
createTapGesture(container, first);
createTapGesture(container, second);
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
expect(first).toHaveBeenCalledOnce();
expect(second).not.toHaveBeenCalled();
});
it('re-registers after all bindings cleaned up', () => {
const container = setup();
const first = vi.fn();
const second = vi.fn();
// Register and immediately clean up.
const cleanup = createTapGesture(container, first);
cleanup();
// Re-register on the same element — should re-attach pointer listeners.
createTapGesture(container, second);
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledOnce();
});
});
describe('createDoubleTapGesture', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('fires on two quick taps', () => {
const container = setup();
const handler = vi.fn();
createDoubleTapGesture(container, handler);
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
vi.advanceTimersByTime(100);
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
expect(handler).toHaveBeenCalledOnce();
});
it('does not fire on single tap', () => {
const container = setup();
const handler = vi.fn();
createDoubleTapGesture(container, handler);
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
vi.advanceTimersByTime(DOUBLETAP_WINDOW + 50);
expect(handler).not.toHaveBeenCalled();
});
it('does not fire when taps are too far apart', () => {
const container = setup();
const handler = vi.fn();
createDoubleTapGesture(container, handler);
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
vi.advanceTimersByTime(DOUBLETAP_WINDOW + 50);
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
expect(handler).not.toHaveBeenCalled();
});
it('does not fire when disabled', () => {
const container = setup();
const handler = vi.fn();
createDoubleTapGesture(container, handler, { disabled: true });
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
vi.advanceTimersByTime(100);
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
expect(handler).not.toHaveBeenCalled();
});
});
describe('tap/doubletap disambiguation', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('delays tap when doubletap bindings exist', () => {
const container = setup();
const tapHandler = vi.fn();
const doubletapHandler = vi.fn();
createTapGesture(container, tapHandler);
createDoubleTapGesture(container, doubletapHandler);
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
expect(tapHandler).not.toHaveBeenCalled();
vi.advanceTimersByTime(DOUBLETAP_WINDOW);
expect(tapHandler).toHaveBeenCalledOnce();
expect(doubletapHandler).not.toHaveBeenCalled();
});
it('cancels pending tap on doubletap', () => {
const container = setup();
const tapHandler = vi.fn();
const doubletapHandler = vi.fn();
createTapGesture(container, tapHandler);
createDoubleTapGesture(container, doubletapHandler);
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
vi.advanceTimersByTime(100);
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
expect(doubletapHandler).toHaveBeenCalledOnce();
expect(tapHandler).not.toHaveBeenCalled();
vi.advanceTimersByTime(DOUBLETAP_WINDOW);
expect(tapHandler).not.toHaveBeenCalled();
});
it('deferred tap does not fire after binding cleanup', () => {
const container = setup();
const tapHandler = vi.fn();
const doubletapHandler = vi.fn();
const tapCleanup = createTapGesture(container, tapHandler);
createDoubleTapGesture(container, doubletapHandler);
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
tapCleanup();
vi.advanceTimersByTime(DOUBLETAP_WINDOW);
expect(tapHandler).not.toHaveBeenCalled();
});
});
describe('pointer filtering', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('only fires for matching pointer type', () => {
const container = setup();
const touchHandler = vi.fn();
const mouseHandler = vi.fn();
createTapGesture(container, touchHandler, { pointer: 'touch' });
createTapGesture(container, mouseHandler, { pointer: 'mouse' });
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'touch', clientX: 150 });
expect(touchHandler).toHaveBeenCalledOnce();
expect(mouseHandler).not.toHaveBeenCalled();
});
it('fires for all pointer types when no filter set', () => {
const container = setup();
const handler = vi.fn();
createTapGesture(container, handler);
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'touch', clientX: 150 });
expect(handler).toHaveBeenCalledOnce();
});
});
describe('regions', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('fires region binding in its zone', () => {
const container = setup();
const leftHandler = vi.fn();
const rightHandler = vi.fn();
createTapGesture(container, leftHandler, { region: 'left' });
createTapGesture(container, rightHandler, { region: 'right' });
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 50 });
expect(leftHandler).toHaveBeenCalledOnce();
expect(rightHandler).not.toHaveBeenCalled();
});
it('region binding takes priority over full-surface', () => {
const container = setup();
const regionHandler = vi.fn();
const fullHandler = vi.fn();
createTapGesture(container, fullHandler);
createTapGesture(container, regionHandler, { region: 'left' });
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 50 });
expect(regionHandler).toHaveBeenCalledOnce();
expect(fullHandler).not.toHaveBeenCalled();
});
it('full-surface fires outside named regions', () => {
const container = setup();
const regionHandler = vi.fn();
const fullHandler = vi.fn();
createTapGesture(container, fullHandler);
createTapGesture(container, regionHandler, { region: 'left' });
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 250 });
expect(fullHandler).toHaveBeenCalledOnce();
expect(regionHandler).not.toHaveBeenCalled();
});
it('full-surface tap fires alongside doubletap regions', () => {
const container = setup();
const tapHandler = vi.fn();
const doubletapLeft = vi.fn();
const doubletapRight = vi.fn();
createTapGesture(container, tapHandler);
createDoubleTapGesture(container, doubletapLeft, { region: 'left' });
createDoubleTapGesture(container, doubletapRight, { region: 'right' });
pointerDown(container);
vi.advanceTimersByTime(50);
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
vi.advanceTimersByTime(DOUBLETAP_WINDOW);
expect(tapHandler).toHaveBeenCalledOnce();
expect(doubletapLeft).not.toHaveBeenCalled();
expect(doubletapRight).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function pointerDown(target: HTMLElement): void {
target.dispatchEvent(new Event('pointerdown', { bubbles: true }));
}
function pointerUp(target: HTMLElement, init: { pointerType: string; clientX: number }): void {
const event = new Event('pointerup', { bubbles: true });
Object.defineProperty(event, 'pointerType', { value: init.pointerType });
Object.defineProperty(event, 'clientX', { value: init.clientX });
target.dispatchEvent(event);
}
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import type { GestureRegion } from '../gesture';
import { resolveRegion } from '../region';
describe('resolveRegion', () => {
const rect = { left: 0, width: 300 } as DOMRect;
describe('halves (left + right)', () => {
const regions = new Set<GestureRegion>(['left', 'right']);
it('returns left for first half', () => {
expect(resolveRegion(50, rect, regions)).toBe('left');
expect(resolveRegion(0, rect, regions)).toBe('left');
expect(resolveRegion(149, rect, regions)).toBe('left');
});
it('returns right for second half', () => {
expect(resolveRegion(150, rect, regions)).toBe('right');
expect(resolveRegion(250, rect, regions)).toBe('right');
expect(resolveRegion(300, rect, regions)).toBe('right');
});
});
describe('thirds (left + center + right)', () => {
const regions = new Set<GestureRegion>(['left', 'center', 'right']);
it('returns left for first third', () => {
expect(resolveRegion(0, rect, regions)).toBe('left');
expect(resolveRegion(50, rect, regions)).toBe('left');
expect(resolveRegion(99, rect, regions)).toBe('left');
});
it('returns center for middle third', () => {
expect(resolveRegion(100, rect, regions)).toBe('center');
expect(resolveRegion(150, rect, regions)).toBe('center');
expect(resolveRegion(199, rect, regions)).toBe('center');
});
it('returns right for last third', () => {
expect(resolveRegion(200, rect, regions)).toBe('right');
expect(resolveRegion(250, rect, regions)).toBe('right');
expect(resolveRegion(300, rect, regions)).toBe('right');
});
});
it('returns null for empty regions', () => {
expect(resolveRegion(100, rect, new Set())).toBe(null);
});
it('returns null for zero-width container', () => {
const zeroRect = { left: 0, width: 0 } as DOMRect;
const regions = new Set<GestureRegion>(['left', 'right']);
expect(resolveRegion(0, zeroRect, regions)).toBe(null);
});
describe('single region', () => {
it('left-only covers the left half', () => {
const regions = new Set<GestureRegion>(['left']);
expect(resolveRegion(0, rect, regions)).toBe('left');
expect(resolveRegion(100, rect, regions)).toBe('left');
// Right half returns null (full-surface gesture handles it).
expect(resolveRegion(200, rect, regions)).toBe(null);
});
it('center-only covers the whole surface', () => {
const regions = new Set<GestureRegion>(['center']);
expect(resolveRegion(0, rect, regions)).toBe('center');
expect(resolveRegion(150, rect, regions)).toBe('center');
expect(resolveRegion(299, rect, regions)).toBe('center');
});
it('right-only covers the right half', () => {
const regions = new Set<GestureRegion>(['right']);
// Left half returns null.
expect(resolveRegion(100, rect, regions)).toBe(null);
expect(resolveRegion(150, rect, regions)).toBe('right');
expect(resolveRegion(250, rect, regions)).toBe('right');
});
});
it('handles offset container', () => {
const offsetRect = { left: 100, width: 300 } as DOMRect;
const regions = new Set<GestureRegion>(['left', 'right']);
// clientX 100 = left edge → left region
expect(resolveRegion(100, offsetRect, regions)).toBe('left');
// clientX 250 = middle → left region (< 50%)
expect(resolveRegion(249, offsetRect, regions)).toBe('left');
// clientX 250 = middle → right region (>= 50%)
expect(resolveRegion(250, offsetRect, regions)).toBe('right');
});
});
@@ -0,0 +1,133 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GestureMatchResult, GestureType } from '../gesture';
import { TapRecognizer } from '../tap';
const DOUBLETAP_WINDOW = 300;
const NOOP_RECOGNIZER = { handleUp: () => {}, reset: () => {} };
function createMatches(handlers: Partial<Record<GestureType, (() => void) | null>>): GestureMatchResult {
return {
resolve(type) {
const handler = handlers[type];
if (!handler) return [];
return [{ type, recognizer: NOOP_RECOGNIZER, onActivate: handler }];
},
};
}
function fakeEvent(): PointerEvent {
return new Event('pointerup') as PointerEvent;
}
describe('TapRecognizer', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('tap without doubletap', () => {
it('fires tap immediately when no doubletap bindings', () => {
const recognizer = new TapRecognizer();
const onTap = vi.fn();
recognizer.handleUp(createMatches({ tap: onTap }), fakeEvent());
expect(onTap).toHaveBeenCalledOnce();
});
});
describe('tap with doubletap', () => {
it('defers tap when doubletap bindings exist', () => {
const recognizer = new TapRecognizer();
const onTap = vi.fn();
const onDoubleTap = vi.fn();
recognizer.handleUp(createMatches({ tap: onTap, doubletap: onDoubleTap }), fakeEvent());
// Not fired yet — waiting for doubletap window.
expect(onTap).not.toHaveBeenCalled();
vi.advanceTimersByTime(DOUBLETAP_WINDOW);
expect(onTap).toHaveBeenCalledOnce();
expect(onDoubleTap).not.toHaveBeenCalled();
});
it('fires doubletap on two quick taps', () => {
const recognizer = new TapRecognizer();
const onTap = vi.fn();
const onDoubleTap = vi.fn();
const matches = createMatches({ tap: onTap, doubletap: onDoubleTap });
// First tap
recognizer.handleUp(matches, fakeEvent());
// Second tap within window
vi.advanceTimersByTime(100);
recognizer.handleUp(matches, fakeEvent());
expect(onDoubleTap).toHaveBeenCalledOnce();
expect(onTap).not.toHaveBeenCalled();
// Deferred tap should not fire after doubletap.
vi.advanceTimersByTime(DOUBLETAP_WINDOW);
expect(onTap).not.toHaveBeenCalled();
});
it('does not fire doubletap when taps are too far apart', () => {
const recognizer = new TapRecognizer();
const onTap = vi.fn();
const onDoubleTap = vi.fn();
const matches = createMatches({ tap: onTap, doubletap: onDoubleTap });
// First tap
recognizer.handleUp(matches, fakeEvent());
// Wait for doubletap window to expire (tap fires).
vi.advanceTimersByTime(DOUBLETAP_WINDOW);
expect(onTap).toHaveBeenCalledOnce();
// Second tap outside window — new deferred tap, not doubletap.
vi.advanceTimersByTime(100);
recognizer.handleUp(matches, fakeEvent());
expect(onDoubleTap).not.toHaveBeenCalled();
});
});
describe('reset', () => {
it('clears pending tap timer', () => {
const recognizer = new TapRecognizer();
const onTap = vi.fn();
recognizer.handleUp(createMatches({ tap: onTap, doubletap: vi.fn() }), fakeEvent());
recognizer.reset();
vi.advanceTimersByTime(DOUBLETAP_WINDOW);
expect(onTap).not.toHaveBeenCalled();
});
it('clears doubletap state', () => {
const recognizer = new TapRecognizer();
const onDoubleTap = vi.fn();
// First tap
recognizer.handleUp(createMatches({ doubletap: onDoubleTap }), fakeEvent());
recognizer.reset();
// Second tap after reset — should not count as doubletap.
vi.advanceTimersByTime(50);
recognizer.handleUp(createMatches({ doubletap: onDoubleTap }), fakeEvent());
expect(onDoubleTap).not.toHaveBeenCalled();
});
});
});
+3
View File
@@ -1,4 +1,7 @@
export * from './feature';
export * from './gesture/coordinator';
export * from './gesture/create-tap-gesture';
export * from './gesture/gesture';
export * from './hotkey/actions';
export * from './hotkey/aria';
export * from './hotkey/coordinator';