Skip to content

Commit 31ecfb8

Browse files
committed
feat: double-tap recognizer with tap/doubletap arbitration
DoubleTapRecognizer detects two consecutive taps within a configurable interval and movement threshold. TapRecognizer gains deferred recognition via requireFailureOf so single-tap waits for doubletap to fail first. 68 unit tests, 22 E2E tests across 3 browsers, Stryker 89.05%.
1 parent 9eb1c61 commit 31ecfb8

17 files changed

Lines changed: 2311 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- DoubleTapRecognizer with configurable threshold and interval between taps
13+
- Tap + DoubleTap arbitration via `requireFailureOf` with deferred recognition
14+
- Convenience API: `doubleTap(el, callback)` with automatic cleanup and manager reuse
15+
- Subpath export: `fngr/doubletap`
16+
- DoubleTap E2E tests (22 tests x 3 browsers, Playwright)
17+
- DoubleTap unit tests (68 tests)
18+
- DoubleTap documentation page
19+
20+
### Changed
21+
22+
- TapRecognizer now supports deferred recognition when failure dependencies are pending
23+
- Mutation testing score: 89.05% (475 mutants, 43 equivalent survivors documented)
24+
- Total tests: 199 unit + 120 E2E
25+
1026
## [0.0.2] - 2026-04-02
1127

1228
### Added

docs-site/.vitepress/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export default defineConfig({
99
resolve: {
1010
alias: {
1111
'fngr/tap': resolve(__dirname, '../../src/recognizers/tap/index.ts'),
12+
'fngr/doubletap': resolve(__dirname, '../../src/recognizers/doubletap/index.ts'),
1213
'fngr/base': resolve(__dirname, '../../src/core/base-recognizer'),
1314
'fngr': resolve(__dirname, '../../src'),
1415
},
@@ -39,6 +40,7 @@ export default defineConfig({
3940
text: 'API Reference',
4041
items: [
4142
{ text: 'TapRecognizer', link: '/api/tap' },
43+
{ text: 'DoubleTapRecognizer', link: '/api/doubletap' },
4244
{ text: 'Manager', link: '/api/manager' },
4345
{ text: 'BaseRecognizer', link: '/api/base-recognizer' },
4446
{ text: 'Arbitrator', link: '/api/arbitrator' },

docs-site/api/doubletap.md

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# DoubleTapRecognizer
2+
3+
Recognizes two consecutive single-finger taps within a configurable time interval. Both taps must land within the movement threshold of each other.
4+
5+
## Import
6+
7+
**Convenience API** (recommended for most use cases):
8+
9+
```ts
10+
import { doubleTap } from 'fngr/doubletap';
11+
```
12+
13+
**Class API** (for advanced composition with a Manager):
14+
15+
```ts
16+
import { DoubleTapRecognizer } from 'fngr/doubletap';
17+
import { Manager } from 'fngr';
18+
```
19+
20+
::: tip
21+
`DoubleTapRecognizer` is exported from `fngr/doubletap`, not the main `fngr` barrel. `Manager` comes from `fngr`.
22+
:::
23+
24+
## Convenience API
25+
26+
The `doubleTap()` function attaches a double-tap recognizer to an element and returns a cleanup function.
27+
28+
### Callback form
29+
30+
```ts
31+
import { doubleTap } from 'fngr/doubletap';
32+
33+
const el = document.getElementById('target')!;
34+
35+
const cleanup = doubleTap(el, (e) => {
36+
console.log('double-tapped at', e.pointers[0].clientX, e.pointers[0].clientY);
37+
});
38+
39+
// Remove the recognizer when no longer needed
40+
cleanup();
41+
```
42+
43+
### Options object form
44+
45+
```ts
46+
import { doubleTap } from 'fngr/doubletap';
47+
48+
const el = document.getElementById('target')!;
49+
50+
const cleanup = doubleTap(el, {
51+
threshold: 15,
52+
interval: 400,
53+
onDoubletap(e) {
54+
console.log('double-tap count:', e.count);
55+
},
56+
});
57+
58+
cleanup();
59+
```
60+
61+
## Class API
62+
63+
Use `DoubleTapRecognizer` directly when composing multiple recognizers under a shared `Manager`.
64+
65+
```ts
66+
import { Manager } from 'fngr';
67+
import { DoubleTapRecognizer } from 'fngr/doubletap';
68+
69+
const el = document.getElementById('target')!;
70+
const manager = new Manager(el);
71+
72+
const recognizer = new DoubleTapRecognizer({
73+
threshold: 10,
74+
interval: 300,
75+
onDoubletap(e) {
76+
console.log('doubletap', e);
77+
},
78+
});
79+
80+
manager.add(recognizer);
81+
82+
// Tear down
83+
manager.remove(recognizer);
84+
recognizer.destroy();
85+
```
86+
87+
## Tap + DoubleTap Arbitration
88+
89+
When you need both tap and double-tap on the same element, use `requireFailureOf` to prevent the tap from firing before the double-tap has had a chance to complete:
90+
91+
```ts
92+
import { Manager } from 'fngr';
93+
import { TapRecognizer } from 'fngr/tap';
94+
import { DoubleTapRecognizer } from 'fngr/doubletap';
95+
96+
const el = document.getElementById('target')!;
97+
const manager = new Manager(el);
98+
99+
const doubleTap = new DoubleTapRecognizer({
100+
interval: 300,
101+
onDoubletap(e) {
102+
console.log('double-tap!');
103+
},
104+
});
105+
106+
const tap = new TapRecognizer({
107+
onTap(e) {
108+
console.log('single tap!');
109+
},
110+
});
111+
112+
// Tap waits for double-tap to fail first
113+
tap.requireFailureOf(doubleTap);
114+
115+
// Higher priority ensures double-tap gets pointer events first
116+
manager.add(doubleTap, { priority: 10 });
117+
manager.add(tap);
118+
```
119+
120+
With this setup:
121+
122+
- **Single tap:** The tap fires after the double-tap interval expires (300ms delay).
123+
- **Double-tap:** The double-tap fires immediately on the second tap. The single tap does not fire.
124+
125+
## Options
126+
127+
| Option | Type | Default | Description |
128+
| ------------- | ----------------------------- | ------- | ---------------------------------------------------------------------------------------------- |
129+
| `threshold` | `number` | `10` | Maximum distance in pixels between the two taps, and maximum pointer movement within each tap. |
130+
| `interval` | `number` | `300` | Maximum time in milliseconds between the first tap and the second tap. |
131+
| `onDoubletap` | `(e: DoubleTapEvent) => void` || Callback invoked when a double-tap is recognized. |
132+
133+
## DoubleTapEvent
134+
135+
The event object passed to the `onDoubletap` callback.
136+
137+
| Property | Type | Description |
138+
| ---------------- | --------------- | ------------------------------------------------------------------------------------- |
139+
| `type` | `'doubletap'` | Always `'doubletap'`. |
140+
| `count` | `2` | Always `2`. |
141+
| `target` | `Element` | The element the gesture was initiated on. |
142+
| `pointers` | `PointerInfo[]` | Array of pointer snapshots from the second tap at the time of recognition. |
143+
| `timestamp` | `number` | The `PointerEvent.timeStamp` value from the triggering event. |
144+
| `srcEvent` | `PointerEvent` | The raw DOM `PointerEvent` that triggered recognition (the second tap's `pointerup`). |
145+
| `preventDefault` | `() => void` | Calls `preventDefault()` on the underlying source event. |
146+
147+
Each `PointerInfo` object in `pointers` has the following shape:
148+
149+
```ts
150+
interface PointerInfo {
151+
id: number;
152+
clientX: number;
153+
clientY: number;
154+
pageX: number;
155+
pageY: number;
156+
}
157+
```
158+
159+
## CustomEvent
160+
161+
In addition to the `onDoubletap` callback, `fngr` dispatches a DOM `CustomEvent` on the target element so you can listen with standard event listeners.
162+
163+
```ts
164+
el.addEventListener('fngr:doubletap', (e) => {
165+
const detail = (e as CustomEvent<DoubleTapEvent>).detail;
166+
console.log('doubletap at', detail.pointers[0].clientX, detail.pointers[0].clientY);
167+
});
168+
```
169+
170+
## State Machine
171+
172+
`DoubleTapRecognizer` follows a variation of the standard `fngr` discrete state machine. It stays in `Possible` between the two taps:
173+
174+
```
175+
pointerdown (1st)
176+
Idle ─────────────────────► Possible
177+
▲ │
178+
│ movement exceeds │ pointerup (1st, valid)
179+
│ threshold, cancel, │ → stays Possible, starts timeout
180+
│ or timeout │
181+
│ │ │ pointerdown (2nd)
182+
│ └──────► Failed │ → stays Possible
183+
│ │ │
184+
│ │ │ pointerup (2nd, within
185+
│ │ │ threshold + interval)
186+
│ │ ▼
187+
└────────────────────┘ Recognized
188+
reset │
189+
◄─────────────────────────────┘
190+
reset
191+
```
192+
193+
| Transition | Trigger |
194+
| --------------------- | ------------------------------------------------------------------------------------------- |
195+
| Idle → Possible | First `pointerdown` received |
196+
| Possible → Possible | First `pointerup` valid (waiting for second tap), or second `pointerdown` |
197+
| Possible → Failed | Movement exceeds `threshold`, `pointercancel`, or timeout (no second tap within `interval`) |
198+
| Possible → Recognized | Second `pointerup` within `threshold` and `interval` |
199+
| Recognized → Idle | Automatic reset after emitting the event |
200+
| Failed → Idle | Automatic reset after failure |

docs-site/contributing.md

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,12 @@ BaseRecognizer.emit
4343

4444
### Core components
4545

46-
| File | Role |
47-
|------|------|
48-
| `src/core/manager.ts` | Attaches to a DOM element, routes raw pointer events to all registered recognizers |
46+
| File | Role |
47+
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------- |
48+
| `src/core/manager.ts` | Attaches to a DOM element, routes raw pointer events to all registered recognizers |
4949
| `src/core/base-recognizer.ts` | Abstract base class: state machine, `transition()`, `emit()`, failure-dependency and simultaneous-recognition wiring |
50-
| `src/core/arbitrator.ts` | Decides whether a recognizer may recognize, and which competing recognizers to fail when one succeeds |
51-
| `src/core/pointer-tracker.ts` | Tracks active pointers and their start positions across a gesture's lifetime |
50+
| `src/core/arbitrator.ts` | Decides whether a recognizer may recognize, and which competing recognizers to fail when one succeeds |
51+
| `src/core/pointer-tracker.ts` | Tracks active pointers and their start positions across a gesture's lifetime |
5252

5353
---
5454

@@ -71,8 +71,12 @@ fngr/
7171
│ │ ├── tap.recognizer.ts # TapRecognizer class + tap() helper
7272
│ │ └── models/
7373
│ │ └── tap.ts # TapEvent, TapOptions
74+
│ ├── doubletap/
75+
│ │ ├── index.ts # Barrel: exports DoubleTapRecognizer, doubleTap, types
76+
│ │ ├── doubletap.recognizer.ts # DoubleTapRecognizer class + doubleTap() helper
77+
│ │ └── models/
78+
│ │ └── doubletap.ts # DoubleTapEvent, DoubleTapOptions
7479
│ └── models/ # Type stubs for future recognizers
75-
│ ├── doubletap.ts
7680
│ ├── longpress.ts
7781
│ └── …
7882
├── tests/
@@ -83,15 +87,18 @@ fngr/
8387
│ │ ├── pointer-tracker.test.ts
8488
│ │ └── types.test.ts
8589
│ ├── recognizers/ # Unit tests for each recognizer
86-
│ │ └── tap.test.ts
90+
│ │ ├── tap.test.ts
91+
│ │ └── doubletap.test.ts
8792
│ └── helpers/ # Shared test utilities
8893
│ ├── pointer.ts # PointerEvent factory helpers
8994
│ └── setup.ts # vitest globalSetup (polyfills, etc.)
9095
├── e2e/ # Playwright end-to-end tests
91-
│ └── tap.spec.ts
96+
│ ├── tap.spec.ts
97+
│ └── doubletap.spec.ts
9298
├── examples/ # Standalone HTML demos (served by Vite)
9399
│ ├── index.html
94100
│ ├── tap.html
101+
│ ├── doubletap.html
95102
│ └── shared/
96103
│ ├── setup.ts
97104
│ └── style.css

0 commit comments

Comments
 (0)