Files
portal/app/resources/js/composables/useCountUp.ts
T

68 lines
2.0 KiB
TypeScript

/**
* useCountUp — RAF-tween анимация числа (Quiet Luxury KPI cards).
*
* - easeOutQuint easing
* - respects prefers-reduced-motion (instant value)
* - re-animates when target ref changes
*
* Spec: docs/superpowers/plans/2026-05-12-portal-redesign-quiet-luxury-plan.md (Task 6).
*/
import { ref, watch, type Ref } from 'vue';
export interface CountUpOptions {
duration?: number; // ms
precision?: number; // знаков после запятой
}
export interface CountUpHandle {
display: Ref<number>;
start: () => void;
}
const easeOutQuint = (t: number): number => 1 - Math.pow(1 - t, 5);
function prefersReducedMotion(): boolean {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
export function useCountUp(target: Ref<number>, opts: CountUpOptions = {}): CountUpHandle {
const duration = opts.duration ?? 600;
const precision = opts.precision ?? 0;
const display = ref(0);
let raf: number | null = null;
let startTime = 0;
let fromValue = 0;
function tick(now: number): void {
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
const eased = easeOutQuint(t);
const value = fromValue + (target.value - fromValue) * eased;
display.value = precision === 0 ? Math.round(value) : parseFloat(value.toFixed(precision));
if (t < 1) {
raf = requestAnimationFrame(tick);
} else {
display.value = target.value;
raf = null;
}
}
function start(): void {
if (prefersReducedMotion()) {
display.value = target.value;
return;
}
if (raf !== null) cancelAnimationFrame(raf);
fromValue = display.value;
startTime = performance.now();
raf = requestAnimationFrame(tick);
}
watch(target, () => {
if (display.value !== target.value) start();
});
return { display, start };
}