mirror of https://github.com/lukechilds/docs.git
Thomas Osmonson
4 years ago
committed by
Thomas Osmonson
2 changed files with 43 additions and 445 deletions
@ -1,422 +1,14 @@ |
|||
/** |
|||
* The state machine used here is based on the one provided |
|||
* in react-native-web: |
|||
* |
|||
* https://github.com/necolas/react-native-web/blob/master/packages/react-native-web/src/exports/Touchable/index.js
|
|||
*/ |
|||
|
|||
import * as React from 'react'; |
|||
import { isHoverEnabled } from '@common/utils/hover-enabled'; |
|||
import { useGestureResponder } from 'react-gesture-responder'; |
|||
|
|||
/** |
|||
* useTouchable |
|||
* |
|||
* useTouchable is a hook that attempt to emulate native touch behaviour for things |
|||
* like list items, buttons, etc. |
|||
* |
|||
* const { bind, active } = useTouchable({ |
|||
* onPress: () => console.log('hello'), |
|||
* disabled: false, |
|||
* delay: 120 |
|||
* }) |
|||
* |
|||
*/ |
|||
|
|||
const HIGHLIGHT_DELAY_MS = 100; |
|||
const PRESS_EXPAND_PX = 20; |
|||
const LONG_PRESS_DELAY = 500 - HIGHLIGHT_DELAY_MS; |
|||
|
|||
type States = |
|||
| 'ERROR' |
|||
| 'NOT_RESPONDER' |
|||
| 'RESPONDER_ACTIVE_IN' |
|||
| 'RESPONDER_ACTIVE_OUT' |
|||
| 'RESPONDER_PRESSED_IN' |
|||
| 'RESPONDER_PRESSED_OUT' |
|||
| 'RESPONDER_LONG_PRESSED_IN'; |
|||
|
|||
type Events = |
|||
| 'DELAY' |
|||
| 'RESPONDER_GRANT' |
|||
| 'RESPONDER_RELEASE' |
|||
| 'RESPONDER_TERMINATED' |
|||
| 'ENTER_PRESS_RECT' |
|||
| 'LEAVE_PRESS_RECT' |
|||
| 'LONG_PRESS_DETECTED'; |
|||
|
|||
type TransitionsType = { [key in States]: TransitionType }; |
|||
|
|||
type TransitionType = { [key in Events]: States }; |
|||
|
|||
const transitions = { |
|||
NOT_RESPONDER: { |
|||
DELAY: 'NOT_RESPONDER', |
|||
RESPONDER_GRANT: 'RESPONDER_ACTIVE_IN', |
|||
RESPONDER_RELEASE: 'NOT_RESPONDER', |
|||
RESPONDER_TERMINATED: 'NOT_RESPONDER', |
|||
ENTER_PRESS_RECT: 'NOT_RESPONDER', |
|||
LEAVE_PRESS_RECT: 'NOT_RESPONDER', |
|||
LONG_PRESS_DETECTED: 'NOT_RESPONDER', |
|||
}, |
|||
RESPONDER_ACTIVE_IN: { |
|||
DELAY: 'RESPONDER_PRESSED_IN', |
|||
RESPONDER_GRANT: 'ERROR', |
|||
RESPONDER_RELEASE: 'NOT_RESPONDER', |
|||
RESPONDER_TERMINATED: 'NOT_RESPONDER', |
|||
ENTER_PRESS_RECT: 'RESPONDER_ACTIVE_IN', |
|||
LEAVE_PRESS_RECT: 'RESPONDER_ACTIVE_OUT', |
|||
LONG_PRESS_DETECTED: 'ERROR', |
|||
}, |
|||
RESPONDER_ACTIVE_OUT: { |
|||
DELAY: 'RESPONDER_PRESSED_OUT', |
|||
RESPONDER_GRANT: 'ERROR', |
|||
RESPONDER_RELEASE: 'NOT_RESPONDER', |
|||
RESPONDER_TERMINATED: 'NOT_RESPONDER', |
|||
ENTER_PRESS_RECT: 'RESPONDER_ACTIVE_IN', |
|||
LEAVE_PRESS_RECT: 'RESPONDER_ACTIVE_OUT', |
|||
LONG_PRESS_DETECTED: 'ERROR', |
|||
}, |
|||
RESPONDER_PRESSED_IN: { |
|||
DELAY: 'ERROR', |
|||
RESPONDER_GRANT: 'ERROR', |
|||
RESPONDER_RELEASE: 'NOT_RESPONDER', |
|||
RESPONDER_TERMINATED: 'NOT_RESPONDER', |
|||
ENTER_PRESS_RECT: 'RESPONDER_PRESSED_IN', |
|||
LEAVE_PRESS_RECT: 'RESPONDER_PRESSED_OUT', |
|||
LONG_PRESS_DETECTED: 'RESPONDER_LONG_PRESSED_IN', |
|||
}, |
|||
RESPONDER_PRESSED_OUT: { |
|||
DELAY: 'ERROR', |
|||
RESPONDER_GRANT: 'ERROR', |
|||
RESPONDER_RELEASE: 'NOT_RESPONDER', |
|||
RESPONDER_TERMINATED: 'NOT_RESPONDER', |
|||
ENTER_PRESS_RECT: 'RESPONDER_PRESSED_IN', |
|||
LEAVE_PRESS_RECT: 'RESPONDER_PRESSED_OUT', |
|||
LONG_PRESS_DETECTED: 'ERROR', |
|||
}, |
|||
RESPONDER_LONG_PRESSED_IN: { |
|||
DELAY: 'ERROR', |
|||
RESPONDER_GRANT: 'ERROR', |
|||
RESPONDER_RELEASE: 'NOT_RESPONDER', |
|||
RESPONDER_TERMINATED: 'NOT_RESPONDER', |
|||
ENTER_PRESS_RECT: 'RESPONDER_PRESSED_IN', |
|||
LEAVE_PRESS_RECT: 'RESPONDER_PRESSED_OUT', |
|||
LONG_PRESS_DETECTED: 'RESPONDER_LONG_PRESSED_IN', |
|||
}, |
|||
ERROR: { |
|||
DELAY: 'NOT_RESPONDER', |
|||
RESPONDER_GRANT: 'RESPONDER_ACTIVE_IN', |
|||
RESPONDER_RELEASE: 'NOT_RESPONDER', |
|||
RESPONDER_TERMINATED: 'NOT_RESPONDER', |
|||
ENTER_PRESS_RECT: 'NOT_RESPONDER', |
|||
LEAVE_PRESS_RECT: 'NOT_RESPONDER', |
|||
LONG_PRESS_DETECTED: 'NOT_RESPONDER', |
|||
}, |
|||
} as TransitionsType; |
|||
|
|||
export type OnPressFunction = ( |
|||
e?: React.TouchEvent | React.MouseEvent | React.KeyboardEvent | Event |
|||
) => void; |
|||
|
|||
export interface TouchableOptions { |
|||
delay: number; |
|||
longPressDelay: number; |
|||
pressExpandPx: number; |
|||
behavior: 'button' | 'link'; |
|||
disabled: boolean; |
|||
terminateOnScroll: boolean; |
|||
onPress?: OnPressFunction; |
|||
onLongPress?: OnPressFunction; |
|||
} |
|||
|
|||
const defaultOptions: TouchableOptions = { |
|||
delay: HIGHLIGHT_DELAY_MS, |
|||
pressExpandPx: PRESS_EXPAND_PX, |
|||
longPressDelay: LONG_PRESS_DELAY, |
|||
behavior: 'button', |
|||
disabled: false, |
|||
terminateOnScroll: true, |
|||
onPress: undefined, |
|||
onLongPress: undefined, |
|||
}; |
|||
|
|||
export function useTouchable(options: Partial<TouchableOptions> = {}) { |
|||
const { |
|||
onPress, |
|||
onLongPress, |
|||
longPressDelay, |
|||
terminateOnScroll, |
|||
delay, |
|||
behavior, |
|||
disabled: localDisabled, |
|||
} = { |
|||
...defaultOptions, |
|||
...options, |
|||
}; |
|||
const disabled = localDisabled; |
|||
const ref = React.useRef<HTMLAnchorElement | HTMLDivElement | any>(null); |
|||
const delayTimer = React.useRef<number>(); |
|||
const longDelayTimer = React.useRef<number>(); |
|||
const bounds = React.useRef<ClientRect>(); |
|||
const [hover, setHover] = React.useState(false); |
|||
const [showHover, setShowHover] = React.useState(true); |
|||
const [active, setActive] = React.useState(false); |
|||
const state = React.useRef<States>('NOT_RESPONDER'); |
|||
|
|||
/** |
|||
* Transition from one state to another |
|||
* @param event |
|||
*/ |
|||
|
|||
function dispatch(event: Events) { |
|||
const nextState = transitions[state.current][event]; |
|||
state.current = nextState; |
|||
|
|||
if (nextState === 'RESPONDER_PRESSED_IN' || nextState === 'RESPONDER_LONG_PRESSED_IN') { |
|||
setActive(true); |
|||
} else { |
|||
setActive(false); |
|||
} |
|||
|
|||
if (nextState === 'NOT_RESPONDER') { |
|||
clearTimeout(delayTimer.current); |
|||
clearTimeout(longDelayTimer.current); |
|||
} |
|||
} |
|||
|
|||
// create a pan responder to handle mouse / touch gestures
|
|||
const { bind, terminateCurrentResponder } = useGestureResponder({ |
|||
onStartShouldSet: () => true, |
|||
onGrant: () => { |
|||
onStart(isHoverEnabled() ? 0 : undefined); |
|||
}, |
|||
onRelease: (_state, e) => onEnd(e), |
|||
onMove: (_state, e) => onTouchMove(e), |
|||
onTerminate: _state => onTerminate(), |
|||
}); |
|||
|
|||
/** |
|||
* Emit a press event if not disabled |
|||
* @param e |
|||
*/ |
|||
|
|||
function emitPress(e: React.TouchEvent | React.MouseEvent | React.KeyboardEvent | Event) { |
|||
if (!disabled && onPress) { |
|||
onPress(e); |
|||
} |
|||
} |
|||
|
|||
function emitLongPress() { |
|||
if (!disabled && onLongPress) { |
|||
onLongPress(); |
|||
} |
|||
} |
|||
|
|||
function bindScroll() { |
|||
if (terminateOnScroll) { |
|||
document.addEventListener('scroll', onScroll, { |
|||
capture: true, |
|||
passive: true, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
function unbindScroll() { |
|||
document.removeEventListener('scroll', onScroll, true); |
|||
} |
|||
|
|||
function afterDelay() { |
|||
dispatch('DELAY'); |
|||
} |
|||
|
|||
/** |
|||
* Get our initial bounding box clientRect and set any delay |
|||
* timers if necessary. |
|||
* @param delayPressMs |
|||
*/ |
|||
|
|||
function onStart(delayPressMs = delay) { |
|||
dispatch('RESPONDER_GRANT'); |
|||
bounds.current = ref.current?.getBoundingClientRect(); |
|||
delayTimer.current = delayPressMs > 0 ? window.setTimeout(afterDelay, delayPressMs) : undefined; |
|||
|
|||
if (delayPressMs === 0) { |
|||
dispatch('DELAY'); |
|||
} |
|||
|
|||
longDelayTimer.current = window.setTimeout(afterLongDelay, longPressDelay); |
|||
|
|||
bindScroll(); |
|||
setShowHover(false); |
|||
} |
|||
|
|||
function afterLongDelay() { |
|||
dispatch('LONG_PRESS_DETECTED'); |
|||
emitLongPress(); |
|||
} |
|||
|
|||
// onTerminate should be disambiguated from onRelease
|
|||
// because it should never trigger onPress events.
|
|||
function onTerminate() { |
|||
if (state.current === 'NOT_RESPONDER') { |
|||
return; |
|||
} |
|||
|
|||
dispatch('RESPONDER_RELEASE'); |
|||
setShowHover(true); |
|||
unbindScroll(); |
|||
} |
|||
|
|||
function onEnd(e?: React.TouchEvent | React.MouseEvent | React.KeyboardEvent | Event) { |
|||
// consider unbinding the end event instead
|
|||
if (state.current === 'NOT_RESPONDER') { |
|||
return; |
|||
} |
|||
|
|||
if ( |
|||
e && |
|||
(state.current === 'RESPONDER_ACTIVE_IN' || state.current === 'RESPONDER_PRESSED_IN') |
|||
) { |
|||
emitPress(e); |
|||
} |
|||
|
|||
dispatch('RESPONDER_RELEASE'); |
|||
setShowHover(true); |
|||
unbindScroll(); |
|||
} |
|||
|
|||
function isWithinActiveBounds( |
|||
clientX: number, |
|||
clientY: number, |
|||
rect: ClientRect, |
|||
expandPx: number = PRESS_EXPAND_PX |
|||
) { |
|||
return ( |
|||
clientX > rect.left - expandPx && |
|||
clientY > rect.top - expandPx && |
|||
clientX < rect.right + expandPx && |
|||
clientY < rect.bottom + expandPx |
|||
); |
|||
} |
|||
|
|||
/** |
|||
* Determine if the touch remains in the active bounds |
|||
* @param e |
|||
*/ |
|||
|
|||
function onTouchMove(e: any) { |
|||
if (state.current === 'NOT_RESPONDER' || state.current === 'ERROR') { |
|||
return; |
|||
} |
|||
|
|||
clearTimeout(longDelayTimer.current); |
|||
|
|||
const { clientX, clientY } = e.touches && e.touches[0] ? e.touches[0] : e; |
|||
const withinBounds = isWithinActiveBounds(clientX, clientY, bounds.current); |
|||
|
|||
if (withinBounds) { |
|||
dispatch('ENTER_PRESS_RECT'); |
|||
} else { |
|||
dispatch('LEAVE_PRESS_RECT'); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Scrolling cancels all responder events. This enables |
|||
* the user to scroll without selecting something |
|||
*/ |
|||
|
|||
function onScroll() { |
|||
unbindScroll(); |
|||
dispatch('RESPONDER_TERMINATED'); |
|||
} |
|||
|
|||
/** |
|||
* If our mouse leaves we terminate our responder, |
|||
* even if our press remains down. This emulates |
|||
* native mouse behaviour. |
|||
* @param e |
|||
*/ |
|||
|
|||
function onMouseLeave() { |
|||
if (hover) { |
|||
setHover(false); |
|||
} |
|||
if (!showHover) { |
|||
setShowHover(true); |
|||
} |
|||
if (state.current !== 'NOT_RESPONDER') { |
|||
terminateCurrentResponder(); |
|||
} |
|||
} |
|||
|
|||
function onMouseEnter() { |
|||
if (!hover) { |
|||
setHover(true); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Handle timer and disabled side-effects |
|||
*/ |
|||
|
|||
React.useEffect(() => { |
|||
return () => { |
|||
clearTimeout(delayTimer.current); |
|||
clearTimeout(longDelayTimer.current); |
|||
unbindScroll(); |
|||
}; |
|||
}, []); |
|||
|
|||
React.useEffect(() => { |
|||
if (disabled && state.current !== 'NOT_RESPONDER') { |
|||
dispatch('RESPONDER_TERMINATED'); |
|||
setShowHover(true); |
|||
} |
|||
}, [disabled]); |
|||
|
|||
/** |
|||
* Keyboard support |
|||
* button: |
|||
* onEnterDown -> onPress |
|||
* onSpaceUp -> onPress |
|||
* Prevent default. |
|||
* |
|||
* link: Don't prevent default |
|||
*/ |
|||
|
|||
function onKey(e: React.KeyboardEvent) { |
|||
const ENTER = 13; |
|||
const SPACE = 32; |
|||
|
|||
if (e.type === 'keydown' && e.which === SPACE) { |
|||
onStart(0); |
|||
} else if (e.type === 'keydown' && e.which === ENTER) { |
|||
emitPress(e); |
|||
} else if (e.type === 'keyup' && e.which === SPACE) { |
|||
onEnd(e); |
|||
} else { |
|||
return; |
|||
} |
|||
|
|||
e.stopPropagation(); |
|||
|
|||
if (!(e.which === ENTER && behavior === 'link')) { |
|||
e.preventDefault(); |
|||
} |
|||
} |
|||
import { useHover, useActive } from 'use-events'; |
|||
|
|||
export const useTouchable = (options?: any) => { |
|||
const [hover, hoverBind] = useHover(); |
|||
const [active, activeBind] = useActive(); |
|||
return { |
|||
bind: { |
|||
...bind, |
|||
onKeyUp: onKey, |
|||
onKeyDown: onKey, |
|||
onMouseEnter, |
|||
onMouseLeave, |
|||
ref, |
|||
...hoverBind, |
|||
...activeBind, |
|||
}, |
|||
active: !disabled && active, |
|||
hover: isHoverEnabled() && !disabled && hover && showHover, |
|||
hover, |
|||
active, |
|||
}; |
|||
} |
|||
}; |
|||
|
Loading…
Reference in new issue