learning_ai_common_plat/packages/motion/src/NumberFlow.tsx
saravanakumardb1 d082480849 feat(packages): Wave 4 motion + Wave 5b data-viz + Wave 7 notifications-ui
Three new product-agnostic packages unlock visible elegance lifts
across every product:

═══════════════════════════════════════════════════════════════════════
@bytelyst/motion@0.1.0 — Wave 4 elegance primitives  (2.21 KB / 8 KB)
═══════════════════════════════════════════════════════════════════════

  <Reveal>          — IntersectionObserver-based fade/slide entry,
                      6 directions, configurable spring + delay
  <StaggerList>     — sequenced reveal of children with per-item delay
  <NumberFlow>      — RAF-tweened number counter, cubic-out easing,
                      Intl-formatted, prefers-reduced-motion aware
  <TiltCard>        — 3D perspective tilt + cursor-tracking glare
                      overlay (single-element ref, no React rerenders)
  <ScrollProgress>  — fixed scroll-position bar (window or any element)

Plus exported `SPRINGS` (4 cubic-bezier presets) + `prefersReducedMotion`
helper. Every primitive accepts `disableMotion` for snapshot tests.

═══════════════════════════════════════════════════════════════════════
@bytelyst/data-viz@0.1.0 — Wave 5b viz primitives  (2.63 KB / 10 KB)
═══════════════════════════════════════════════════════════════════════

  <Sparkline>     — line trend with gradient fill + last-point marker
  <BarSparkline>  — discrete-bar mini-chart with max-bar highlight
  <KpiCard>       — label + headline + delta arrow + sparkline; supports
                    'goodWhen=lower' for latency/cost metrics
  <ProgressRing>  — circular progress with center content slot,
                    animated stroke-dashoffset
  <Heatmap>       — GitHub-style calendar grid with color-mix() intensity

All pure SVG / CSS — zero runtime dependencies.

═══════════════════════════════════════════════════════════════════════
@bytelyst/notifications-ui@0.1.0 — Wave 7 essentials (3.31 KB / 10 KB)
═══════════════════════════════════════════════════════════════════════

  <NotificationCenter>  — bell trigger + badge + dropdown panel with
                          All / Unread / Mentions tabs, outside-click
                          + Escape close, mark-all-read action
  <InboxItem>           — single row with unread dot, kind glyph,
                          relative timestamp, optional action buttons
  <BannerStack>         — top-of-page strip with maxVisible + +N more,
                          accent-bordered tone variants, dismissible
  <Announcement>        — inline 'What's new' pill (3 tone variants)

5 notification kinds (info/success/warning/danger/mention) + 5 banner
kinds (... + announcement gradient).

═══════════════════════════════════════════════════════════════════════
Quality gates
═══════════════════════════════════════════════════════════════════════
  All three packages: tsc --noEmit clean, build clean.
  Tests:    motion 16/16  ·  data-viz 14/14  ·  notifications-ui 17/17
  Bundles:  motion 2.21 KB  ·  data-viz 2.63 KB  ·  noti-ui 3.31 KB
  Budgets:  added to .size-limit.cjs (8/10/10 KB respectively)

Refs:
  learning_ai_uxui_web/docs/ROADMAP_2026.md
    §Wave 4 (Motion), §Wave 5b (Charts), §Wave 7 (Productisation)
  Decisions doc §13 (mobile-native = tokens-only) leaves room for these
    web-first packages to be the canonical surface
2026-05-27 13:08:30 -07:00

106 lines
2.7 KiB
TypeScript

import {
useEffect,
useRef,
useState,
type CSSProperties,
} from 'react';
import { SPRINGS, prefersReducedMotion } from './utils.js';
export interface NumberFlowProps {
/** Target value to animate to. */
value: number;
/** Tween duration in ms. Default 700. */
duration?: number;
/** Decimal places. Default 0. */
decimals?: number;
/** Optional formatter (overrides `decimals` + locale). */
format?: (n: number) => string;
/** Locale for default formatter. Default 'en-US'. */
locale?: string;
/** Prefix rendered before the number (e.g. '$'). */
prefix?: string;
/** Suffix rendered after the number (e.g. '%'). */
suffix?: string;
/** Bypass animation. */
disableMotion?: boolean;
className?: string;
style?: CSSProperties;
}
/**
* `<NumberFlow>` — smoothly tweens a number from its previous value to
* the new one. Counts up, counts down, and respects
* `prefers-reduced-motion` (snaps to final value).
*
* Pure RAF — no dependencies. Cancels in-flight tweens when `value`
* changes mid-animation so the latest target always wins.
*/
export function NumberFlow({
value,
duration = 700,
decimals = 0,
format,
locale = 'en-US',
prefix,
suffix,
disableMotion,
className,
style,
}: NumberFlowProps) {
const [display, setDisplay] = useState(value);
const fromRef = useRef(value);
const rafRef = useRef<number | null>(null);
const reduced = disableMotion ?? prefersReducedMotion();
useEffect(() => {
if (reduced) {
fromRef.current = value;
setDisplay(value);
return;
}
const from = fromRef.current;
const start = performance.now();
const tween = (now: number) => {
const t = Math.min(1, (now - start) / duration);
// Apply a snappy ease-out so the number doesn't feel linear.
const eased = 1 - Math.pow(1 - t, 3);
const current = from + (value - from) * eased;
setDisplay(current);
if (t < 1) {
rafRef.current = requestAnimationFrame(tween);
} else {
fromRef.current = value;
rafRef.current = null;
}
};
rafRef.current = requestAnimationFrame(tween);
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
};
}, [value, duration, reduced]);
const formatted = format
? format(display)
: new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(display);
return (
<span
data-testid="bl-number-flow"
data-target={value}
className={className}
style={{
fontVariantNumeric: 'tabular-nums',
transition: `color 200ms ${SPRINGS.snappy}`,
...style,
}}
>
{prefix}
{formatted}
{suffix}
</span>
);
}