@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
288 lines (287 loc) • 12.9 kB
TypeScript
/**
* Local mirrors of `motion-utils`'s `Axis` / `Box` / `AxisDelta` /
* `Delta` types. Inlined because `motion-utils` is a runtime-only
* transitive dep through `motion-dom` — the runtime helpers
* (`calcBoxDelta`, `createDelta`, `isDeltaZero`) are re-exported, but
* the type aliases are not. Same approach we use in
* `src/lib/components/Reorder/context.ts`.
*
* Values match upstream byte-for-byte so handoff between our types and
* the runtime helpers from motion-dom is implicit.
*/
export interface Axis {
min: number;
max: number;
}
export interface Box {
x: Axis;
y: Axis;
}
export interface AxisDelta {
translate: number;
scale: number;
origin: number;
originPoint: number;
}
export interface Delta {
x: AxisDelta;
y: AxisDelta;
}
/**
* Event names the projection node fans out. Mirrors framer-motion's
* `LayoutEvents` subset that's actually consumed externally.
*
* - `willUpdate` — fires inside `willUpdate()`, AFTER the pre-mutation
* snapshot has been captured. Receives the snapshot Box.
* - `didUpdate` — fires inside `didUpdate()`, AFTER the post-mutation
* re-measure. Receives the full `LayoutUpdateData` payload (layout,
* snapshot, delta, hasLayoutChanged).
* - `measure` — fires every time `measure()` returns a non-null Box.
* Useful for debug overlays and follow-up event consumers.
*/
export type ProjectionEventName = 'willUpdate' | 'didUpdate' | 'measure';
/**
* Payload delivered to `didUpdate` listeners.
*
* `layout` is the post-mutation measurement; `snapshot` is the
* pre-mutation one. `delta` is `calcBoxDelta(snapshot, layout)` and is
* the value drag-listeners apply via `originPoint += delta.translate`
* + `motionValue.set(motionValue.get() + delta.translate)` to keep a
* dragged element under the cursor while its slot moves.
*
* `hasLayoutChanged` is `!isDeltaZero(delta)`. `isDeltaZero` (from
* motion-dom) treats an axis as unchanged only when its translate is
* within ±0.01px and its scale within ±0.0001 of identity — a tight
* floating-point epsilon, NOT a 1px rounding threshold. A genuine
* sub-pixel layout shift (say 0.4px) is therefore reported as a change.
*/
export interface ProjectionDidUpdateData {
layout: Box;
snapshot: Box;
delta: Delta;
hasLayoutChanged: boolean;
}
type Listener<E extends ProjectionEventName> = E extends 'didUpdate' ? (data: ProjectionDidUpdateData) => void : E extends 'willUpdate' ? (snapshot: Box) => void : (layout: Box) => void;
/**
* Options passed at `ProjectionNode` construction time. All optional —
* a node with no options still works as a leaf measurement target.
*/
export interface ProjectionNodeOptions {
/**
* Parent node in the projection tree. Wire-up is callsite-driven
* (the consumer reads `getProjectionParent()` from the Svelte
* context system and passes the result here); the node stores it
* as `this.parent` and registers self in `parent.children` on
* `mount()`.
*/
parent?: ProjectionNode | null;
/**
* Thunk returning the `layoutScroll` ancestor chain at measure
* time. Used as the second argument to `measureRect`, which
* shifts the returned rect by the viewport scroll plus the sum of
* ancestor `scrollLeft`/`scrollTop` so FLIP deltas stay correct
* when the page or scrollable ancestors scroll between two measurements.
*
* Returning `[]` (or omitting the option entirely) gives
* viewport-relative measurements — fine for the common case.
*/
getScrollContainers?: () => HTMLElement[];
/**
* Thunk returning the element's USER-authored `transform` — the
* value `measure()` resets to while reading, so the motion-applied
* portion (`initial`/`animate`/FLIP/drag) is stripped but the user's
* own transform is preserved.
*
* Preferred over the mount-time `baseTransform` capture, because at
* mount the inline `style.transform` already carries any
* transform-type `initial` keyframe (it is serialized inline before
* effects run). The consumer therefore sources this from the `style`
* prop instead (see `extractTransform`). When omitted, the node
* falls back to the mount-captured `baseTransform`.
*/
getBaseTransform?: () => string;
}
/**
* Per-element node in the projection tree. Created at component setup
* time in `_MotionContainer.svelte`, mounted when the element ref
* binds, unmounted on cleanup.
*
* Lifecycle:
* 1. `new ProjectionNode({ parent, getScrollContainers })` at setup.
* 2. `node.mount(element)` once the element ref binds.
* 3. `node.willUpdate()` before any layout-mutating state change (e.g.
* a `values` reassign that reorders DOM children).
* 4. State mutates → Svelte commits the DOM update.
* 5. `node.didUpdate()` after the DOM update is flushed — fires
* `didUpdate` listeners with the snapshot→current delta.
* 6. `node.unmount()` on cleanup.
*/
export declare class ProjectionNode {
/** The mounted element. `null` until `mount()` runs. */
element: HTMLElement | null;
/**
* Parent node in the projection tree. Captured at construction
* from the Svelte context. Set to `null` for root-level motion
* elements that have no motion ancestor.
*/
parent: ProjectionNode | null;
/**
* Descendant nodes registered via `mount()`. Iterated when we
* need to broadcast to the subtree (none in this PR; reserved
* for follow-up work).
*/
readonly children: Set<ProjectionNode>;
/** Most-recent post-mutation measurement, or `null` before first measure. */
latestLayout: Box | null;
/**
* Pre-mutation snapshot captured by `willUpdate`. Cleared by
* `didUpdate` after the delta has been computed. Idempotent for
* repeat `willUpdate` calls in the same frame — only the first
* snapshots; subsequent calls no-op so a parent broadcasting
* `willUpdate` to its children doesn't clobber a child's own
* earlier snapshot.
*/
snapshot: Box | null;
/** Whether `mount()` has been called and `unmount()` has not. */
isMounted: boolean;
/**
* Fallback user-authored base transform, captured from
* `element.style.transform` at `mount()` time. Used only when no
* `getBaseTransform` thunk was provided (e.g. unit tests that
* construct a bare node and set the transform before mounting).
*
* For real `motion.*` elements the `getBaseTransform` thunk is
* preferred: capturing at mount is unsafe because a transform-type
* `initial` keyframe is serialized into the inline `style.transform`
* BEFORE effects run, so the mount-time value can be a motion
* transform rather than the user's. `resolveBaseTransform()` picks
* the thunk first.
*
* `measure()` resets ancestors (and self, via `measureRect`) to this
* base rather than to `'none'`, removing the motion-applied portion
* while leaving the user-authored part intact — the same distinction
* framer-motion draws by only subtracting motion-tracked
* `latestValues` in `removeBoxTransforms`.
*/
baseTransform: string;
private readonly listeners;
private readonly getScrollContainers;
private readonly getBaseTransform;
constructor(options?: ProjectionNodeOptions);
/**
* The transform `measure()` resets this node's element to while
* reading. Prefers the `getBaseTransform` thunk (the user's authored
* `style` transform, motion-independent); falls back to the
* mount-captured `baseTransform`. See `getBaseTransform` /
* `baseTransform` for why the thunk is the safe source.
*/
private resolveBaseTransform;
/**
* Register this node with its parent + bind to a DOM element.
* Idempotent — calling `mount()` twice on the same element is a
* no-op for the registration steps.
*
* Re-mounting onto a DIFFERENT element swaps the element in place
* WITHOUT a full `unmount()`. A full unmount would `children.clear()`,
* orphaning still-mounted descendants that registered themselves in
* this node's `children` (they keep their `parent` pointer but the
* set would never be repopulated). Listeners and children are
* therefore preserved across an element swap.
*
* @param element The DOM element this node represents.
*/
mount(element: HTMLElement): void;
/**
* Tear down. Detaches from parent, clears children references,
* drops all listeners. Safe to call on a never-mounted node and
* safe to call twice.
*/
unmount(): void;
/**
* Read the element's layout box with every ancestor's
* motion-applied transform temporarily removed, while preserving
* each ancestor's user-authored base transform.
*
* Mechanism:
* 1. Walk `this.parent` chain bottom-up, collecting every
* mounted ancestor node (excludes self — `measureRect`
* handles self's transform internally).
* 2. Snapshot each ancestor's current `el.style.transform`.
* 3. Set each to its node's resolved base transform (the
* user-authored value, via `resolveBaseTransform`). This strips
* any FLIP/drag/initial transform while keeping the user's static
* one — see `getBaseTransform` / `baseTransform`.
* 4. Delegate to `measureRect(self.element, scrollContainers,
* self base)`, which applies self's base transform inside its own
* try/finally and returns the scroll-compensated DOMRect.
* 5. Restore ancestor transforms in reverse order inside a
* `finally` block — guarantees restoration even if measure
* throws.
* 6. Convert DOMRect → Box and cache as `latestLayout`.
*
* Returns `null` when `element` is not mounted.
*/
measure(): Box | null;
/**
* Snapshot the current layout box for use by the next
* `didUpdate()`. Caller's contract: invoke this BEFORE the
* layout-mutating state change so the snapshot reflects the
* pre-mutation position.
*
* Idempotent within a frame — once a snapshot exists, subsequent
* `willUpdate()` calls are no-ops until `didUpdate()` consumes it.
* This means a parent that broadcasts `willUpdate` to its
* children before its own snapshot is fine: children snapshot
* themselves first via their own willUpdate, parent's broadcast
* is a no-op.
*/
willUpdate(): void;
/**
* Re-measure post-mutation, compute the delta against the
* snapshot, fire `didUpdate` listeners. No-op when there's no
* snapshot (matches upstream — `willUpdate` MUST precede
* `didUpdate` for the cycle to fire).
*
* Always clears the snapshot at the end so the next gesture's
* `willUpdate`/`didUpdate` cycle starts fresh.
*/
didUpdate(): void;
/**
* Observer-driven layout-change commit. Unlike the explicit
* `willUpdate()` → mutate → `didUpdate()` cycle (used when a
* consumer controls the exact mutation moment, e.g. Reorder.Group
* before a `values` reassign), this is for the reactive path where
* a layout change has ALREADY happened and we only learn about it
* after the fact (the existing `observeLayoutChanges` FLIP loop in
* `_MotionContainer`).
*
* Uses the cached `latestLayout` (the pre-change position from
* mount or the previous commit) as the snapshot, re-measures the
* post-change position, and fires `didUpdate` with the delta.
*
* First call after mount just seeds `latestLayout` (no prior
* position to diff against) and fires nothing.
*
* Returns the freshly-measured `Box` (or `null` when unmounted) so
* the caller can reuse it — the FLIP loop in `_MotionContainer` uses
* this as its `next` rect instead of measuring a second time per
* frame.
*
* The `didUpdate` fan-out is gated on a non-zero delta. The FLIP
* animation this commit runs alongside writes its own inverse
* `transform` to the element every frame, and those writes re-trigger
* the same `observeLayoutChanges` signal that drives this method.
* Those re-fires carry no real layout change (the ancestor-stripped
* measure is identical to the previous one), so without the
* `isDeltaZero` gate every animation frame would fan out a `delta: 0`
* event and clobber the genuine delta from the originating change.
*/
commitLayoutChange(): Box | null;
/**
* Subscribe to a projection event. Returns an unsubscribe
* function. Safe to call after `unmount()` (becomes a no-op).
*/
addEventListener<E extends ProjectionEventName>(name: E, cb: Listener<E>): () => void;
private notify;
}
export {};