feat(vue): expand @robonen/vue composable collection

Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
2026-06-08 15:51:16 +07:00
parent 9a912f7a77
commit 59e995d0b5
369 changed files with 36554 additions and 188 deletions
@@ -0,0 +1,360 @@
import { computed, ref, toValue, watch } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import { clamp, isFunction, isNumber, lerp, noop } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useRafFn } from '@/composables/animation/useRafFn';
/**
* Cubic bezier control points `[x1, y1, x2, y2]` (the implied endpoints are
* `(0, 0)` and `(1, 1)`), matching the CSS `cubic-bezier()` argument order.
*/
export type CubicBezierPoints = [number, number, number, number];
/**
* An easing function mapping linear progress in `[0, 1]` to eased progress.
*/
export type EasingFunction = (n: number) => number;
/**
* Interpolates between two values of `T` given an eased progress `alpha`.
*/
export type TransitionInterpolation<T> = (from: T, to: T, alpha: number) => T;
/**
* The transition easing: either a cubic bezier tuple or a custom easing function.
*/
export type TransitionEasing = CubicBezierPoints | EasingFunction;
/**
* Values that can be transitioned: a single number or a fixed-length number array.
*/
export type TransitionValue = number | number[];
/**
* Common cubic bezier easing presets (same curves as CSS / VueUse).
*/
export const TransitionPresets = {
linear: [0, 0, 1, 1],
easeInSine: [0.12, 0, 0.39, 0],
easeOutSine: [0.61, 1, 0.88, 1],
easeInOutSine: [0.37, 0, 0.63, 1],
easeInQuad: [0.11, 0, 0.5, 0],
easeOutQuad: [0.5, 1, 0.89, 1],
easeInOutQuad: [0.45, 0, 0.55, 1],
easeInCubic: [0.32, 0, 0.67, 0],
easeOutCubic: [0.33, 1, 0.68, 1],
easeInOutCubic: [0.65, 0, 0.35, 1],
easeInQuart: [0.5, 0, 0.75, 0],
easeOutQuart: [0.25, 1, 0.5, 1],
easeInOutQuart: [0.76, 0, 0.24, 1],
easeInQuint: [0.64, 0, 0.78, 0],
easeOutQuint: [0.22, 1, 0.36, 1],
easeInOutQuint: [0.83, 0, 0.17, 1],
easeInExpo: [0.7, 0, 0.84, 0],
easeOutExpo: [0.16, 1, 0.3, 1],
easeInOutExpo: [0.87, 0, 0.13, 1],
easeInCirc: [0.55, 0, 1, 0.45],
easeOutCirc: [0, 0.55, 0.45, 1],
easeInOutCirc: [0.85, 0, 0.15, 1],
easeInBack: [0.36, 0, 0.66, -0.56],
easeOutBack: [0.34, 1.56, 0.64, 1],
easeInOutBack: [0.68, -0.6, 0.32, 1.6],
} satisfies Record<string, CubicBezierPoints>;
export interface UseTransitionOptions {
/**
* Transition duration in milliseconds. Accepts a reactive value (resolved
* at the start of each transition).
*
* @default 1000
*/
duration?: MaybeRefOrGetter<number>;
/**
* Easing applied to the progress: a cubic bezier tuple (e.g. one of
* {@link TransitionPresets}) or a custom easing function.
*
* @default identity (linear)
*/
transition?: MaybeRefOrGetter<TransitionEasing>;
/**
* Delay in milliseconds before a transition begins after the source changes.
*
* @default 0
*/
delay?: MaybeRefOrGetter<number>;
/**
* When `true`, transitions are skipped and the output tracks the source
* value directly (no animation). Reactive.
*
* @default false
*/
disabled?: MaybeRefOrGetter<boolean>;
/**
* Called when a transition starts.
*/
onStarted?: () => void;
/**
* Called when a transition finishes (not called when aborted by a new change).
*/
onFinished?: () => void;
}
export type UseTransitionReturn<T> = Readonly<Ref<T>>;
const identity: EasingFunction = n => n;
interface BezierCoefficients {
a: (a1: number, a2: number) => number;
b: (a1: number, a2: number) => number;
c: (a1: number) => number;
}
function createEasingFunction(points: CubicBezierPoints): EasingFunction {
const [p0, p1, p2, p3] = points;
const coeffs: BezierCoefficients = {
a: (a1, a2) => 1 - 3 * a2 + 3 * a1,
b: (a1, a2) => 3 * a2 - 6 * a1,
c: a1 => 3 * a1,
};
const calcBezier = (t: number, a1: number, a2: number): number =>
((coeffs.a(a1, a2) * t + coeffs.b(a1, a2)) * t + coeffs.c(a1)) * t;
const getSlope = (t: number, a1: number, a2: number): number =>
3 * coeffs.a(a1, a2) * t * t + 2 * coeffs.b(a1, a2) * t + coeffs.c(a1);
const getTForX = (x: number): number => {
let guess = x;
for (let i = 0; i < 4; ++i) {
const slope = getSlope(guess, p0, p2);
if (slope === 0)
return guess;
const currentX = calcBezier(guess, p0, p2) - x;
guess -= currentX / slope;
}
return guess;
};
return n => (p0 === p1 && p2 === p3) ? n : calcBezier(getTForX(n), p1, p3);
}
function resolveEasing(transition: TransitionEasing | undefined): EasingFunction {
if (!transition)
return identity;
if (isFunction(transition))
return transition;
return createEasingFunction(transition);
}
// Interpolate a single number or a (fixed-length) numeric array.
function interpolate(from: TransitionValue, to: TransitionValue, alpha: number): TransitionValue {
if (isNumber(from) && isNumber(to))
return lerp(from, to, alpha);
const source = from as number[];
const target = to as number[];
return source.map((value, index) => lerp(value, target[index] ?? value, alpha));
}
function snapshot<T extends TransitionValue>(value: T): T {
return (isNumber(value) ? value : (value as number[]).slice()) as T;
}
function valuesEqual(a: TransitionValue, b: TransitionValue): boolean {
if (isNumber(a) && isNumber(b))
return a === b;
if (isNumber(a) || isNumber(b))
return false;
if (a.length !== b.length)
return false;
for (let i = 0; i < a.length; ++i) {
if (a[i] !== b[i])
return false;
}
return true;
}
/**
* @name useTransition
* @category Animation
* @description Reactively transition between numeric values (or numeric arrays)
* over a duration with configurable easing. Wraps a single, paused
* `requestAnimationFrame` loop that only runs while a transition is in flight,
* so it is cheaper than re-creating an RAF loop per change. SSR-safe: without a
* `window` the output tracks the source synchronously.
*
* @param {MaybeRefOrGetter<T>} source The reactive source value (number or number[])
* @param {UseTransitionOptions & ConfigurableWindow} [options={}] Transition options
* @returns {UseTransitionReturn<T>} A readonly ref of the transitioned value
*
* @example
* const progress = ref(0);
* const output = useTransition(progress, {
* duration: 500,
* transition: TransitionPresets.easeOutCubic,
* });
*
* @example
* // Transition a tuple (e.g. an RGB color)
* const color = ref([0, 0, 0]);
* const animated = useTransition(color, { duration: 1000 });
*
* @since 0.0.15
*/
export function useTransition<T extends TransitionValue>(
source: MaybeRefOrGetter<T>,
options: UseTransitionOptions & ConfigurableWindow = {},
): UseTransitionReturn<T> {
const {
duration = 1000,
transition = identity,
delay = 0,
disabled = false,
onStarted = noop,
onFinished = noop,
} = options;
const window = 'window' in options ? options.window : defaultWindow;
// The animated output. Seeded with a snapshot of the current source.
const outputRef = ref(snapshot(toValue(source))) as Ref<T>;
// Active-transition state. `endpoints` are detached snapshots so that later
// source mutations cannot bleed into an in-flight transition.
let fromValue: T = outputRef.value;
let toValue_: T = outputRef.value;
let startedAt = 0;
let durationMs = 0;
let easing: EasingFunction = identity;
let delayTimer: ReturnType<typeof setTimeout> | null = null;
let finishPending = false;
const { pause, resume, isActive } = useRafFn(tick, { immediate: false, window });
function clearDelay() {
if (delayTimer !== null && window) {
window.clearTimeout(delayTimer);
delayTimer = null;
}
}
function settle(value: T) {
outputRef.value = snapshot(value);
if (isActive.value)
pause();
if (finishPending) {
finishPending = false;
onFinished();
}
}
function tick() {
const now = Date.now();
const alpha = durationMs <= 0 ? 1 : clamp((now - startedAt) / durationMs, 0, 1);
outputRef.value = interpolate(fromValue, toValue_, easing(alpha)) as T;
if (alpha >= 1)
settle(toValue_);
}
function begin(target: T) {
fromValue = snapshot(outputRef.value);
toValue_ = snapshot(target);
durationMs = Math.max(0, toValue(duration));
easing = resolveEasing(toValue(transition));
startedAt = Date.now();
finishPending = true;
onStarted();
if (durationMs <= 0 || !window) {
settle(toValue_);
return;
}
if (!isActive.value)
resume();
}
function start(target: T) {
clearDelay();
const delayMs = Math.max(0, toValue(delay));
if (delayMs > 0 && window) {
delayTimer = window.setTimeout(() => {
delayTimer = null;
begin(target);
}, delayMs);
return;
}
begin(target);
}
watch(
() => toValue(source),
(value) => {
// When disabled, mirror the source instantly and abort any animation.
if (toValue(disabled)) {
clearDelay();
if (isActive.value)
pause();
finishPending = false;
outputRef.value = snapshot(value);
return;
}
// Skip no-op changes so we don't restart an identical transition.
if (valuesEqual(value, outputRef.value) && !isActive.value && delayTimer === null)
return;
start(value);
},
{ deep: true },
);
// Reacting to `disabled` flipping to true mid-transition: snap to source.
watch(
() => toValue(disabled),
(off) => {
if (off) {
clearDelay();
if (isActive.value)
pause();
finishPending = false;
outputRef.value = snapshot(toValue(source));
}
},
);
return computed(() => outputRef.value);
}