Skip to content

Commit b320b78

Browse files
cursoragentregenrek
andcommitted
feat: Add useDoubleClick hook for reliable double-click detection
Co-authored-by: kevin <kevin@macherjek.at>
1 parent b60012c commit b320b78

4 files changed

Lines changed: 794 additions & 0 deletions

File tree

docs/hooks/use-double-click.md

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
# useDoubleClick Hook
2+
3+
## Overview
4+
5+
The `useDoubleClick` hook provides reliable double-click and double-tap detection across all platforms, with special optimizations for iOS devices. It solves common iOS issues such as ghost clicks, delayed touch responses, and inconsistent double-tap behavior.
6+
7+
## Problem Statement
8+
9+
On iOS devices, double-click/tap interactions often fail or require multiple attempts due to:
10+
11+
- **Ghost Clicks**: iOS adds a 300ms delay after touch events and can fire both touch and click events
12+
- **Touch Event Handling**: Standard `onDoubleClick` doesn't work reliably with touch events
13+
- **Event Timing**: Inconsistent timing between touch and click events
14+
- **Context Menu**: Long presses can interfere with double-tap detection
15+
16+
## Solution
17+
18+
This hook addresses all these issues by:
19+
20+
1. Using both touch and click event handlers
21+
2. Preventing ghost clicks with smart event deduplication
22+
3. Implementing custom double-tap detection with configurable timing
23+
4. Supporting both single and double click callbacks
24+
25+
## Installation
26+
27+
The hook is located at `src/hooks/use-double-click.ts` and is ready to use in your project.
28+
29+
## API
30+
31+
### Parameters
32+
33+
```typescript
34+
interface UseDoubleClickOptions {
35+
/**
36+
* Callback for single click events
37+
*/
38+
onSingleClick?: (event: MouseEvent | TouchEvent) => void;
39+
40+
/**
41+
* Callback for double click events (required)
42+
*/
43+
onDoubleClick: (event: MouseEvent | TouchEvent) => void;
44+
45+
/**
46+
* Maximum time between clicks to count as double click (in ms)
47+
* @default 300
48+
*/
49+
delay?: number;
50+
51+
/**
52+
* If true, single click callback won't fire (only double click)
53+
* @default false
54+
*/
55+
doubleClickOnly?: boolean;
56+
}
57+
```
58+
59+
### Return Value
60+
61+
```typescript
62+
interface UseDoubleClickReturn {
63+
onClick: (event: MouseEvent) => void;
64+
onTouchEnd: (event: TouchEvent) => void;
65+
}
66+
```
67+
68+
## Usage Examples
69+
70+
### Basic Usage (Single and Double Click)
71+
72+
```tsx
73+
import { useDoubleClick } from '@/hooks/use-double-click';
74+
75+
function MyComponent() {
76+
const { onClick, onTouchEnd } = useDoubleClick({
77+
onSingleClick: () => {
78+
console.log('Single click detected');
79+
},
80+
onDoubleClick: () => {
81+
console.log('Double click detected');
82+
},
83+
});
84+
85+
return (
86+
<button onClick={onClick} onTouchEnd={onTouchEnd}>
87+
Click or Tap Me
88+
</button>
89+
);
90+
}
91+
```
92+
93+
### Double-Click Only Mode
94+
95+
```tsx
96+
const { onClick, onTouchEnd } = useDoubleClick({
97+
onDoubleClick: () => {
98+
console.log('Double click only!');
99+
},
100+
doubleClickOnly: true, // Single clicks are ignored
101+
});
102+
```
103+
104+
### Custom Delay
105+
106+
```tsx
107+
const { onClick, onTouchEnd } = useDoubleClick({
108+
onDoubleClick: () => {
109+
console.log('Slower double click');
110+
},
111+
delay: 500, // Wait up to 500ms for second click
112+
});
113+
```
114+
115+
### With State Management
116+
117+
```tsx
118+
function LikeButton() {
119+
const [likes, setLikes] = useState(0);
120+
const [isLiked, setIsLiked] = useState(false);
121+
122+
const { onClick, onTouchEnd } = useDoubleClick({
123+
onSingleClick: () => {
124+
setIsLiked(!isLiked);
125+
},
126+
onDoubleClick: () => {
127+
setLikes(prev => prev + 1);
128+
setIsLiked(true);
129+
},
130+
});
131+
132+
return (
133+
<button onClick={onClick} onTouchEnd={onTouchEnd}>
134+
❤️ {likes} {isLiked ? '(Liked)' : ''}
135+
</button>
136+
);
137+
}
138+
```
139+
140+
## How It Works
141+
142+
### 1. Click Counting
143+
- Tracks the number of clicks within the specified delay
144+
- Resets counter after double click or timeout
145+
146+
### 2. Ghost Click Prevention
147+
- Records timestamp of touch events
148+
- Ignores click events that occur within 500ms of a touch event
149+
- Prevents duplicate event firing on iOS
150+
151+
### 3. Timer Management
152+
- Uses a timer to detect when clicking has stopped
153+
- Clears timer when new click arrives
154+
- Fires appropriate callback based on click count
155+
156+
### 4. Event Handling
157+
- `onClick`: Handles mouse clicks (desktop)
158+
- `onTouchEnd`: Handles touch events (mobile/iOS)
159+
- Both handlers share the same logic
160+
161+
## Platform Support
162+
163+
- ✅ iOS 18+ (primary target)
164+
- ✅ Android
165+
- ✅ Desktop (Chrome, Firefox, Safari, Edge)
166+
- ✅ Mobile web browsers
167+
168+
## Testing
169+
170+
The hook includes comprehensive tests covering:
171+
172+
- Basic double-click detection
173+
- Single click callback
174+
- Double-click only mode
175+
- Custom delay timing
176+
- iOS touch events
177+
- Ghost click prevention
178+
- Triple-click handling
179+
- Event type mixing
180+
181+
Run tests with:
182+
183+
```bash
184+
pnpm test tests/use-double-click.test.ts --run
185+
```
186+
187+
## Demo Component
188+
189+
A demo component is available at `src/components/examples/DoubleClickDemo.tsx` that showcases:
190+
191+
- Standard mode (single + double click)
192+
- Double-click only mode
193+
- Visual feedback
194+
- Click counters
195+
- Usage examples
196+
197+
## Best Practices
198+
199+
1. **Always include both handlers**: Use both `onClick` and `onTouchEnd` for cross-platform support
200+
2. **Prevent default carefully**: The hook handles `preventDefault` on touch events to prevent ghost clicks
201+
3. **Consider delay timing**: Default 300ms works well, but adjust based on your use case
202+
4. **Test on real devices**: Always test on actual iOS devices when possible
203+
5. **Provide visual feedback**: Give users immediate feedback on interaction
204+
205+
## Troubleshooting
206+
207+
### Double clicks not working on iOS
208+
- Ensure both `onClick` and `onTouchEnd` are attached to your element
209+
- Check that no parent element is calling `stopPropagation()`
210+
- Verify element is not disabled or hidden
211+
212+
### Single clicks firing too early
213+
- Increase the `delay` parameter
214+
- Check that you're not mixing native `onDoubleClick` with this hook
215+
216+
### Ghost clicks still occurring
217+
- The hook should prevent these automatically
218+
- Verify you're using the returned handlers correctly
219+
- Check for conflicting click handlers in parent components
220+
221+
## Related Issues
222+
223+
- [INS-5: iOS Double-Click Bug](linear-issue-url)
224+
225+
## License
226+
227+
Part of the Constructa Starter project.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { useState } from 'react';
2+
import { useDoubleClick } from '../../hooks/use-double-click';
3+
4+
/**
5+
* Demo component showcasing the iOS-compatible double-click hook
6+
*
7+
* This component demonstrates how to use the useDoubleClick hook
8+
* to handle both single and double click/tap events reliably across
9+
* all platforms, including iOS devices.
10+
*/
11+
export function DoubleClickDemo() {
12+
const [singleClickCount, setSingleClickCount] = useState(0);
13+
const [doubleClickCount, setDoubleClickCount] = useState(0);
14+
const [lastAction, setLastAction] = useState<string>('');
15+
16+
const { onClick, onTouchEnd } = useDoubleClick({
17+
onSingleClick: () => {
18+
setSingleClickCount((prev) => prev + 1);
19+
setLastAction('Single Click');
20+
},
21+
onDoubleClick: () => {
22+
setDoubleClickCount((prev) => prev + 1);
23+
setLastAction('Double Click');
24+
},
25+
});
26+
27+
const doubleClickOnly = useDoubleClick({
28+
onDoubleClick: () => {
29+
setLastAction('Double Click Only Mode');
30+
},
31+
doubleClickOnly: true,
32+
});
33+
34+
return (
35+
<div className="p-8 max-w-2xl mx-auto space-y-8">
36+
<div>
37+
<h1 className="text-3xl font-bold mb-2">iOS Double-Click Demo</h1>
38+
<p className="text-gray-600">
39+
Test the double-click/tap functionality. Works on all devices including iOS.
40+
</p>
41+
</div>
42+
43+
<div className="space-y-4">
44+
{/* Standard mode with both single and double click */}
45+
<div className="border rounded-lg p-6 bg-blue-50">
46+
<h2 className="text-xl font-semibold mb-4">Standard Mode</h2>
47+
<button
48+
onClick={onClick}
49+
onTouchEnd={onTouchEnd}
50+
className="w-full py-8 px-4 bg-blue-500 text-white rounded-lg hover:bg-blue-600 active:bg-blue-700 transition-colors text-lg font-medium"
51+
>
52+
Click or Tap Me!
53+
</button>
54+
<div className="mt-4 space-y-2">
55+
<p className="text-sm">
56+
Single Clicks: <span className="font-bold">{singleClickCount}</span>
57+
</p>
58+
<p className="text-sm">
59+
Double Clicks: <span className="font-bold">{doubleClickCount}</span>
60+
</p>
61+
<p className="text-sm">
62+
Last Action: <span className="font-bold text-blue-600">{lastAction || 'None'}</span>
63+
</p>
64+
</div>
65+
</div>
66+
67+
{/* Double-click only mode */}
68+
<div className="border rounded-lg p-6 bg-purple-50">
69+
<h2 className="text-xl font-semibold mb-4">Double-Click Only Mode</h2>
70+
<button
71+
onClick={doubleClickOnly.onClick}
72+
onTouchEnd={doubleClickOnly.onTouchEnd}
73+
className="w-full py-8 px-4 bg-purple-500 text-white rounded-lg hover:bg-purple-600 active:bg-purple-700 transition-colors text-lg font-medium"
74+
>
75+
Double Click/Tap Only
76+
</button>
77+
<p className="mt-4 text-sm text-gray-600">
78+
This button only responds to double clicks/taps. Single clicks are ignored.
79+
</p>
80+
</div>
81+
</div>
82+
83+
<div className="bg-gray-50 rounded-lg p-6">
84+
<h3 className="font-semibold mb-2">Features:</h3>
85+
<ul className="list-disc list-inside space-y-1 text-sm text-gray-700">
86+
<li>Reliable double-tap detection on iOS devices</li>
87+
<li>Prevents ghost clicks (300ms delay issue on iOS)</li>
88+
<li>Configurable delay between clicks</li>
89+
<li>Optional single-click callback</li>
90+
<li>Works across all platforms (iOS, Android, Desktop)</li>
91+
<li>Touch and click event support</li>
92+
</ul>
93+
</div>
94+
95+
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
96+
<h3 className="font-semibold mb-2">💡 Usage Example:</h3>
97+
<pre className="text-xs bg-white p-4 rounded overflow-x-auto">
98+
{`import { useDoubleClick } from '@/hooks/use-double-click';
99+
100+
const { onClick, onTouchEnd } = useDoubleClick({
101+
onSingleClick: () => console.log('single'),
102+
onDoubleClick: () => console.log('double'),
103+
delay: 300, // optional, default is 300ms
104+
});
105+
106+
<button onClick={onClick} onTouchEnd={onTouchEnd}>
107+
Click me
108+
</button>`}
109+
</pre>
110+
</div>
111+
</div>
112+
);
113+
}

0 commit comments

Comments
 (0)