feat: update useSnapPoints to improve drawer snapping behavior and add new features
Publish to NPM / Check version changes and publish (push) Successful in 11m14s
Publish to NPM / Check version changes and publish (push) Successful in 11m14s
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import { clamp } from '@robonen/stdlib';
|
||||
import type { DrawerDirection } from './types';
|
||||
import {
|
||||
MAX_VELOCITY_AGE,
|
||||
MIN_SETTLE_DURATION,
|
||||
MIN_VELOCITY_DT,
|
||||
REVERSE_CANCEL_ARM_DISTANCE,
|
||||
REVERSE_CANCEL_THRESHOLD,
|
||||
SETTLE_VELOCITY_THRESHOLD,
|
||||
TRANSITIONS,
|
||||
} from './constants';
|
||||
|
||||
/** The client-coordinate axis a drawer drags along. */
|
||||
export type GestureAxis = 'x' | 'y';
|
||||
|
||||
export interface VelocityTracker {
|
||||
/** Record a pointer sample (client coordinate along the axis + event timeStamp). */
|
||||
add: (position: number, time: number) => void;
|
||||
/**
|
||||
* Instantaneous velocity (px/ms) over the two newest samples. Returns 0 when
|
||||
* the last sample is older than {@link MAX_VELOCITY_AGE} — the pointer paused
|
||||
* before release, so no fling momentum should apply.
|
||||
*/
|
||||
read: (now: number) => number;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantaneous release velocity from the trailing pair of pointer samples,
|
||||
* instead of averaging the whole gesture: "slow pull, then flick" reads as a
|
||||
* flick, and "fast start, stop, release" reads as a stop.
|
||||
*/
|
||||
export function createVelocityTracker(): VelocityTracker {
|
||||
let lastPosition = 0;
|
||||
let lastTime = Number.NaN;
|
||||
let velocity = 0;
|
||||
|
||||
return {
|
||||
add(position, time) {
|
||||
if (!Number.isNaN(lastTime) && time > lastTime) {
|
||||
// Clamp dt so same-frame event bursts don't produce huge spikes.
|
||||
const dt = Math.max(time - lastTime, MIN_VELOCITY_DT);
|
||||
velocity = (position - lastPosition) / dt;
|
||||
}
|
||||
|
||||
lastPosition = position;
|
||||
lastTime = time;
|
||||
},
|
||||
read(now) {
|
||||
if (Number.isNaN(lastTime) || now - lastTime > MAX_VELOCITY_AGE)
|
||||
return 0;
|
||||
|
||||
return velocity;
|
||||
},
|
||||
reset() {
|
||||
lastPosition = 0;
|
||||
lastTime = Number.NaN;
|
||||
velocity = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReverseCancelTracker {
|
||||
/** Feed the current dismiss-positive displacement (px). */
|
||||
update: (displacement: number) => void;
|
||||
/** Whether the gesture pulled back far enough to cancel the dismiss. */
|
||||
readonly cancelled: boolean;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the "changed my mind" gesture: once the drawer has been dragged at
|
||||
* least {@link REVERSE_CANCEL_ARM_DISTANCE} toward dismiss, pulling back by
|
||||
* {@link REVERSE_CANCEL_THRESHOLD} from the furthest point cancels the dismiss
|
||||
* even if the release still sits past the close threshold. Dragging past the
|
||||
* previous furthest point re-arms the dismiss (renewed intent).
|
||||
*/
|
||||
export function createReverseCancelTracker(): ReverseCancelTracker {
|
||||
let max = 0;
|
||||
let cancelled = false;
|
||||
|
||||
return {
|
||||
update(displacement) {
|
||||
if (displacement >= max) {
|
||||
max = displacement;
|
||||
cancelled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (max > REVERSE_CANCEL_ARM_DISTANCE && max - displacement > REVERSE_CANCEL_THRESHOLD)
|
||||
cancelled = true;
|
||||
},
|
||||
get cancelled() {
|
||||
return cancelled;
|
||||
},
|
||||
reset() {
|
||||
max = 0;
|
||||
cancelled = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle duration (in seconds) scaled by the release velocity: a hard flick
|
||||
* over a short remaining distance settles in as little as
|
||||
* {@link MIN_SETTLE_DURATION}ms, while a gentle release keeps the default
|
||||
* {@link TRANSITIONS} duration. Never returns a duration longer than the default.
|
||||
*/
|
||||
export function computeSettleDuration(remainingDistance: number, velocity: number): number {
|
||||
const fallback = TRANSITIONS.DURATION;
|
||||
|
||||
if (!Number.isFinite(remainingDistance) || remainingDistance <= 0)
|
||||
return fallback;
|
||||
|
||||
const speed = Math.abs(velocity);
|
||||
|
||||
if (!Number.isFinite(speed) || speed < SETTLE_VELOCITY_THRESHOLD)
|
||||
return fallback;
|
||||
|
||||
return clamp(remainingDistance / speed, MIN_SETTLE_DURATION, fallback * 1000) / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nearest ancestor (from `start` up to and including `boundary`) that can
|
||||
* scroll along `axis`. The `getComputedStyle` read runs at most once per
|
||||
* candidate and only at gesture start — never per pointer move.
|
||||
*/
|
||||
export function findScrollableAncestor(
|
||||
start: Element | null,
|
||||
boundary: HTMLElement,
|
||||
axis: GestureAxis,
|
||||
): HTMLElement | null {
|
||||
let element: Element | null = start;
|
||||
|
||||
while (element) {
|
||||
if (element instanceof HTMLElement) {
|
||||
const canScroll = axis === 'y'
|
||||
? element.scrollHeight > element.clientHeight
|
||||
: element.scrollWidth > element.clientWidth;
|
||||
|
||||
if (canScroll) {
|
||||
const overflow = getComputedStyle(element)[axis === 'y' ? 'overflowY' : 'overflowX'];
|
||||
|
||||
if (overflow === 'auto' || overflow === 'scroll')
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
if (element === boundary)
|
||||
break;
|
||||
|
||||
element = element.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scroll container sits at the edge the dismiss gesture pulls away
|
||||
* from — only then may a drag that starts inside it become a drawer gesture;
|
||||
* otherwise the user is scrolling, not dismissing:
|
||||
* - `bottom` drawer dismisses downward → the scroller must be at its top;
|
||||
* - `top` drawer dismisses upward → at its bottom;
|
||||
* - `right` drawer dismisses rightward → at its left edge;
|
||||
* - `left` drawer dismisses leftward → at its right edge.
|
||||
*/
|
||||
export function isAtScrollEdge(scroller: HTMLElement, direction: DrawerDirection): boolean {
|
||||
switch (direction) {
|
||||
case 'bottom':
|
||||
return scroller.scrollTop <= 0;
|
||||
case 'top':
|
||||
return scroller.scrollTop >= scroller.scrollHeight - scroller.clientHeight;
|
||||
case 'right':
|
||||
return scroller.scrollLeft <= 0;
|
||||
case 'left':
|
||||
return scroller.scrollLeft >= scroller.scrollWidth - scroller.clientWidth;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user