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:
141
src/components/shared/RollingValue.vue
Normal file
141
src/components/shared/RollingValue.vue
Normal 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>
|
||||
Reference in New Issue
Block a user