Skip to content
Open
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
32 changes: 32 additions & 0 deletions .changeset/tap-gesture-feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@use-gesture/core": minor
"@use-gesture/react": minor
"@use-gesture/vanilla": minor
---

Add tap gesture with single/double tap and long press discrimination

New tap gesture provides discrimination between single taps, double taps, and long presses:

- **singleTap** - Confirmed after `tapTimeout` (180ms) with no second tap
- **doubleTap** - Two taps within `tapTimeout` window
- **longPress** - Pointer held for `longPressTimeout` ms
- **tapCount** - Number of taps detected (1 or 2)

Configuration options:
- `tapDiscrimination` - Enable single/double tap discrimination (default: true)
- `tapTimeout` - Max time between taps for double tap (default: 180ms)
- `longPressTimeout` - Time to trigger long press (default: 0 = disabled)
- `moveThreshold` - Max movement before tap is canceled (default: 10px)

Usage:
```tsx
// React
import { useTap } from '@use-gesture/react'

const bind = useTap(({ singleTap, doubleTap, longPress, tapCount }) => {
if (singleTap) console.log('single tap')
if (doubleTap) console.log('double tap')
if (longPress) console.log('long press')
})
```
1 change: 1 addition & 0 deletions .codesandbox/ci.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"/demo/src/sandboxes/gesture-pinch",
"/demo/src/sandboxes/gesture-pinch-multiple",
"/demo/src/sandboxes/gesture-three",
"/demo/src/sandboxes/gesture-tap",
"/demo/src/sandboxes/card-zoom",
"/demo/src/sandboxes/viewpager"
],
Expand Down
2 changes: 2 additions & 0 deletions demo/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import InfiniteSlideshow from './sandboxes/infinite-slideshow/src/App'
import ActionSheet from './sandboxes/action-sheet/src/App'
import DotsConnect from './sandboxes/dots-connect/src/App'
import NativeVsLib from './sandboxes/native-vs-lib/src/App'
import Tap from './sandboxes/gesture-tap/src/App'

const links = {
'gesture-simplest': Simplest,
Expand All @@ -42,6 +43,7 @@ const links = {
'gesture-three-prevent-scroll': ThreePreventScroll,
'gesture-scroll': Scroll,
'gesture-wheel': Wheel,
'gesture-tap': Tap,
slide: Slide,
'draggable-list': DraggableList,
'draggable-image': DraggableImage,
Expand Down
33 changes: 33 additions & 0 deletions demo/src/sandboxes/gesture-tap/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "gesture-tap",
"version": "1.0.0",
"main": "src/index.jsx",
"dependencies": {
"@leva-ui/plugin-spring": "*",
"@react-spring/web": "^9.4.5",
"@use-gesture/react": "latest",
"leva": "*",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"react-scripts": "^5.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
],
"devDependencies": {
"@types/lodash-es": "^4.17.4",
"@types/react": "^18.0.3",
"@types/react-dom": "^18.0.0",
"typescript": "^4.9.4",
"typescript-plugin-css-modules": "^4.1.1"
}
}
14 changes: 14 additions & 0 deletions demo/src/sandboxes/gesture-tap/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="theme-color" content="#000000" />
<title>Use-Gesture Tap Sandbox</title>
</head>

<body>
<noscript> You need to enable JavaScript to run this app. </noscript>
<div id="root"></div>
</body>
</html>
92 changes: 92 additions & 0 deletions demo/src/sandboxes/gesture-tap/src/App.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import React from 'react'
import { useTap } from '@use-gesture/react'
import { a, useSpring } from '@react-spring/web'
import { useControls } from 'leva'

import styles from './styles.module.css'

function Tappable() {
const [gestureType, setGestureType] = React.useState('none')
const [tapCount, setTapCount] = React.useState(0)

const { tapDiscrimination, tapTimeout, longPressTimeout, moveThreshold } = useControls({
tapDiscrimination: true,
tapTimeout: { value: 250, step: 50, min: 100, max: 500 },
longPressTimeout: { value: 500, step: 100, min: 200, max: 1000 },
moveThreshold: { value: 10, step: 5, min: 5, max: 50 }
})

const [style, api] = useSpring(() => ({ scale: 1, backgroundColor: '#ec625c' }))

const bind = useTap(
({ singleTap, doubleTap, longPress, tapCount: count, xy, tapping }) => {
setTapCount(count)

if (longPress) {
setGestureType('longPress')
api.start({ scale: 1.3, backgroundColor: '#f472b6' })
setTimeout(() => {
api.start({ scale: 1, backgroundColor: '#ec625c' })
}, 300)
} else if (doubleTap) {
setGestureType('doubleTap')
api.start({ scale: 1.2, backgroundColor: '#60a5fa' })
setTimeout(() => {
api.start({ scale: 1, backgroundColor: '#ec625c' })
}, 300)
} else if (singleTap) {
setGestureType('singleTap')
api.start({ scale: 0.9, backgroundColor: '#4ade80' })
setTimeout(() => {
api.start({ scale: 1, backgroundColor: '#ec625c' })
}, 150)
}

// Reset after showing feedback
if (singleTap || doubleTap || longPress) {
setTimeout(() => setGestureType('none'), 1000)
}
},
{
tapDiscrimination,
tapTimeout,
longPressTimeout,
moveThreshold
}
)

const getLabel = () => {
if (gestureType === 'singleTap') return 'Single Tap!'
if (gestureType === 'doubleTap') return 'Double Tap!'
if (gestureType === 'longPress') return 'Long Press!'
return 'Tap me'
}

return (
<>
<a.div tabIndex={-1} {...bind()} className={`${styles.tap} ${styles[gestureType]}`} style={style}>
<div>
<span>{getLabel()}</span>
<span>count: {tapCount}</span>
</div>
</a.div>
<div className={styles.status}>
<span>Single Tap: {gestureType === 'singleTap' ? '✓' : '-'}</span>
<span>Double Tap: {gestureType === 'doubleTap' ? '✓' : '-'}</span>
<span>Long Press: {gestureType === 'longPress' ? '✓' : '-'}</span>
</div>
<div className={styles.info}>
<p>Try: single tap, double tap, or hold for long press</p>
<p>Use Leva controls to adjust timing thresholds</p>
</div>
</>
)
}

export default function App() {
return (
<div className="flex fill center">
<Tappable />
</div>
)
}
14 changes: 14 additions & 0 deletions demo/src/sandboxes/gesture-tap/src/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
html,
body,
#root {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
}

body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
13 changes: 13 additions & 0 deletions demo/src/sandboxes/gesture-tap/src/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'

import './index.css'

const rootElement = document.getElementById('root')
const root = createRoot(rootElement)
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
)
71 changes: 71 additions & 0 deletions demo/src/sandboxes/gesture-tap/src/styles.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
.tap {
position: absolute;
height: 120px;
width: 120px;
background-color: #ec625c;
cursor: pointer;
touch-action: none;
-webkit-user-select: none;
user-select: none;
border-radius: 8px;
}

.tap > div {
margin: 10%;
width: 80%;
height: 80%;
background-color: #000;
color: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: monospace;
font-size: 11px;
border-radius: 4px;
transition: background-color 0.2s;
}

.tap:focus {
outline: 2px solid #fff;
}

.tap.singleTap > div {
background-color: #4ade80;
}

.tap.doubleTap > div {
background-color: #60a5fa;
}

.tap.longPress > div {
background-color: #f472b6;
}

.status {
position: absolute;
bottom: 100px;
color: #888;
font-family: monospace;
font-size: 14px;
text-align: center;
}

.status span {
display: block;
margin: 4px 0;
}

.info {
position: absolute;
bottom: 40px;
color: #888;
font-family: monospace;
font-size: 11px;
text-align: center;
max-width: 300px;
}

.info p {
margin: 4px 0;
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,5 +113,6 @@
"lint-staged": {
"*.js": "eslint --cache --fix",
"*.{js,css,md}": "prettier --write"
}
},
"packageManager": "pnpm@8.7.5"
}
4 changes: 4 additions & 0 deletions packages/core/src/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { TimeoutStore } from './TimeoutStore'
import { chain } from './utils/fn'
import { GestureKey, InternalConfig, InternalHandlers, NativeHandlers, State, UserGestureConfig } from './types'

/**
* The Controller class is responsible for managing the state of gestures.
*/
export class Controller {
/**
* The list of gestures handled by the Controller.
Expand Down Expand Up @@ -163,6 +166,7 @@ function resolveGestures(ctrl: Controller, internalHandlers: InternalHandlers) {
if (internalHandlers.move) setupGesture(ctrl, 'move')
if (internalHandlers.pinch) setupGesture(ctrl, 'pinch')
if (internalHandlers.hover) setupGesture(ctrl, 'hover')
if (internalHandlers.tap) setupGesture(ctrl, 'tap')
}

const bindToProps =
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import { wheelConfigResolver } from './config/wheelConfigResolver'
import { HoverEngine } from './engines/HoverEngine'
import { hoverConfigResolver } from './config/hoverConfigResolver'

import { TapEngine } from './engines/TapEngine'
import { tapConfigResolver } from './config/tapConfigResolver'

export const EngineMap = new Map<GestureKey, EngineClass<any>>()
export const ConfigResolverMap = new Map<GestureKey, ResolverMap>()

Expand Down Expand Up @@ -62,3 +65,9 @@ export const wheelAction: Action = {
engine: WheelEngine as any,
resolver: wheelConfigResolver
}

export const tapAction: Action = {
key: 'tap',
engine: TapEngine as any,
resolver: tapConfigResolver
}
40 changes: 40 additions & 0 deletions packages/core/src/config/tapConfigResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { commonConfigResolver } from './commonConfigResolver'

export const DEFAULT_TAP_TIMEOUT = 180
export const DEFAULT_LONG_PRESS_TIMEOUT = 0
export const DEFAULT_TAP_MOVE_THRESHOLD = 10

export const tapConfigResolver = {
...commonConfigResolver,
// CoordinatesEngine compatibility - tap doesn't use axis locking
axis(_value?: undefined): undefined {
return undefined
},
axisThreshold(_value = 0) {
return 0
},
lockDirection(_value = false) {
return false
},
tapDiscrimination(value = false) {
return value
},
tapTimeout(value = DEFAULT_TAP_TIMEOUT) {
return value
},
longPressTimeout(value = DEFAULT_LONG_PRESS_TIMEOUT) {
return value
},
moveThreshold(value = DEFAULT_TAP_MOVE_THRESHOLD) {
return value
},
mouseOnly(value = true) {
return value
},
pointerButtons(value: number | number[] | -1 = 1) {
return value
},
pointerCapture(value = true) {
return value
}
}
Loading