fix(home): slow sponsor ticker
This commit is contained in:
@@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
73
tests/int/partner-ticker.int.spec.tsx
Normal file
73
tests/int/partner-ticker.int.spec.tsx
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user