@humanspeak/svelte-motion
Version:
Framer Motion for Svelte 5. Declarative motion.<tag> components with AnimatePresence exit animations, gestures (hover, tap, drag, focus, in-view), variants, FLIP layout animations, shared-layout transitions, spring physics, and scroll-linked motion values
136 lines (135 loc) • 6.55 kB
TypeScript
/**
* Pan gesture session.
*
* Direct port of framer-motion's `PanSession`
* (`packages/framer-motion/src/gestures/pan/PanSession.ts`) and `PanGesture`
* (`packages/framer-motion/src/gestures/pan/index.ts`). Pan is the
* primitive that powers swipe-to-dismiss drawers, swipe-to-delete rows,
* carousels, and any gesture that tracks pointer offset/velocity without
* the constraint/momentum/snap-to-origin baggage of `drag`.
*
* Critical design notes (mirrors upstream):
*
* - `pointermove`, `pointerup`, `pointercancel` subscribe on the
* `contextWindow` (defaults to `window`), NOT the source element. This
* keeps the gesture alive even when the pointer leaves the element's
* bounds during a fast swipe — the original element is only used for
* the initial `pointerdown` and for scroll-compensation tracking.
*
* - `distanceThreshold` (default `3`px) gates the `onStart` callback so
* a steady press without movement doesn't fire a pan. `onSessionStart`
* fires immediately on pointerdown for setup work.
*
* - Per-frame throttling via `frame.update(updatePoint, true)` so a flood
* of pointermove events doesn't run handlers more than once per render
* frame. On top of that, individual handlers are routed onto motion-dom's
* step lanes (see `wrapUpdate` / `wrapPostRender` above):
* `onSessionStart` / `onStart` / `onMove` land on `update`,
* `onEnd` / `onSessionEnd` on `postRender`. Matches upstream's
* `asyncHandler` + `frame.postRender` split byte-for-byte.
*
* - `getPanInfo` returns `{ point, delta, offset, velocity }` — identical
* shape to motion-dom's `DragInfo` / framer-motion's `PanInfo`.
*
* - Velocity uses a 100ms history window with the "skip the pointer-down
* origin if it's too stale" tweak upstream added for hold-then-flick
* gestures.
*/
import type { DragInfo } from '../types';
export interface PanHandlers {
/** Fires on `pointerdown` regardless of whether movement follows. */
onSessionStart?: (event: PointerEvent, info: DragInfo) => void;
/** Fires the first time pointer offset crosses `distanceThreshold`. */
onStart?: (event: PointerEvent, info: DragInfo) => void;
/** Fires on every per-frame-throttled pointermove past threshold. */
onMove?: (event: PointerEvent, info: DragInfo) => void;
/** Fires on `pointerup` / `pointercancel` if `onStart` ever fired. */
onEnd?: (event: PointerEvent, info: DragInfo) => void;
/** Fires on `pointerup` / `pointercancel` always (paired with `onSessionStart`). */
onSessionEnd?: (event: PointerEvent, info: DragInfo) => void;
}
export interface AttachPanOptions {
/**
* Movement distance (in pixels) required before `onStart`/`onMove`
* fire. Default `3` — same as framer-motion. A steady press with
* sub-threshold drift is reported via `onSessionStart` / `onSessionEnd`
* only.
*/
distanceThreshold?: number;
/**
* Window to attach the move/up/cancel listeners to. Defaults to the
* source element's owner window. Override for iframe / shadow-root
* scenarios.
*/
contextWindow?: Window | null;
}
/**
* Cleanup function returned by `attachPan`. Carries an `update` method
* that hot-swaps the live handler set without tearing down the active
* `PanSession` — call this when a consumer's `onPan` reference changes
* mid-gesture (the canonical Svelte 5 pattern of inline arrow handlers
* passes a fresh closure every render). Without this, the host
* `$effect` would have to teardown + re-attach and the user's in-flight
* pan would silently die.
*/
export type AttachPanCleanup = (() => void) & {
update: (next: PanHandlers) => void;
};
/**
* Attach a pan gesture session to `el`. Returns a cleanup function that
* tears down the pointerdown listener and ends any in-flight session,
* with a `.update(next)` method for hot-swapping handlers mid-gesture.
*
* Internally a fresh `PanSession` spawns on each pointerdown — the
* outer attachment just keeps the pointerdown listener alive across the
* element's lifetime.
*
* SSR-safe: returns a no-op cleanup if `window` is undefined. The Svelte
* `$effect` consumer never fires on the server anyway, but defending the
* boundary lets the module load cleanly in node-only test runners.
*
* Lifecycle guarantee: when the returned cleanup runs mid-gesture, the
* session synthesizes `onEnd` + `onSessionEnd` against the raw handlers
* BEFORE removing listeners (see `PanSession.dispatchTerminal`). Hosts
* (e.g. `_MotionContainer`'s pan `$effect`) can put their `whilePan`
* revert logic inside the user-supplied `onEnd` and rely on it firing
* exactly once per gesture — whether the user released or the host
* forced teardown.
*
* @param el Target element to bind `pointerdown` on. Move/up/cancel
* events are listened for on the element's owning window so a fast
* swipe past the element's bounds keeps the gesture alive.
* @param handlers Pan lifecycle handlers. Any subset of
* `onSessionStart` (fires on pointerdown), `onStart` (fires the first
* time the cumulative offset crosses `distanceThreshold`), `onMove`
* (per-frame-throttled on every pointermove past threshold), `onEnd`
* (fires on pointerup/cancel if `onStart` ever fired), `onSessionEnd`
* (fires on every pointerup/cancel where a pointermove occurred).
* @param options Per-session config. `distanceThreshold` (default 3px)
* gates the start callback; `contextWindow` overrides the owning
* window (use for shadow-root / iframe scenarios).
* @returns A cleanup function with an attached `.update(next)` method.
* Calling the cleanup ends the session + removes the pointerdown
* listener. Calling `.update(next)` swaps handlers in place on the
* live session without rebuilding it — the canonical Svelte pattern
* for inline arrow handlers that change identity each render.
*
* @example
* ```ts
* const cleanup = attachPan(node, {
* onStart: (_event, info) => console.log('start', info.offset),
* onMove: (_event, info) => x.set(info.offset.x),
* onEnd: (_event, info) => {
* if (Math.abs(info.velocity.x) > 600) commit()
* else animate(x, 0, { type: 'spring' })
* }
* })
*
* // Later, swap handlers without ending the live gesture:
* cleanup.update({ onMove: (_e, info) => x.set(info.offset.x * 2) })
*
* // On unmount:
* cleanup()
* ```
*/
export declare const attachPan: (el: HTMLElement, handlers: PanHandlers, options?: AttachPanOptions) => AttachPanCleanup;