Add text ticker

This commit is contained in:
pythongosssss
2026-02-13 09:58:23 -08:00
parent 663efeff35
commit 7d73f01588
2 changed files with 62 additions and 43 deletions

View File

@@ -4,24 +4,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import TextTicker from './TextTicker.vue'
function mockOverflow(
el: HTMLElement,
{ scrollWidth, clientWidth }: { scrollWidth: number; clientWidth: number }
) {
function mockScrollWidth(el: HTMLElement, scrollWidth: number) {
Object.defineProperty(el, 'scrollWidth', {
value: scrollWidth,
configurable: true
})
Object.defineProperty(el, 'clientWidth', {
value: clientWidth,
configurable: true
})
}
describe('TextTicker', () => {
let rafCallbacks: ((time: number) => void)[]
beforeEach(() => {
vi.useFakeTimers()
rafCallbacks = []
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
rafCallbacks.push(cb)
@@ -31,6 +25,7 @@ describe('TextTicker', () => {
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
@@ -41,27 +36,50 @@ describe('TextTicker', () => {
expect(wrapper.text()).toBe('Hello World')
})
it('scrolls on hover when content overflows', async () => {
it('scrolls on hover after delay', async () => {
const wrapper = mount(TextTicker, {
slots: { default: 'Very long text that overflows' },
props: { speed: 100 }
})
const el = wrapper.element as HTMLElement
mockOverflow(el, { scrollWidth: 300, clientWidth: 100 })
mockScrollWidth(el, 300)
// Allow useElementHover to set up listeners after mount
await nextTick()
await wrapper.trigger('mouseenter')
await nextTick()
expect(rafCallbacks.length).toBe(0)
vi.advanceTimersByTime(350)
await nextTick()
expect(rafCallbacks.length).toBeGreaterThan(0)
// Simulate animation frame - should set scrollLeft
rafCallbacks[0](performance.now() + 500)
expect(el.scrollLeft).toBeGreaterThan(0)
})
it('cancels delayed scroll on mouse leave before delay elapses', async () => {
const wrapper = mount(TextTicker, {
slots: { default: 'Very long text that overflows' },
props: { speed: 100 }
})
mockScrollWidth(wrapper.element as HTMLElement, 300)
await nextTick()
await wrapper.trigger('mouseenter')
await nextTick()
vi.advanceTimersByTime(200)
await wrapper.trigger('mouseleave')
await nextTick()
vi.advanceTimersByTime(350)
await nextTick()
expect(rafCallbacks.length).toBe(0)
})
it('resets scroll position on mouse leave', async () => {
const wrapper = mount(TextTicker, {
slots: { default: 'Very long text that overflows' },
@@ -69,11 +87,13 @@ describe('TextTicker', () => {
})
const el = wrapper.element as HTMLElement
mockOverflow(el, { scrollWidth: 300, clientWidth: 100 })
mockScrollWidth(el, 300)
await nextTick()
await wrapper.trigger('mouseenter')
await nextTick()
vi.advanceTimersByTime(350)
await nextTick()
rafCallbacks[0](performance.now() + 500)
expect(el.scrollLeft).toBeGreaterThan(0)
@@ -89,12 +109,11 @@ describe('TextTicker', () => {
slots: { default: 'Short' }
})
const el = wrapper.element as HTMLElement
mockOverflow(el, { scrollWidth: 50, clientWidth: 100 })
await nextTick()
await wrapper.trigger('mouseenter')
await nextTick()
vi.advanceTimersByTime(350)
await nextTick()
expect(rafCallbacks.length).toBe(0)
})

View File

@@ -10,50 +10,52 @@
</template>
<script setup lang="ts">
import { useElementHover } from '@vueuse/core'
import { onBeforeUnmount, ref, watch } from 'vue'
import { useElementHover, useElementSize, useRafFn } from '@vueuse/core'
import { ref, watch } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { speed = 80 } = defineProps<{
const { speed = 70 } = defineProps<{
/** Scroll speed in pixels per second */
speed?: number
}>()
const containerRef = ref<HTMLElement | null>(null)
const isHovered = useElementHover(containerRef)
const isHovered = useElementHover(containerRef, { delayEnter: 350 })
const { width: containerWidth } = useElementSize(containerRef)
const isScrolling = ref(false)
let animationId: number | null = null
let scrollStartTime = 0
let overflowAmount = 0
const { pause, resume } = useRafFn(
({ timestamp }) => {
const el = containerRef.value
if (!el || overflowAmount <= 0) return pause()
const elapsed = timestamp - scrollStartTime
const duration = (overflowAmount / speed) * 1000
const progress = Math.min(elapsed / duration, 1)
el.scrollLeft = overflowAmount * progress
if (progress >= 1) pause()
},
{ immediate: false }
)
function startScroll() {
const el = containerRef.value
if (!el) return
const overflow = el.scrollWidth - el.clientWidth
if (overflow <= 0) return
overflowAmount = el.scrollWidth - containerWidth.value
if (overflowAmount <= 0) return
isScrolling.value = true
const duration = (overflow / speed) * 1000
const startTime = performance.now()
function animate(currentTime: number) {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
el!.scrollLeft = overflow * progress
if (progress < 1) {
animationId = requestAnimationFrame(animate)
}
}
animationId = requestAnimationFrame(animate)
scrollStartTime = performance.now()
resume()
}
function stopScroll() {
if (animationId !== null) {
cancelAnimationFrame(animationId)
animationId = null
}
pause()
if (containerRef.value) {
containerRef.value.scrollLeft = 0
}
@@ -64,6 +66,4 @@ watch(isHovered, (hovered) => {
if (hovered) startScroll()
else stopScroll()
})
onBeforeUnmount(stopScroll)
</script>