UNPKG

@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
/** * 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 {};