|
| 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. |
0 commit comments