feat: add ApprovalCenterView with animated rolling values

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-05-01 00:41:52 +08:00
parent 7d6dbc4ac0
commit 99fde07826
3 changed files with 796 additions and 0 deletions

View File

@@ -0,0 +1,141 @@
<template>
<span ref="root" class="rv" :aria-label="value">
<template v-for="(char, i) in chars" :key="`${i}-${charKinds[i]}`">
<span
v-if="isDigit(char)"
class="rv-col"
:style="{ '--y': `-${targetOffset(char)}em`, '--wait': `${i * 28 + 120}ms` }"
aria-hidden="true"
>
<span class="rv-strip" :class="{ go: on }">
<span v-for="d in len" :key="d" class="rv-d">{{ (d - 1) % 10 }}</span>
</span>
</span>
<span v-else class="rv-static" :class="{ currency: char === '¥', unit: /[a-zA-Z%]/.test(char) }" aria-hidden="true">{{ char }}</span>
</template>
</span>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = defineProps({
value: { type: String, required: true },
preRoll: { type: Number, default: 2 }
})
const PRE = props.preRoll * 10
const chars = computed(() => props.value.split(''))
const charKinds = computed(() => chars.value.map((char) => isDigit(char) ? 'digit' : 'static'))
const len = (props.preRoll + 1) * 10
function isDigit(c) { return /\d/.test(c) }
function targetOffset(c) { return PRE + Number(c) }
const on = ref(false)
const root = ref(null)
let observer = null
function roll() {
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
on.value = true
return
}
on.value = false
nextTick(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
on.value = true
})
})
})
}
onMounted(() => {
if (!('IntersectionObserver' in window)) {
roll()
return
}
observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
roll()
observer?.disconnect()
}
}, { threshold: 0.2 })
if (root.value) observer.observe(root.value)
})
onBeforeUnmount(() => observer?.disconnect())
watch(() => props.value, roll)
</script>
<style scoped>
.rv {
display: inline-flex;
align-items: center;
line-height: 1;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.rv-col {
display: inline-block;
position: relative;
width: .64em;
height: 1em;
line-height: 1;
overflow: hidden;
vertical-align: 0;
}
.rv-strip {
display: block;
transform: translate3d(0, 0, 0);
will-change: transform;
}
.rv-strip.go {
animation: digitRoll 2.35s cubic-bezier(.45, 0, .18, 1) var(--wait, 0ms) both;
}
.rv-d {
height: 1em;
display: block;
text-align: center;
line-height: 1;
}
.rv-static {
display: inline-flex;
align-items: center;
height: 1em;
line-height: 1;
}
.rv-static.currency {
margin-right: .02em;
}
.rv-static.unit {
margin-left: .04em;
}
@keyframes digitRoll {
from {
transform: translate3d(0, 0, 0);
}
to {
transform: translate3d(0, var(--y, 0), 0);
}
}
@media (prefers-reduced-motion: reduce) {
.rv-strip {
animation: none;
transform: translateY(var(--y, 0));
}
}
</style>