motion-dom
Version:
275 lines (272 loc) • 10.9 kB
JavaScript
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