From adda9ddbd07a7bb04947c349a2117dd31879d190 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 31 Jul 2026 17:04:27 +0200 Subject: [PATCH] fix(home): slow sponsor ticker --- src/spa/components/ui/partner-ticker.tsx | 167 ++++++++++++++++++----- src/spa/pages/Home.tsx | 5 +- tests/int/partner-ticker.int.spec.tsx | 73 ++++++++++ 3 files changed, 210 insertions(+), 35 deletions(-) create mode 100644 tests/int/partner-ticker.int.spec.tsx diff --git a/src/spa/components/ui/partner-ticker.tsx b/src/spa/components/ui/partner-ticker.tsx index c530581..ef370cf 100644 --- a/src/spa/components/ui/partner-ticker.tsx +++ b/src/spa/components/ui/partner-ticker.tsx @@ -1,52 +1,151 @@ -import React from "react"; +import React, { useState } from 'react' interface PartnerLogo { - id: string; - label: React.ReactNode; + id: string + label: React.ReactNode } interface PartnerTickerProps { - logos: PartnerLogo[]; + ariaLabel: string + logos: PartnerLogo[] } -export function PartnerTicker({ logos }: PartnerTickerProps) { - // Duplicate logos for seamless infinite loop - const track = [...logos, ...logos]; +export const PARTNER_TICKER_ITEM_WIDTH_PX = 260 +export const PARTNER_TICKER_SPEED_PX_PER_SECOND = 48 +export const PARTNER_TICKER_MIN_DURATION_SECONDS = 24 + +export function partnerTickerDurationSeconds(logoCount: number) { + if (logoCount <= 0) return 0 + + const loopDistance = logoCount * PARTNER_TICKER_ITEM_WIDTH_PX + return Math.max( + PARTNER_TICKER_MIN_DURATION_SECONDS, + loopDistance / PARTNER_TICKER_SPEED_PX_PER_SECOND, + ) +} + +export function PartnerTicker({ ariaLabel, logos }: PartnerTickerProps) { + const [hasFocusWithin, setHasFocusWithin] = useState(false) + const [isHovered, setIsHovered] = useState(false) + + if (logos.length === 0) return null + + const durationSeconds = partnerTickerDurationSeconds(logos.length) + const isPaused = hasFocusWithin || isHovered + + const renderLogoCopy = (copy: number) => ( +
+ {logos.map((logo) => ( +
+ {logo.label} +
+ ))} +
+ ) return ( -
- {/* fade edges */} -
-
+
{ + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setHasFocusWithin(false) + } + }} + onFocus={() => setHasFocusWithin(true)} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + role="region" + style={{ flex: 1, minWidth: 0, overflow: 'hidden', position: 'relative' }} + tabIndex={0} + > +
+
-
- {track.map((logo, i) => ( -
- {logo.label} -
- ))} +
+ {renderLogoCopy(0)} + {renderLogoCopy(1)}
- ); + ) } diff --git a/src/spa/pages/Home.tsx b/src/spa/pages/Home.tsx index acd39ef..64bb261 100644 --- a/src/spa/pages/Home.tsx +++ b/src/spa/pages/Home.tsx @@ -268,7 +268,10 @@ const Home: React.FC = () => {
- +
diff --git a/tests/int/partner-ticker.int.spec.tsx b/tests/int/partner-ticker.int.spec.tsx new file mode 100644 index 0000000..55bc834 --- /dev/null +++ b/tests/int/partner-ticker.int.spec.tsx @@ -0,0 +1,73 @@ +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { + PARTNER_TICKER_ITEM_WIDTH_PX, + PARTNER_TICKER_MIN_DURATION_SECONDS, + PARTNER_TICKER_SPEED_PX_PER_SECOND, + PartnerTicker, + partnerTickerDurationSeconds, +} from '@/spa/components/ui/partner-ticker' + +const logos = ['RSM', 'WWK', 'münchen.tv'].map((name) => ({ + id: name, + label: {name}, +})) + +describe('homepage partner ticker', () => { + afterEach(() => cleanup()) + + it('derives a calm duration from the exact single-copy track length', () => { + expect(partnerTickerDurationSeconds(0)).toBe(0) + expect(partnerTickerDurationSeconds(1)).toBe(PARTNER_TICKER_MIN_DURATION_SECONDS) + expect(partnerTickerDurationSeconds(12)).toBe( + (12 * PARTNER_TICKER_ITEM_WIDTH_PX) / PARTNER_TICKER_SPEED_PX_PER_SECOND, + ) + expect(partnerTickerDurationSeconds(12)).toBe(65) + }) + + it('duplicates one identical track and translates by exactly one copy', () => { + render() + + const copies = screen.getAllByTestId('partner-ticker-copy') + expect(copies).toHaveLength(2) + expect(copies[1].getAttribute('aria-hidden')).toBe('true') + expect(within(copies[0]).getAllByText(/RSM|WWK|münchen.tv/)).toHaveLength(logos.length) + expect(within(copies[1]).getAllByText(/RSM|WWK|münchen.tv/)).toHaveLength(logos.length) + + const ticker = screen.getByTestId('partner-ticker') + const styles = ticker.querySelector('style')?.textContent || '' + expect(styles).toContain('to { transform: translateX(-50%); }') + }) + + it('pauses for pointer hover and keyboard focus within the ticker', () => { + render() + + const ticker = screen.getByTestId('partner-ticker') + const track = screen.getByTestId('partner-ticker-track') + expect(track.style.animationPlayState).toBe('running') + + fireEvent.mouseEnter(ticker) + expect(track.style.animationPlayState).toBe('paused') + fireEvent.mouseLeave(ticker) + expect(track.style.animationPlayState).toBe('running') + + fireEvent.focus(ticker) + expect(track.style.animationPlayState).toBe('paused') + fireEvent.blur(ticker) + expect(track.style.animationPlayState).toBe('running') + }) + + it('provides a non-animated, horizontally accessible reduced-motion presentation', () => { + render() + + const ticker = screen.getByTestId('partner-ticker') + const styles = ticker.querySelector('style')?.textContent || '' + expect(ticker.getAttribute('tabindex')).toBe('0') + expect(styles).toContain('@media (prefers-reduced-motion: reduce)') + expect(styles).toContain('overflow-x: auto !important') + expect(styles).toContain('animation: none !important') + expect(styles).toContain('.partner-ticker-copy[aria-hidden="true"]') + expect(styles).toContain('display: none !important') + }) +})