diff --git a/packages/core/src/dom/gesture/coordinator.ts b/packages/core/src/dom/gesture/coordinator.ts new file mode 100644 index 00000000..f03beb1a --- /dev/null +++ b/packages/core/src/dom/gesture/coordinator.ts @@ -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(); + #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(); + +/** 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 { + const regions = new Set(); + 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; +} diff --git a/packages/core/src/dom/gesture/create-tap-gesture.ts b/packages/core/src/dom/gesture/create-tap-gesture.ts new file mode 100644 index 00000000..fd6eba79 --- /dev/null +++ b/packages/core/src/dom/gesture/create-tap-gesture.ts @@ -0,0 +1,64 @@ +import { getGestureCoordinator } from './coordinator'; +import type { GestureOptions } from './gesture'; +import { TapRecognizer } from './tap'; + +const recognizers = new WeakMap(); + +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, + }); +} diff --git a/packages/core/src/dom/gesture/gesture.ts b/packages/core/src/dom/gesture/gesture.ts new file mode 100644 index 00000000..2c9bd25b --- /dev/null +++ b/packages/core/src/dom/gesture/gesture.ts @@ -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[]; +} diff --git a/packages/core/src/dom/gesture/region.ts b/packages/core/src/dom/gesture/region.ts new file mode 100644 index 00000000..c3985d2d --- /dev/null +++ b/packages/core/src/dom/gesture/region.ts @@ -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 | 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 0–50% and center covers 33–66%. + // Overlap at 33–50% 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; +} diff --git a/packages/core/src/dom/gesture/tap.ts b/packages/core/src/dom/gesture/tap.ts new file mode 100644 index 00000000..f3ee3859 --- /dev/null +++ b/packages/core/src/dom/gesture/tap.ts @@ -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 | 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; + } +} diff --git a/packages/core/src/dom/gesture/tests/gesture.test.ts b/packages/core/src/dom/gesture/tests/gesture.test.ts new file mode 100644 index 00000000..d5296edd --- /dev/null +++ b/packages/core/src/dom/gesture/tests/gesture.test.ts @@ -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); +} diff --git a/packages/core/src/dom/gesture/tests/region.test.ts b/packages/core/src/dom/gesture/tests/region.test.ts new file mode 100644 index 00000000..b678abc2 --- /dev/null +++ b/packages/core/src/dom/gesture/tests/region.test.ts @@ -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(['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(['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(['left', 'right']); + expect(resolveRegion(0, zeroRect, regions)).toBe(null); + }); + + describe('single region', () => { + it('left-only covers the left half', () => { + const regions = new Set(['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(['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(['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(['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'); + }); +}); diff --git a/packages/core/src/dom/gesture/tests/tap.test.ts b/packages/core/src/dom/gesture/tests/tap.test.ts new file mode 100644 index 00000000..12e1229d --- /dev/null +++ b/packages/core/src/dom/gesture/tests/tap.test.ts @@ -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 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(); + }); + }); +}); diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts index 11dd7616..227b9c84 100644 --- a/packages/core/src/dom/index.ts +++ b/packages/core/src/dom/index.ts @@ -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';