diff --git a/src/text/motion-ticker/motion-ticker.test.ts b/src/text/motion-ticker/motion-ticker.test.ts index 58634b7..e985b86 100644 --- a/src/text/motion-ticker/motion-ticker.test.ts +++ b/src/text/motion-ticker/motion-ticker.test.ts @@ -11,6 +11,37 @@ const ticker = (extra = '') => >`, ) as Promise +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * Long enough for the rate ramp to bottom out, so a resume happens from a + * fully stopped ticker rather than from one still coasting near full speed. + */ +const HOVER_DWELL = 900 + +/** Track offset in px — negative and decreasing while scrolling left. */ +const trackX = (el: MotionTicker) => { + const track = el.querySelector('div') + if (!track) throw new Error('ticker has no track') + return new DOMMatrix(getComputedStyle(track).transform).m41 +} + +/** Poll until `predicate` holds, so tests never depend on a fixed ramp length. */ +async function until(predicate: () => boolean, timeout = 3000) { + const deadline = performance.now() + timeout + while (!predicate()) { + if (performance.now() > deadline) throw new Error('timed out waiting for condition') + await sleep(16) + } +} + +/** A ticker that has been scrolling long enough to be well away from x: 0. */ +async function scrollingTicker() { + const el = await ticker() + await until(() => trackX(el) < -20) + return el +} + describe('motion-ticker', () => { beforeEach(() => { stubReducedMotion(false) @@ -59,4 +90,178 @@ describe('motion-ticker', () => { el.finish() expect(el.playState).toBe('finished') }) + + it('keeps scrolling while it decelerates on hover', async () => { + const el = await scrollingTicker() + const atHover = trackX(el) + el.dispatchEvent(new MouseEvent('mouseenter')) + await sleep(80) + expect(trackX(el)).toBeLessThan(atHover) + }) + + it('holds its position for as long as the pointer stays', async () => { + const el = await scrollingTicker() + el.dispatchEvent(new MouseEvent('mouseenter')) + await sleep(HOVER_DWELL) + expect(el.playState).toBe('paused') + const stopped = trackX(el) + await sleep(120) + expect(trackX(el)).toBe(stopped) + }) + + it('resumes from where it stopped when the pointer leaves', async () => { + const el = await scrollingTicker() + el.dispatchEvent(new MouseEvent('mouseenter')) + await sleep(HOVER_DWELL) + const stopped = trackX(el) + + el.dispatchEvent(new MouseEvent('mouseleave')) + await sleep(120) + + expect(el.playState).toBe('running') + expect(trackX(el)).toBeLessThanOrEqual(stopped) + expect(trackX(el)).toBeGreaterThan(stopped - 20) + }) + + it('decelerates and resumes in place when toggled by keyboard', async () => { + const el = await scrollingTicker() + const atPress = trackX(el) + el.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })) + await sleep(80) + expect(trackX(el)).toBeLessThan(atPress) + + await sleep(HOVER_DWELL) + const stopped = trackX(el) + el.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })) + await sleep(120) + + expect(el.playState).toBe('running') + expect(trackX(el)).toBeLessThanOrEqual(stopped) + expect(trackX(el)).toBeGreaterThan(stopped - 20) + }) + + it('stays parked in place when a live attribute changes while hover-paused', async () => { + const el = await scrollingTicker() + el.dispatchEvent(new MouseEvent('mouseenter')) + await sleep(HOVER_DWELL) + expect(el.playState).toBe('paused') + const stopped = trackX(el) + + el.setAttribute('speed', '30') + await sleep(120) + + expect(el.playState).toBe('paused') + const parked = trackX(el) + expect(Math.abs(parked - stopped)).toBeLessThan(2) + await sleep(120) + expect(trackX(el)).toBe(parked) + + el.dispatchEvent(new MouseEvent('mouseleave')) + await sleep(120) + + expect(el.playState).toBe('running') + expect(trackX(el)).toBeLessThanOrEqual(parked) + expect(trackX(el)).toBeGreaterThan(parked - 20) + }) + + it('scrolls on without a jump across a live attribute change while running', async () => { + const el = await scrollingTicker() + const atChange = trackX(el) + el.setAttribute('speed', '30') + await sleep(120) + expect(el.playState).toBe('running') + expect(trackX(el)).toBeLessThan(atChange) + expect(trackX(el)).toBeGreaterThan(atChange - 20) + }) + + it('keeps its rendered position when direction flips', async () => { + const el = await scrollingTicker() + el.dispatchEvent(new MouseEvent('mouseenter')) + await sleep(HOVER_DWELL) + expect(el.playState).toBe('paused') + const stopped = trackX(el) + + el.setAttribute('direction', 'right') + await sleep(120) + + expect(el.playState).toBe('paused') + expect(Math.abs(trackX(el) - stopped)).toBeLessThan(2) + }) + + it('keeps a keyboard pause across a pointer visit', async () => { + const el = await scrollingTicker() + el.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })) + await sleep(HOVER_DWELL) + expect(el.playState).toBe('paused') + const stopped = trackX(el) + + el.dispatchEvent(new MouseEvent('mouseenter')) + await sleep(80) + el.dispatchEvent(new MouseEvent('mouseleave')) + await sleep(200) + + expect(el.playState).toBe('paused') + expect(trackX(el)).toBe(stopped) + + el.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })) + await sleep(120) + expect(el.playState).toBe('running') + }) + + it('tops the track back up when the container grows', async () => { + const el = await scrollingTicker() + const setA = el.querySelector('div > div') as HTMLElement + const grownTo = setA.offsetWidth + 200 + el.style.width = `${grownTo}px` + await until(() => setA.offsetWidth >= grownTo) + }) + + it('re-times the wave when speed changes', async () => { + const el = (await fixture( + html`OneTwoThree`, + )) as MotionTicker + await until(() => trackX(el) < -5) + + // Wave period is wave-length / speed: 10s before, 0.5s after. Total + // vertical travel over ~1.1s tells the two apart with a wide margin. + el.setAttribute('speed', '600') + const item = el.querySelector('div > div > span') as HTMLElement + const y = () => new DOMMatrix(getComputedStyle(item).transform).m42 + let travel = 0 + let last = y() + for (let i = 0; i < 14; i++) { + await sleep(80) + const cur = y() + travel += Math.abs(cur - last) + last = cur + } + expect(travel).toBeGreaterThan(25) + }) + + it('re-applies the gap to the track when the attribute changes', async () => { + const el = await scrollingTicker() + const setA = el.querySelector('div > div') as HTMLElement + const setB = setA.nextElementSibling as HTMLElement + expect(getComputedStyle(setA).columnGap).toBe('32px') + + el.setAttribute('gap', '64') + + expect(getComputedStyle(setA).columnGap).toBe('64px') + expect(getComputedStyle(setA).marginRight).toBe('64px') + expect(getComputedStyle(setB).columnGap).toBe('64px') + }) + + it('does not pause on hover when pause-on-hover is false', async () => { + const el = (await fixture( + html`OneTwoThree`, + )) as MotionTicker + await until(() => trackX(el) < -20) + el.dispatchEvent(new MouseEvent('mouseenter')) + await sleep(120) + expect(el.playState).toBe('running') + }) }) diff --git a/src/text/motion-ticker/motion-ticker.ts b/src/text/motion-ticker/motion-ticker.ts index d37a526..f953aec 100644 --- a/src/text/motion-ticker/motion-ticker.ts +++ b/src/text/motion-ticker/motion-ticker.ts @@ -5,6 +5,8 @@ import type { MotionTickerProps, TickerDirection } from './motion-ticker.types.j export type { MotionTickerProps, TickerDirection } from './motion-ticker.types.js' +const MIN_RATE = 0.05 + /** * Horizontal auto-scrolling ticker / marquee. Duplicates children to create a * seamless infinite loop. Supports pause-on-hover, keyboard pause (Space/Enter), @@ -52,12 +54,12 @@ export class MotionTicker extends Controllable(HTMLElement) { private targetRate = 1 private currentRate = 1 private rateRaf: number | null = null - private paused = false private waveRaf: number | null = null private wavePhase = 0 private itemLocalPositions: number[] = [] private resizeObserver: ResizeObserver | null = null + private originalItems: HTMLElement[] = [] playback: PlaybackController = new PlaybackController(this, { start: () => { @@ -73,7 +75,7 @@ export class MotionTicker extends Controllable(HTMLElement) { } }, resume: () => { - this.ctrls?.play() + this.resumeCtrls() if (this.wave) this.startWave() if (this.currentRate < 1) this.lerpRate(1) }, @@ -150,24 +152,14 @@ export class MotionTicker extends Controllable(HTMLElement) { } attributeChangedCallback() { - if (!this.ctrls || !this.setA) return - const w = this.setA.offsetWidth + this.gap - if (!w) return - const progress = (this.ctrls.time / (w / this.speed)) % 1 - this.ctrls.stop() - this.ctrls = animate( - this.track!, - { x: this.direction === 'left' ? [0, -w] : [-w, 0] }, - { duration: w / this.speed, repeat: Infinity, ease: 'linear' }, - ) - this.ctrls.time = progress * (w / this.speed) + this.rebuildMarquee() } private build() { const items = Array.from(this.children) as HTMLElement[] if (!items.length) return + this.originalItems = items - const gap = `${this.gap}px` const track = node('div', { display: 'flex', alignItems: 'center', @@ -178,14 +170,11 @@ export class MotionTicker extends Controllable(HTMLElement) { display: 'flex', alignItems: 'center', flexShrink: '0', - columnGap: gap, - marginRight: gap, }) const setB = node('div', { display: 'flex', alignItems: 'center', flexShrink: '0', - columnGap: gap, }) setB.setAttribute('aria-hidden', 'true') @@ -197,6 +186,7 @@ export class MotionTicker extends Controllable(HTMLElement) { this.track = track this.setA = setA + this.applyGap() requestAnimationFrame(() => { this.fillSet(items) @@ -213,11 +203,13 @@ export class MotionTicker extends Controllable(HTMLElement) { const containerW = this.offsetWidth if (!containerW) return let safety = 50 + let grown = false while (this.setA.offsetWidth < containerW && safety-- > 0) { originals.forEach((c) => this.setA!.appendChild(c.cloneNode(true))) + grown = true } const setB = this.setA.nextElementSibling as HTMLElement | null - if (setB) { + if (setB && (grown || setB.childElementCount !== this.setA.childElementCount)) { setB.replaceChildren() Array.from(this.setA.children).forEach((c) => setB.appendChild(c.cloneNode(true))) } @@ -225,6 +217,7 @@ export class MotionTicker extends Controllable(HTMLElement) { private startMarquee() { if (!this.track || !this.setA) return + this.applyGap() const w = this.setA.offsetWidth + this.gap if (!w) { requestAnimationFrame(() => this.startMarquee()) @@ -234,7 +227,6 @@ export class MotionTicker extends Controllable(HTMLElement) { this.ctrls?.stop() this.currentRate = 1 this.targetRate = 1 - this.paused = false this.ctrls = animate( this.track, @@ -257,35 +249,91 @@ export class MotionTicker extends Controllable(HTMLElement) { this.addEventListener('blur', this.onLeave) this.addEventListener('keydown', this.onKeyDown) + this.refreshWave() + } + + private refreshWave() { + if (!this.setA) return this.style.overflow = this.wave ? 'visible' : 'hidden' - if (this.wave) { - const setALeft = this.setA.getBoundingClientRect().left - this.itemLocalPositions = (Array.from(this.setA.children) as HTMLElement[]).map((el) => { - const r = el.getBoundingClientRect() - return r.left + r.width / 2 - setALeft - }) - this.startWave() - } else { + if (!this.wave) { this.stopWave() + return } + const setALeft = this.setA.getBoundingClientRect().left + this.itemLocalPositions = (Array.from(this.setA.children) as HTMLElement[]).map((el) => { + const r = el.getBoundingClientRect() + return r.left + r.width / 2 - setALeft + }) + // While paused the wave loop stays down; resume restarts it and picks up + // the freshly measured positions. + if (this.playState === 'running') this.startWave() } private onResize() { + this.rebuildMarquee() + } + + /** + * Rebuilds the animation against the current attribute values and geometry, + * carrying over the rendered position, rate and the pause state. `speed` is + * floored at MIN_RATE like `resumeCtrls()`, since a running animation at + * speed 0 reads back `time` as 0 and would lose the position on the next + * resume. + */ + private applyGap() { + if (!this.setA) return + const gap = `${this.gap}px` + this.setA.style.columnGap = gap + this.setA.style.marginRight = gap + const setB = this.setA.nextElementSibling as HTMLElement | null + if (setB) setB.style.columnGap = gap + } + + private rebuildMarquee() { + if (this.playState !== 'running' && this.playState !== 'paused') return if (!this.ctrls || !this.setA || !this.track) return + this.applyGap() + this.fillSet(this.originalItems) const w = this.setA.offsetWidth + this.gap if (!w) return - const elapsed = this.ctrls.time - const oldDuration = w / this.speed - const progress = (elapsed / oldDuration) % 1 + // The new time is derived from the rendered offset rather than the old + // animation's clock, so the track holds its place even when the duration + // or direction it would be measured against has just changed. + const progress = this.renderedProgress(w) this.ctrls.stop() - const newDuration = w / this.speed + const duration = w / this.speed this.ctrls = animate( this.track, { x: this.direction === 'left' ? [0, -w] : [-w, 0] }, - { duration: newDuration, repeat: Infinity, ease: 'linear' }, + { duration, repeat: Infinity, ease: 'linear' }, ) - this.ctrls.time = progress * newDuration + // Reading `duration` flushes motion's async keyframe resolver; before + // that, assigning `time` cannot rebase the running animation and play() + // would restart it from 0 on the next frame. + void this.ctrls.duration + this.ctrls.speed = Math.max(this.currentRate, MIN_RATE) + // The playback controller already reports 'paused', so its pause() is a + // no-op here — the freshly built animation has to be held directly. + if (this.playState === 'paused') this.ctrls.pause() + this.ctrls.time = progress * duration + this.refreshWave() + } + + private renderedProgress(w: number): number { + const transform = getComputedStyle(this.track!).transform + if (transform === 'none') return 0 + const x = new DOMMatrix(transform).m41 + const p = this.direction === 'left' ? -x / w : x / w + 1 + return ((p % 1) + 1) % 1 + } + + private resumeCtrls() { + if (!this.ctrls) return + const time = this.ctrls.time + this.currentRate = Math.max(this.currentRate, MIN_RATE) this.ctrls.speed = this.currentRate + this.ctrls.play() + this.ctrls.time = time } private lerpRate(target: number) { @@ -293,17 +341,21 @@ export class MotionTicker extends Controllable(HTMLElement) { if (this.rateRaf !== null) return const step = () => { - if (!this.ctrls) return + if (!this.ctrls) { + this.rateRaf = null + return + } const diff = this.targetRate - this.currentRate - if (Math.abs(diff) < 0.003) { - this.currentRate = this.targetRate - if (this.currentRate === 0) { - this.ctrls.pause() - this.paused = true + const stopped = this.targetRate === 0 && this.currentRate <= MIN_RATE + if (stopped || Math.abs(diff) < 0.003) { + this.rateRaf = null + if (this.targetRate === 0) { + this.currentRate = 0 + this.pause() } else { + this.currentRate = this.targetRate this.ctrls.speed = this.currentRate } - this.rateRaf = null return } this.currentRate += diff * 0.1 @@ -314,16 +366,14 @@ export class MotionTicker extends Controllable(HTMLElement) { } private onEnter = () => { - if (this.playState === 'running') this.pause() this.lerpRate(0) } private onLeave = () => { - if (this.paused) { - this.ctrls?.play() - this.paused = false - } - this.lerpRate(1) + // A keyboard pause is an explicit request; the pointer or focus wandering + // off must not override it. Space lifts it again. + if (this.keyboardPaused) return if (this.playState === 'paused') void this.play() + this.lerpRate(1) } private keyboardPaused = false @@ -333,16 +383,11 @@ export class MotionTicker extends Controllable(HTMLElement) { e.preventDefault() if (this.keyboardPaused) { this.keyboardPaused = false - if (this.paused) { - this.ctrls?.play() - this.paused = false - } - this.lerpRate(1) if (this.playState === 'paused') void this.play() + this.lerpRate(1) } else { this.keyboardPaused = true this.lerpRate(0) - if (this.playState === 'running') this.pause() } }