@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
120 lines (119 loc) • 4.5 kB
JavaScript
import { animate } from 'motion';
/**
* Split a focus definition into keyframes and an optional nested transition.
*
* @param def While-focus record that may include a nested `transition`.
* @returns Object with `keyframes` (no `transition`) and optional `transition`.
*/
export const splitFocusDefinition = (def) => {
const { transition, ...rest } = (def ?? {});
return { keyframes: rest, transition };
};
/**
* Compute the baseline values to restore to on focus end.
*
* Preference order per key: `animate` → `initial` → neutral transform defaults.
*
* @param el Target element.
* @param opts Source records for baseline computation.
* @returns Minimal baseline record to restore on focus end.
*/
export const computeFocusBaseline = (el, opts) => {
const baseline = {};
const initialRecord = (opts.initial ?? {});
const animateRecord = (opts.animate ?? {});
const whileFocusRecordRaw = (opts.whileFocus ?? {});
const whileFocusRecord = { ...whileFocusRecordRaw };
delete whileFocusRecord.transition;
const neutralTransformDefaults = {
x: 0,
y: 0,
translateX: 0,
translateY: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
rotate: 0,
rotateX: 0,
rotateY: 0,
rotateZ: 0,
skewX: 0,
skewY: 0,
opacity: 1
};
const cs = getComputedStyle(el);
for (const key of Object.keys(whileFocusRecord)) {
if (Object.prototype.hasOwnProperty.call(animateRecord, key)) {
baseline[key] = animateRecord[key];
}
else if (Object.prototype.hasOwnProperty.call(initialRecord, key)) {
baseline[key] = initialRecord[key];
}
else if (key in neutralTransformDefaults) {
baseline[key] = neutralTransformDefaults[key];
}
else {
// Convert camelCase to kebab-case for CSS property access
const cssProperty = key.replace(/([A-Z])/g, '-$1').toLowerCase();
const value = cs.getPropertyValue(cssProperty);
// Always assign a baseline entry to ensure a removal keyframe exists.
if (value) {
baseline[key] = value;
}
else {
// Fallback to inline style for that property, else explicit empty string
const inlineValue = el.style.getPropertyValue(cssProperty);
baseline[key] = inlineValue || '';
}
}
}
return baseline;
};
/**
* Attach whileFocus interactions to an element.
*
* On focus, animates to `whileFocus` (using nested `transition` if provided).
* On blur, restores changed keys to the baseline using the merged transition.
*
* @param el Target element.
* @param whileFocus While-focus definition.
* @param mergedTransition Root/component merged transition.
* @param callbacks Optional lifecycle callbacks for focus start/end.
* @param baselineSources Optional sources used to compute baseline.
* @returns Cleanup function to remove listeners.
*/
export const attachWhileFocus = (el, whileFocus, mergedTransition, callbacks, baselineSources) => {
if (!whileFocus)
return () => { };
let focusBaseline = null;
const handleFocus = () => {
focusBaseline = computeFocusBaseline(el, {
initial: baselineSources?.initial,
animate: baselineSources?.animate,
whileFocus
});
callbacks?.onStart?.();
const { keyframes, transition } = splitFocusDefinition(whileFocus);
animate(el, keyframes, (transition ?? mergedTransition));
};
const handleBlur = () => {
if (focusBaseline && Object.keys(focusBaseline).length > 0) {
const baselineForAnimation = { ...focusBaseline };
// For baseline entries that are empty string, proactively clear inline CSS
for (const [key, v] of Object.entries(baselineForAnimation)) {
if (v === '') {
const cssProperty = key.replace(/([A-Z])/g, '-$1').toLowerCase();
el.style.removeProperty(cssProperty);
}
}
animate(el, baselineForAnimation, mergedTransition);
}
callbacks?.onEnd?.();
};
el.addEventListener('focus', handleFocus);
el.addEventListener('blur', handleBlur);
return () => {
el.removeEventListener('focus', handleFocus);
el.removeEventListener('blur', handleBlur);
};
};