fix(home): slow sponsor ticker

This commit is contained in:
syntaxbullet
2026-07-31 17:04:27 +02:00
parent 95037e9908
commit adda9ddbd0
3 changed files with 210 additions and 35 deletions

View File

@@ -1,52 +1,151 @@
import React from "react"; import React, { useState } from 'react'
interface PartnerLogo { interface PartnerLogo {
id: string; id: string
label: React.ReactNode; label: React.ReactNode
} }
interface PartnerTickerProps { interface PartnerTickerProps {
logos: PartnerLogo[]; ariaLabel: string
logos: PartnerLogo[]
} }
export function PartnerTicker({ logos }: PartnerTickerProps) { export const PARTNER_TICKER_ITEM_WIDTH_PX = 260
// Duplicate logos for seamless infinite loop export const PARTNER_TICKER_SPEED_PX_PER_SECOND = 48
const track = [...logos, ...logos]; 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) => (
<div
aria-hidden={copy === 1 ? 'true' : undefined}
className="partner-ticker-copy"
data-testid="partner-ticker-copy"
key={copy}
style={{ display: 'flex' }}
>
{logos.map((logo) => (
<div
key={`${copy}-${logo.id}`}
style={{
alignItems: 'center',
color: '#fff',
display: 'flex',
flexShrink: 0,
fontFamily: '"IBM Plex Sans", sans-serif',
justifyContent: 'center',
padding: '0 24px',
textAlign: 'center',
whiteSpace: 'nowrap',
width: PARTNER_TICKER_ITEM_WIDTH_PX,
}}
>
{logo.label}
</div>
))}
</div>
)
return ( return (
<div style={{ overflow: 'hidden', flex: 1, minWidth: 0, position: 'relative' }}> <div
{/* fade edges */} aria-label={ariaLabel}
<div style={{ position: 'absolute', inset: 0, left: 0, width: 32, background: 'linear-gradient(to right, rgba(5,26,135,0.6), transparent)', zIndex: 1, pointerEvents: 'none' }} /> className="partner-ticker-viewport"
<div style={{ position: 'absolute', inset: 0, right: 0, left: 'auto', width: 32, background: 'linear-gradient(to left, rgba(5,26,135,0.6), transparent)', zIndex: 1, pointerEvents: 'none' }} /> data-duration-seconds={durationSeconds}
data-testid="partner-ticker"
onBlur={(event) => {
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}
>
<div
style={{
background: 'linear-gradient(to right, rgba(5,26,135,0.6), transparent)',
inset: 0,
left: 0,
pointerEvents: 'none',
position: 'absolute',
width: 32,
zIndex: 1,
}}
/>
<div
style={{
background: 'linear-gradient(to left, rgba(5,26,135,0.6), transparent)',
inset: 0,
left: 'auto',
pointerEvents: 'none',
position: 'absolute',
right: 0,
width: 32,
zIndex: 1,
}}
/>
<div style={{ display: 'flex', animation: 'marquee 22s linear infinite', width: 'max-content' }}> <div
{track.map((logo, i) => ( className="partner-ticker-track"
<div data-testid="partner-ticker-track"
key={`${logo.id}-${i}`} style={{
style={{ animation: `partner-marquee ${durationSeconds}s linear infinite`,
display: 'flex', animationPlayState: isPaused ? 'paused' : 'running',
alignItems: 'center', display: 'flex',
justifyContent: 'center', width: 'max-content',
padding: '0 24px', }}
color: '#fff', >
fontFamily: '"IBM Plex Sans", sans-serif', {renderLogoCopy(0)}
whiteSpace: 'nowrap', {renderLogoCopy(1)}
width: 260,
flexShrink: 0,
textAlign: 'center',
}}
>
{logo.label}
</div>
))}
</div> </div>
<style>{` <style>{`
@keyframes marquee { @keyframes partner-marquee {
from { transform: translateX(0); } from { transform: translateX(0); }
to { transform: translateX(-50%); } to { transform: translateX(-50%); }
}
.partner-ticker-viewport:hover .partner-ticker-track,
.partner-ticker-viewport:focus-within .partner-ticker-track {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.partner-ticker-viewport {
overflow-x: auto !important;
overscroll-behavior-x: contain;
}
.partner-ticker-track {
animation: none !important;
transform: none !important;
}
.partner-ticker-copy[aria-hidden="true"] {
display: none !important;
}
} }
`}</style> `}</style>
</div> </div>
); )
} }

View File

@@ -268,7 +268,10 @@ const Home: React.FC = () => {
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}>
<div style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.2)' }} /> <div style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.2)' }} />
<div style={{ width: 'min(820px, calc(100vw - 260px))', overflow: 'hidden', flexShrink: 0 }}> <div style={{ width: 'min(820px, calc(100vw - 260px))', overflow: 'hidden', flexShrink: 0 }}>
<PartnerTicker logos={partnerLogos(networkPartners)} /> <PartnerTicker
ariaLabel={fallbackText(hero.partnersLabel, homeContent.hero.partnersLabel)}
logos={partnerLogos(networkPartners)}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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: <span>{name}</span>,
}))
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(<PartnerTicker ariaLabel="Unsere Partner" logos={logos} />)
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(<PartnerTicker ariaLabel="Unsere Partner" logos={logos} />)
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(<PartnerTicker ariaLabel="Unsere Partner" logos={logos} />)
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')
})
})