summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/components/animated-number.tsx
blob: b5fceba256301c522f6df501a214b8a9358bac80 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js"

const TRACK = Array.from({ length: 30 }, (_, index) => index % 10)
const DURATION = 600

function normalize(value: number) {
  return ((value % 10) + 10) % 10
}

function spin(from: number, to: number, direction: 1 | -1) {
  if (from === to) return 0
  if (direction > 0) return (to - from + 10) % 10
  return -((from - to + 10) % 10)
}

function Digit(props: { value: number; direction: 1 | -1 }) {
  const [step, setStep] = createSignal(props.value + 10)
  const [animating, setAnimating] = createSignal(false)
  let last = props.value

  createEffect(
    on(
      () => props.value,
      (next) => {
        const delta = spin(last, next, props.direction)
        last = next
        if (!delta) {
          setAnimating(false)
          setStep(next + 10)
          return
        }

        setAnimating(true)
        setStep((value) => value + delta)
      },
      { defer: true },
    ),
  )

  return (
    <span data-slot="animated-number-digit">
      <span
        data-slot="animated-number-strip"
        data-animating={animating() ? "true" : "false"}
        onTransitionEnd={() => {
          setAnimating(false)
          setStep((value) => normalize(value) + 10)
        }}
        style={{
          "--animated-number-offset": `${step()}`,
          "--animated-number-duration": `var(--tool-motion-odometer-ms, ${DURATION}ms)`,
        }}
      >
        <For each={TRACK}>{(value) => <span data-slot="animated-number-cell">{value}</span>}</For>
      </span>
    </span>
  )
}

export function AnimatedNumber(props: { value: number; class?: string }) {
  const target = createMemo(() => {
    if (!Number.isFinite(props.value)) return 0
    return Math.max(0, Math.round(props.value))
  })

  const [value, setValue] = createSignal(target())
  const [direction, setDirection] = createSignal<1 | -1>(1)

  createEffect(
    on(
      target,
      (next) => {
        const current = value()
        if (next === current) return

        setDirection(next > current ? 1 : -1)
        setValue(next)
      },
      { defer: true },
    ),
  )

  const label = createMemo(() => value().toString())
  const digits = createMemo(() =>
    Array.from(label(), (char) => {
      const code = char.charCodeAt(0) - 48
      if (code < 0 || code > 9) return 0
      return code
    }).reverse(),
  )
  const width = createMemo(() => `${digits().length}ch`)

  return (
    <span data-component="animated-number" class={props.class} aria-label={label()}>
      <span data-slot="animated-number-value" style={{ "--animated-number-width": width() }}>
        <Index each={digits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
      </span>
    </span>
  )
}