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) => (
+
- {/* 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')
+ })
+})