Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 227 additions & 0 deletions docs/hooks/use-double-click.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
# useDoubleClick Hook

## Overview

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.

## Problem Statement

On iOS devices, double-click/tap interactions often fail or require multiple attempts due to:

- **Ghost Clicks**: iOS adds a 300ms delay after touch events and can fire both touch and click events
- **Touch Event Handling**: Standard `onDoubleClick` doesn't work reliably with touch events
- **Event Timing**: Inconsistent timing between touch and click events
- **Context Menu**: Long presses can interfere with double-tap detection

## Solution

This hook addresses all these issues by:

1. Using both touch and click event handlers
2. Preventing ghost clicks with smart event deduplication
3. Implementing custom double-tap detection with configurable timing
4. Supporting both single and double click callbacks

## Installation

The hook is located at `src/hooks/use-double-click.ts` and is ready to use in your project.

## API

### Parameters

```typescript
interface UseDoubleClickOptions {
/**
* Callback for single click events
*/
onSingleClick?: (event: MouseEvent | TouchEvent) => void;

/**
* Callback for double click events (required)
*/
onDoubleClick: (event: MouseEvent | TouchEvent) => void;

/**
* Maximum time between clicks to count as double click (in ms)
* @default 300
*/
delay?: number;

/**
* If true, single click callback won't fire (only double click)
* @default false
*/
doubleClickOnly?: boolean;
}
```

### Return Value

```typescript
interface UseDoubleClickReturn {
onClick: (event: MouseEvent) => void;
onTouchEnd: (event: TouchEvent) => void;
}
```

## Usage Examples

### Basic Usage (Single and Double Click)

```tsx
import { useDoubleClick } from '@/hooks/use-double-click';

function MyComponent() {
const { onClick, onTouchEnd } = useDoubleClick({
onSingleClick: () => {
console.log('Single click detected');
},
onDoubleClick: () => {
console.log('Double click detected');
},
});

return (
<button onClick={onClick} onTouchEnd={onTouchEnd}>
Click or Tap Me
</button>
);
}
```

### Double-Click Only Mode

```tsx
const { onClick, onTouchEnd } = useDoubleClick({
onDoubleClick: () => {
console.log('Double click only!');
},
doubleClickOnly: true, // Single clicks are ignored
});
```

### Custom Delay

```tsx
const { onClick, onTouchEnd } = useDoubleClick({
onDoubleClick: () => {
console.log('Slower double click');
},
delay: 500, // Wait up to 500ms for second click
});
```

### With State Management

```tsx
function LikeButton() {
const [likes, setLikes] = useState(0);
const [isLiked, setIsLiked] = useState(false);

const { onClick, onTouchEnd } = useDoubleClick({
onSingleClick: () => {
setIsLiked(!isLiked);
},
onDoubleClick: () => {
setLikes(prev => prev + 1);
setIsLiked(true);
},
});

return (
<button onClick={onClick} onTouchEnd={onTouchEnd}>
❤️ {likes} {isLiked ? '(Liked)' : ''}
</button>
);
}
```

## How It Works

### 1. Click Counting
- Tracks the number of clicks within the specified delay
- Resets counter after double click or timeout

### 2. Ghost Click Prevention
- Records timestamp of touch events
- Ignores click events that occur within 500ms of a touch event
- Prevents duplicate event firing on iOS

### 3. Timer Management
- Uses a timer to detect when clicking has stopped
- Clears timer when new click arrives
- Fires appropriate callback based on click count

### 4. Event Handling
- `onClick`: Handles mouse clicks (desktop)
- `onTouchEnd`: Handles touch events (mobile/iOS)
- Both handlers share the same logic

## Platform Support

- ✅ iOS 18+ (primary target)
- ✅ Android
- ✅ Desktop (Chrome, Firefox, Safari, Edge)
- ✅ Mobile web browsers

## Testing

The hook includes comprehensive tests covering:

- Basic double-click detection
- Single click callback
- Double-click only mode
- Custom delay timing
- iOS touch events
- Ghost click prevention
- Triple-click handling
- Event type mixing

Run tests with:

```bash
pnpm test tests/use-double-click.test.ts --run
```

## Demo Component

A demo component is available at `src/components/examples/DoubleClickDemo.tsx` that showcases:

- Standard mode (single + double click)
- Double-click only mode
- Visual feedback
- Click counters
- Usage examples

## Best Practices

1. **Always include both handlers**: Use both `onClick` and `onTouchEnd` for cross-platform support
2. **Prevent default carefully**: The hook handles `preventDefault` on touch events to prevent ghost clicks
3. **Consider delay timing**: Default 300ms works well, but adjust based on your use case
4. **Test on real devices**: Always test on actual iOS devices when possible
5. **Provide visual feedback**: Give users immediate feedback on interaction

## Troubleshooting

### Double clicks not working on iOS
- Ensure both `onClick` and `onTouchEnd` are attached to your element
- Check that no parent element is calling `stopPropagation()`
- Verify element is not disabled or hidden

### Single clicks firing too early
- Increase the `delay` parameter
- Check that you're not mixing native `onDoubleClick` with this hook

### Ghost clicks still occurring
- The hook should prevent these automatically
- Verify you're using the returned handlers correctly
- Check for conflicting click handlers in parent components

## Related Issues

- [INS-5: iOS Double-Click Bug](linear-issue-url)

## License

Part of the Constructa Starter project.
113 changes: 113 additions & 0 deletions src/components/examples/DoubleClickDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useState } from 'react';
import { useDoubleClick } from '../../hooks/use-double-click';

/**
* Demo component showcasing the iOS-compatible double-click hook
*
* This component demonstrates how to use the useDoubleClick hook
* to handle both single and double click/tap events reliably across
* all platforms, including iOS devices.
*/
export function DoubleClickDemo() {
const [singleClickCount, setSingleClickCount] = useState(0);
const [doubleClickCount, setDoubleClickCount] = useState(0);
const [lastAction, setLastAction] = useState<string>('');

const { onClick, onTouchEnd } = useDoubleClick({
onSingleClick: () => {
setSingleClickCount((prev) => prev + 1);
setLastAction('Single Click');
},
onDoubleClick: () => {
setDoubleClickCount((prev) => prev + 1);
setLastAction('Double Click');
},
});

const doubleClickOnly = useDoubleClick({
onDoubleClick: () => {
setLastAction('Double Click Only Mode');
},
doubleClickOnly: true,
});

return (
<div className="p-8 max-w-2xl mx-auto space-y-8">
<div>
<h1 className="text-3xl font-bold mb-2">iOS Double-Click Demo</h1>
<p className="text-gray-600">
Test the double-click/tap functionality. Works on all devices including iOS.
</p>
</div>

<div className="space-y-4">
{/* Standard mode with both single and double click */}
<div className="border rounded-lg p-6 bg-blue-50">
<h2 className="text-xl font-semibold mb-4">Standard Mode</h2>
<button
onClick={onClick}
onTouchEnd={onTouchEnd}
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"
>
Click or Tap Me!
</button>
<div className="mt-4 space-y-2">
<p className="text-sm">
Single Clicks: <span className="font-bold">{singleClickCount}</span>
</p>
<p className="text-sm">
Double Clicks: <span className="font-bold">{doubleClickCount}</span>
</p>
<p className="text-sm">
Last Action: <span className="font-bold text-blue-600">{lastAction || 'None'}</span>
</p>
</div>
</div>

{/* Double-click only mode */}
<div className="border rounded-lg p-6 bg-purple-50">
<h2 className="text-xl font-semibold mb-4">Double-Click Only Mode</h2>
<button
onClick={doubleClickOnly.onClick}
onTouchEnd={doubleClickOnly.onTouchEnd}
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"
>
Double Click/Tap Only
</button>
<p className="mt-4 text-sm text-gray-600">
This button only responds to double clicks/taps. Single clicks are ignored.
</p>
</div>
</div>

<div className="bg-gray-50 rounded-lg p-6">
<h3 className="font-semibold mb-2">Features:</h3>
<ul className="list-disc list-inside space-y-1 text-sm text-gray-700">
<li>Reliable double-tap detection on iOS devices</li>
<li>Prevents ghost clicks (300ms delay issue on iOS)</li>
<li>Configurable delay between clicks</li>
<li>Optional single-click callback</li>
<li>Works across all platforms (iOS, Android, Desktop)</li>
<li>Touch and click event support</li>
</ul>
</div>

<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<h3 className="font-semibold mb-2">💡 Usage Example:</h3>
<pre className="text-xs bg-white p-4 rounded overflow-x-auto">
{`import { useDoubleClick } from '@/hooks/use-double-click';

const { onClick, onTouchEnd } = useDoubleClick({
onSingleClick: () => console.log('single'),
onDoubleClick: () => console.log('double'),
delay: 300, // optional, default is 300ms
});

<button onClick={onClick} onTouchEnd={onTouchEnd}>
Click me
</button>`}
</pre>
</div>
</div>
);
}
Loading
Loading