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

272 lines (271 loc) 10.3 kB
import { isHoverCapable, splitHoverDefinition } from './hover'; import { pwLog } from './log'; import { animate } from 'motion'; import { press } from 'motion-dom'; /** * Build a reset record for whileTap on pointerup. * * For each key present in `whileTap`, this returns the value from `animate` * if provided, otherwise from `initial`. Keys not present in `whileTap` are * omitted to avoid unintended style changes. * * @param initial Initial keyframe record. * @param animateDef Animate keyframe record. * @param whileTap While-tap keyframe record. * @return Minimal record to restore post-tap values. */ export const buildTapResetRecord = (initial, animateDef, whileTap) => { // Reset any key that whileTap modified. Prefer animate > initial; otherwise provide safe defaults const overlappingKeys = Object.keys(whileTap ?? {}); const resetRecord = {}; const has = (rec, key) => !!rec && Object.prototype.hasOwnProperty.call(rec, key); for (const k of overlappingKeys) { if (k === 'scale') { if (has(animateDef, 'scale')) resetRecord.scale = animateDef.scale; else if (has(initial, 'scale')) resetRecord.scale = initial.scale; else if (has(animateDef, 'scaleX') || has(animateDef, 'scaleY')) { const sx = animateDef.scaleX ?? 1; const sy = animateDef.scaleY ?? 1; resetRecord.scale = (sx + sy) / 2; } else if (has(initial, 'scaleX') || has(initial, 'scaleY')) { const sx = initial.scaleX ?? 1; const sy = initial.scaleY ?? 1; resetRecord.scale = (sx + sy) / 2; } else { resetRecord.scale = 1; } continue; } if (k === 'scaleX') { if (has(animateDef, 'scaleX')) resetRecord.scaleX = animateDef.scaleX; else if (has(animateDef, 'scale')) resetRecord.scaleX = animateDef.scale; else if (has(initial, 'scaleX')) resetRecord.scaleX = initial.scaleX; else if (has(initial, 'scale')) resetRecord.scaleX = initial.scale; else resetRecord.scaleX = 1; continue; } if (k === 'scaleY') { if (has(animateDef, 'scaleY')) resetRecord.scaleY = animateDef.scaleY; else if (has(animateDef, 'scale')) resetRecord.scaleY = animateDef.scale; else if (has(initial, 'scaleY')) resetRecord.scaleY = initial.scaleY; else if (has(initial, 'scale')) resetRecord.scaleY = initial.scale; else resetRecord.scaleY = 1; continue; } if (has(animateDef, k)) { resetRecord[k] = animateDef[k]; } else if (has(initial, k)) { resetRecord[k] = initial[k]; } else { resetRecord[k] = undefined; } } return resetRecord; }; /** * Attach whileTap interactions to an element. * * Uses motion-dom's `press()` for pointer and Enter-key handling (with * primary-pointer filtering, drag interop, and global release listeners). * Space-key support is added manually since `press()` only handles Enter. * * @param el Element to attach listeners to. * @param whileTap While-tap keyframe record. * @param initial Initial keyframe record. * @param animateDef Animate keyframe record. * @param callbacks Optional lifecycle callbacks. * @return Cleanup function to remove listeners. */ export const attachWhileTap = (el, whileTap, initial, animateDef, callbacks) => { if (!whileTap) return () => { }; pwLog('[tap] attached', { whileTap, initial, animateDef, hasHoverDef: !!callbacks?.hoverDef }); // Tween transitions prevent spring velocity accumulation during rapid // press/release cycles. Cubic-bezier with slight overshoot mimics the // reference spring feel (~275ms settle, ~7% overshoot). const pressTransition = { duration: 0.25, ease: [0.22, 1.1, 0.36, 1] }; const releaseTransition = { duration: 0.3, ease: [0.22, 1.1, 0.36, 1] }; // Single control tracking whatever gesture animation is in-flight // (tap, reset, or hover reapply). Every new gesture cancels the previous. let gestureCtl = null; const cancelGesture = () => { if (gestureCtl) { pwLog('[tap] cancel-gesture', { currentTime: gestureCtl.currentTime, transform: getComputedStyle(el).transform }); } try { // Use stop() instead of cancel(). cancel() reverts to the // pre-animation state (causing a visual snap), while stop() // holds the element at its current interpolated position. gestureCtl?.stop(); } catch { // ignore } gestureCtl = null; }; const animateTap = () => { pwLog('[tap] animate-tap', { w: el.getBoundingClientRect().width, h: el.getBoundingClientRect().height, transform: getComputedStyle(el).transform, whileTap, gestureActive: gestureCtl !== null }); cancelGesture(); callbacks?.onTapStart?.(); gestureCtl = animate(el, whileTap, pressTransition); Promise.resolve(gestureCtl?.finished) .then(() => pwLog('[tap] tap-applied', { w: el.getBoundingClientRect().width, h: el.getBoundingClientRect().height, transform: getComputedStyle(el).transform })) .catch(() => { }); }; const reapplyHoverIfActive = () => { if (!callbacks?.hoverDef) { pwLog('[tap] hover-reapply-skip', { reason: 'no hoverDef' }); return false; } if (!isHoverCapable()) { pwLog('[tap] hover-reapply-skip', { reason: 'not hover-capable' }); return false; } try { if (!el.matches(':hover')) { pwLog('[tap] hover-reapply-skip', { reason: 'not :hover' }); return false; } } catch { pwLog('[tap] hover-reapply-skip', { reason: 'matches threw' }); return false; } const { keyframes } = splitHoverDefinition(callbacks.hoverDef); pwLog('[tap] hover-reapply', { keyframes, transform: getComputedStyle(el).transform, w: el.getBoundingClientRect().width }); gestureCtl = animate(el, keyframes, releaseTransition); Promise.resolve(gestureCtl?.finished) .then(() => pwLog('[tap] hover-reapply-done', { w: el.getBoundingClientRect().width, transform: getComputedStyle(el).transform })) .catch(() => { }); return true; }; const animateReset = (success) => { pwLog('[tap] animate-reset', { success, w: el.getBoundingClientRect().width, h: el.getBoundingClientRect().height, transform: getComputedStyle(el).transform, gestureActive: gestureCtl !== null }); if (success) callbacks?.onTap?.(); else callbacks?.onTapCancel?.(); cancelGesture(); // If still hovering after a successful tap, animate to hover state if (success && reapplyHoverIfActive()) return; const resetRecord = buildTapResetRecord(initial ?? {}, animateDef ?? {}, whileTap ?? {}); pwLog('[tap] reset-record', resetRecord); if (Object.keys(resetRecord).length > 0) { gestureCtl = animate(el, resetRecord, releaseTransition); Promise.resolve(gestureCtl?.finished) .then(() => pwLog('[tap] reset-done', { w: el.getBoundingClientRect().width, h: el.getBoundingClientRect().height, transform: getComputedStyle(el).transform })) .catch(() => { }); } }; // Use press() for pointer + Enter key handling const cancelPress = press(el, () => { pwLog('[tap] press-start', { w: el.getBoundingClientRect().width, transform: getComputedStyle(el).transform }); animateTap(); return (_endEvent, { success }) => { pwLog('[tap] press-end', { success, w: el.getBoundingClientRect().width, transform: getComputedStyle(el).transform }); animateReset(success); }; }); // Add Space key support (press() only handles Enter) let spaceActive = false; const onKeyDown = (e) => { if (e.key !== ' ' && e.key !== 'Space') return; e.preventDefault(); if (spaceActive) return; spaceActive = true; pwLog('[tap] space-down', { w: el.getBoundingClientRect().width, transform: getComputedStyle(el).transform }); animateTap(); }; const onKeyUp = (e) => { if (e.key !== ' ' && e.key !== 'Space') return; e.preventDefault(); if (!spaceActive) return; spaceActive = false; pwLog('[tap] space-up', { w: el.getBoundingClientRect().width, transform: getComputedStyle(el).transform }); animateReset(true); }; const onBlur = () => { if (!spaceActive) return; spaceActive = false; pwLog('[tap] blur', { w: el.getBoundingClientRect().width, transform: getComputedStyle(el).transform }); animateReset(false); }; el.addEventListener('keydown', onKeyDown); el.addEventListener('keyup', onKeyUp); el.addEventListener('blur', onBlur); return () => { pwLog('[tap] cleanup'); cancelPress(); el.removeEventListener('keydown', onKeyDown); el.removeEventListener('keyup', onKeyUp); el.removeEventListener('blur', onBlur); }; };