UNPKG

motion-dom

Version:
275 lines (272 loc) • 10.9 kB
import { secondsToMilliseconds } from 'motion-utils'; import { cancelFrame, frame } from '../../frameloop/frame.mjs'; import { time } from '../../frameloop/sync-time.mjs'; import { bindToScope } from '../../scope/active.mjs'; import { isHTMLElement } from '../../utils/is-html-element.mjs'; import { isDragActive } from '../drag/state/is-active.mjs'; import { isNodeOrChild } from '../utils/is-node-or-child.mjs'; import { isPrimaryPointer } from '../utils/is-primary-pointer.mjs'; import { setupGesture } from '../utils/setup.mjs'; import { isElementKeyboardAccessible } from './utils/is-keyboard-accessible.mjs'; import { enableKeyboardPress } from './utils/keyboard.mjs'; import { isPressing } from './utils/state.mjs'; /** * Filter out events that are not primary pointer events, or are triggering * while a Motion gesture is active. */ function isValidPressEvent(event) { return isPrimaryPointer(event) && !isDragActive(); } const claimedPointerDownEvents = new WeakSet(); /** * Max distance in px between consecutive presses of a `count` sequence. * Android's `ViewConfiguration` double tap slop is 100dp. */ const sequenceSlop = 100; /** * Max distance in px a pointer can move during a held press (`hold` * pending, or `repeat`). Android's `ViewConfiguration` touch slop is 8dp. */ const holdSlop = 8; const exceeds = (a, b, max) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) > max; const preventDefault = (event) => event.preventDefault(); /** * Create a press gesture. * * Press is different to `"pointerdown"`, `"pointerup"` in that it * automatically filters out secondary pointer events like right * click and multitouch. * * It also adds accessibility support for keyboards, where * an element with a press gesture will receive focus and * trigger on Enter `"keydown"` and `"keyup"` events. * * This is different to a browser's `"click"` event, which does * respond to keyboards but only for the `"click"` itself, rather * than the press start and end/cancel. The element also needs * to be focusable for this to work, whereas a press gesture will * make an element focusable by default. * * `onPressStart` fires on the first pointer or key down, and can return * a function to call when the press ends, or an object of handlers for * this press: * * ```js * press(button, () => ({ * onHold: (progress) => {}, * onComplete: (event, { success }) => {}, * }), { hold: 0.5 }) * ``` * * @public */ function press(targetOrSelector, onPressStart, options = {}) { onPressStart = bindToScope(onPressStart); const [targets, eventOptions, cancelEvents] = setupGesture(targetOrSelector, options); const sequences = new WeakMap(); const startPress = (startEvent) => { const target = startEvent.currentTarget; if (!isValidPressEvent(startEvent)) return; if (claimedPointerDownEvents.has(startEvent)) return; if (options.stopPropagation) { claimedPointerDownEvents.add(startEvent); } /** * Options are read per press so they can be updated between presses. */ const { count: presses = 1, countTimeout = 0.3, hold = 0, repeat, } = options; const downTime = time.now(); const holdTime = secondsToMilliseconds(hold); const timeout = secondsToMilliseconds(countTimeout); let previous = sequences.get(target); /** * A press during a `count` sequence continues it. If it's too late * or too far away, the sequence is cancelled and this press starts * a new one. */ if (previous?.expire(exceeds(previous.event, startEvent, sequenceSlop))) { previous = undefined; } else if (previous) { cancelFrame(previous.expire); sequences.delete(target); } const count = previous ? previous.count + 1 : 1; const isFinal = count >= presses; const isHeld = isFinal && (hold || repeat); let isActive = !hold; let repeats = 0; let handlers; isPressing.add(target); if (previous) { handlers = previous.handlers; } else { const result = onPressStart(target, startEvent); handlers = typeof result === "function" ? { onComplete: result } : result || {}; } isFinal && isActive && handlers.onActive?.(); /** * While held, `onHold` reports progress from 0 to 1 over `hold`, * then over each `repeat`. Each cycle reports exactly 1 at its end, * then 0 on the next frame. */ const repeatTime = secondsToMilliseconds(repeat); let length = holdTime || repeatTime; let cycleEnd = downTime + length; let isCycleStart = true; const tick = () => { if (eventOptions.signal.aborted) return cancelFrame(tick); let progress = Math.min(1 - (cycleEnd - time.now()) / length, 1); /** * The frame after a boundary reports 0, so each cycle visibly * restarts. Boundaries are timed from `downTime`, so this * doesn't delay the next one. If a long frame has already * passed the next boundary, it reports 1 instead. */ if (isCycleStart && progress < 1) progress = 0; isCycleStart = false; handlers.onHold?.(progress); if (progress < 1) return; if (isActive) { handlers.onRepeat?.(++repeats); } else { isActive = true; handlers.onActive?.(); } if (repeat) { isCycleStart = true; cycleEnd += length = repeatTime; } else { cancelFrame(tick); } }; /** * End listeners run in the capture phase so a descendant calling * stopPropagation() in its own pointerup handler can't prevent the * press gesture from ending. This also keeps the gesture-end * ordering consistent with the drag gesture. See #2794. */ const endEventOptions = { ...eventOptions, capture: true }; const onPointerEnd = (endEvent, success) => { /** * Another touch lifting (for instance after a pinch) doesn't * end the press. */ if (!isPrimaryPointer(endEvent)) return; window.removeEventListener("pointerup", onPointerUp, endEventOptions); window.removeEventListener("pointercancel", onPointerCancel, endEventOptions); window.removeEventListener("pointermove", onPointerMove, endEventOptions); target.removeEventListener("contextmenu", preventDefault); cancelFrame(tick); isPressing.delete(target); /** * A press that ends while a drag is active is a cancel. */ if (isDragActive()) success = false; if (success && !isFinal) { /** * Wait for the next press of the sequence, and cancel the * gesture if it doesn't arrive in time. */ const end = time.now(); /** * Cancels the sequence if it's `force`d, or the interval * has passed. Runs every frame, where `force` is frame * data, so it's checked with `=== true`. */ const expire = (force) => { const isAborted = eventOptions.signal.aborted; if (isAborted || force === true || time.now() - end > timeout) { cancelFrame(expire); sequences.delete(target); isAborted || handlers.onComplete?.(endEvent, { success: false }); return true; } }; sequences.set(target, { count, event: startEvent, handlers, expire, }); frame.update(expire, true); } else { /** * Releasing before `hold` is reached is a cancel. */ handlers.onComplete?.(endEvent, { success: success && isActive, }); } }; const onPointerUp = (upEvent) => { onPointerEnd(upEvent, target === window || target === document || options.useGlobalTarget || isNodeOrChild(target, upEvent.target)); }; const onPointerCancel = (cancelEvent) => { onPointerEnd(cancelEvent, false); }; /** * Moving during a pending long press, or a repeating press, cancels * it, so scrolling on touch devices doesn't trigger it. A drag * starting during a `count` sequence cancels it. */ const onPointerMove = (moveEvent) => { if (isPrimaryPointer(moveEvent) && (((repeat || !isActive) && exceeds(startEvent, moveEvent, holdSlop)) || (presses > 1 && isDragActive()))) { onPointerCancel(moveEvent); } }; window.addEventListener("pointerup", onPointerUp, endEventOptions); window.addEventListener("pointercancel", onPointerCancel, endEventOptions); if (isHeld || presses > 1) { window.addEventListener("pointermove", onPointerMove, endEventOptions); } if (isHeld) { frame.update(tick, true); /** * Touch devices open a context menu or callout on long press, * which would cancel the gesture. */ target.addEventListener("contextmenu", preventDefault, { ...eventOptions, passive: false, }); } }; targets.forEach((target) => { const pointerDownTarget = options.useGlobalTarget ? window : target; pointerDownTarget.addEventListener("pointerdown", startPress, eventOptions); if (isHTMLElement(target)) { target.addEventListener("focus", () => enableKeyboardPress(target, eventOptions), eventOptions); if (!isElementKeyboardAccessible(target) && !target.hasAttribute("tabindex")) { target.tabIndex = 0; } } }); return cancelEvents; } export { press }; //# sourceMappingURL=index.mjs.map