fix(participation): repair process timeline

This commit is contained in:
syntaxbullet
2026-07-31 15:40:21 +02:00
parent c2dc42f243
commit ebecf66e6f
4 changed files with 393 additions and 153 deletions

View File

@@ -1,8 +1,9 @@
import React from 'react';
import { FileText, Search, UserPlus, Trophy } from 'lucide-react';
import { ChevronLeft, ChevronRight, FileText, Search, UserPlus, Trophy } from 'lucide-react';
import {
ContainerScroll,
ContainerSticky,
getActiveTimelineStep,
getTimelineScrollBehavior,
getTimelineStepScrollLeft,
ProcessCard,
ProcessCardBody,
ProcessCardTitle,
@@ -34,12 +35,69 @@ function Lines({ text }: { text: string }) {
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
}
function usePrefersReducedMotion() {
const query = '(prefers-reduced-motion: reduce)';
return React.useSyncExternalStore(
(onStoreChange) => {
const mediaQuery = window.matchMedia(query);
mediaQuery.addEventListener('change', onStoreChange);
return () => mediaQuery.removeEventListener('change', onStoreChange);
},
() => window.matchMedia(query).matches,
() => false,
);
}
export default function WegZurAuszeichnungSection({ content }: { content?: ProcessContent }) {
const isMobile = useIsMobile();
const prefersReducedMotion = usePrefersReducedMotion();
const trackRef = React.useRef<HTMLOListElement>(null);
const [activeStep, setActiveStep] = React.useState(0);
const section = { ...participationContent.process, ...(content || {}) };
const steps = fallbackArray(section.steps, participationContent.process.steps);
const stepPrefix = fallbackText(section.stepPrefix, participationContent.process.stepPrefix);
const scrollToStep = React.useCallback((index: number) => {
const track = trackRef.current;
if (!track) return;
const nextStep = Math.min(steps.length - 1, Math.max(0, index));
const maxScroll = Math.max(0, track.scrollWidth - track.clientWidth);
track.scrollTo({
behavior: getTimelineScrollBehavior(prefersReducedMotion),
left: getTimelineStepScrollLeft(nextStep, maxScroll, steps.length),
});
setActiveStep(nextStep);
}, [prefersReducedMotion, steps.length]);
const handleTrackScroll = React.useCallback(() => {
const track = trackRef.current;
if (!track) return;
setActiveStep(getActiveTimelineStep(
track.scrollLeft,
Math.max(0, track.scrollWidth - track.clientWidth),
steps.length,
));
}, [steps.length]);
const handleTrackKeyDown = (event: React.KeyboardEvent<HTMLOListElement>) => {
if (event.key === 'ArrowRight') {
event.preventDefault();
scrollToStep(activeStep + 1);
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollToStep(activeStep - 1);
} else if (event.key === 'Home') {
event.preventDefault();
scrollToStep(0);
} else if (event.key === 'End') {
event.preventDefault();
scrollToStep(steps.length - 1);
}
};
// ── MOBILE: stacked vertical list (no scroll-jack, no horizontal overflow) ──
if (isMobile) {
return (
@@ -94,14 +152,13 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
}
return (
<ContainerScroll id="schritte" className="h-[300vh]" style={{ background: NAVY }}>
<ContainerSticky className="top-0 h-screen flex flex-col">
<section id="schritte" aria-labelledby="process-heading" style={{ background: NAVY }}>
{/* Section header */}
<div style={{ padding: '56px 80px 40px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 40, alignItems: 'flex-end', borderBottom: '1px solid rgba(255,255,255,0.07)', flexShrink: 0 }}>
<div className="process-timeline-header" style={{ padding: '56px 80px 40px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 40, alignItems: 'flex-end', borderBottom: '1px solid rgba(255,255,255,0.07)' }}>
<div>
<span style={{ fontFamily: FF, fontSize: 10, color: GOLD, textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 14 }}>{fallbackText(section.eyebrow, participationContent.process.eyebrow)}</span>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: 0 }}>
<h2 id="process-heading" className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: 0 }}>
<Lines text={fallbackText(section.heading, participationContent.process.heading)} />
</h2>
</div>
@@ -110,15 +167,14 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
</p>
</div>
{/* Scroll hint prominent */}
<div style={{ padding: '20px 80px', display: 'flex', alignItems: 'center', gap: 14, flexShrink: 0 }}>
{/* Animated chevron stack */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{[0, 1, 2].map(i => (
{/* Controls and real progress state */}
<div className="process-timeline-controls" style={{ padding: '20px 80px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div aria-hidden="true" style={{ display: 'flex', gap: 2 }}>
{[0, 1, 2].map((i) => (
<svg
key={i}
width="12" height="7" viewBox="0 0 12 7" fill="none"
style={{ opacity: 1 - i * 0.3, animation: `scrollBounce 1.6s ease-in-out ${i * 0.18}s infinite` }}
style={{ opacity: 1 - i * 0.3, transform: 'rotate(-90deg)' }}
>
<polyline points="1,1 6,6 11,1" stroke={GOLD} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
@@ -127,24 +183,57 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.22em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.55)' }}>
{fallbackText(section.scrollHint, participationContent.process.scrollHint)}
</span>
{/* Progress bar */}
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,0.07)', position: 'relative', overflow: 'hidden', maxWidth: 200 }}>
<div style={{ position: 'absolute', left: 0, top: 0, height: '100%', width: '25%', background: GOLD, opacity: 0.6 }} />
<div data-testid="timeline-progress" style={{ position: 'absolute', left: 0, top: 0, height: '100%', width: `${((activeStep + 1) / steps.length) * 100}%`, background: GOLD, opacity: 0.6, transition: prefersReducedMotion ? 'none' : 'width 0.2s ease' }} />
</div>
<span aria-live="polite" data-testid="timeline-progress-label" style={{ fontFamily: FF, fontSize: 9, color: 'rgba(255,255,255,0.50)', letterSpacing: '0.1em', minWidth: 44 }}>
{String(activeStep + 1).padStart(2, '0')} / {String(steps.length).padStart(2, '0')}
</span>
<div style={{ display: 'flex', gap: 8, marginLeft: 'auto' }}>
<button
type="button"
aria-label="Vorheriger Schritt"
aria-controls="participation-timeline-track"
disabled={activeStep === 0}
onClick={() => scrollToStep(activeStep - 1)}
className="process-timeline-button"
>
<ChevronLeft size={18} aria-hidden="true" />
</button>
<button
type="button"
aria-label="Nächster Schritt"
aria-controls="participation-timeline-track"
disabled={activeStep === steps.length - 1}
onClick={() => scrollToStep(activeStep + 1)}
className="process-timeline-button"
>
<ChevronRight size={18} aria-hidden="true" />
</button>
</div>
<span style={{ fontFamily: FF, fontSize: 9, color: 'rgba(255,255,255,0.50)', letterSpacing: '0.1em' }}>{fallbackText(section.progressLabel, participationContent.process.progressLabel)}</span>
</div>
{/* Cards strip */}
<div className="flex flex-nowrap flex-1 items-stretch px-20 pb-16 gap-0">
{/* Native horizontal carousel: wheel/trackpad, touch, controls, and keyboard all work. */}
<div className="process-timeline-card-shell">
<ol
ref={trackRef}
id="participation-timeline-track"
data-testid="timeline-track"
aria-label="Schritte zum Preis"
tabIndex={0}
onKeyDown={handleTrackKeyDown}
onScroll={handleTrackScroll}
className="process-timeline-track"
>
{steps.map((item, index) => {
const Icon = ICONS[item.icon as keyof typeof ICONS] || FileText;
return (
<ProcessCard
key={`${item.step}-${index}`}
itemsLength={steps.length}
index={index}
variant="bmp"
className="h-[300px] self-start min-w-[70%] max-w-[70%] flex-shrink-0"
data-testid={`timeline-step-${index + 1}`}
aria-label={`${stepPrefix} ${fallbackText(item.step, String(index + 1))}: ${fallbackText(item.title, '')}`}
className="process-timeline-card"
>
{/* Left accent strip with step number */}
<ProcessCardTitle className="border-r border-[rgba(239,191,4,0.2)] flex flex-col items-center justify-between py-6 px-4 min-w-[64px]">
@@ -175,24 +264,88 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
{fallbackText(item.desc, '')}
</p>
{/* Step progress indicator */}
<div style={{ display: 'flex', gap: 4 }}>
<div aria-hidden="true" style={{ display: 'flex', gap: 4 }}>
{steps.map((_, i) => (
<div key={i} style={{ height: 2, flex: i === index ? 2 : 1, background: i === index ? GOLD : 'rgba(255,255,255,0.1)', transition: 'flex 0.3s' }} />
<div
key={i}
style={{ height: 2, flex: i === activeStep ? 2 : 1, background: i === activeStep ? GOLD : 'rgba(255,255,255,0.1)', transition: prefersReducedMotion ? 'none' : 'flex 0.2s ease' }}
/>
))}
</div>
</ProcessCardBody>
</ProcessCard>
);
})}
</ol>
</div>
</ContainerSticky>
<style>{`
@keyframes scrollBounce {
0%, 100% { transform: translateY(0); opacity: inherit; }
50% { transform: translateY(3px); }
.process-timeline-card-shell {
padding: 0 80px 52px;
}
.process-timeline-track {
display: grid;
grid-auto-flow: column;
grid-auto-columns: min(75%, 980px);
gap: 16px;
margin: 0;
padding: 0 0 12px;
overflow-x: auto;
overscroll-behavior-inline: contain;
scroll-snap-type: inline mandatory;
scrollbar-color: rgba(239,191,4,0.55) rgba(255,255,255,0.08);
scrollbar-width: thin;
}
.process-timeline-card {
min-height: 320px;
scroll-snap-align: start;
scroll-snap-stop: always;
}
.process-timeline-track:focus-visible,
.process-timeline-button:focus-visible {
outline: 2px solid ${GOLD};
outline-offset: 3px;
}
.process-timeline-button {
width: 38px;
height: 38px;
border: 1px solid rgba(239,191,4,0.45);
background: transparent;
color: ${GOLD};
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.process-timeline-button:disabled {
opacity: 0.3;
cursor: default;
}
@media (max-width: 1024px) {
.process-timeline-header {
padding: 48px 48px 36px !important;
}
.process-timeline-controls {
padding: 18px 48px !important;
}
.process-timeline-card-shell {
padding: 0 48px 44px;
}
.process-timeline-track {
grid-auto-columns: 82%;
}
}
@media (prefers-reduced-motion: reduce) {
.process-timeline-track {
scroll-behavior: auto;
}
.process-timeline-card,
.process-timeline-card * {
transition: none !important;
animation: none !important;
}
}
`}</style>
</ContainerScroll>
</section>
);
}

View File

@@ -2,117 +2,64 @@
import * as React from "react"
import { useMeasure } from "@uidotdev/usehooks"
import { VariantProps, cva } from "class-variance-authority"
import {
HTMLMotionProps,
MotionValue,
motion,
useScroll,
useTransform,
} from "motion/react"
import { cn } from "@/spa/lib/utils"
const processCardVariants = cva("flex border backdrop-blur-lg", {
const processCardVariants = cva("flex border", {
variants: {
variant: {
indigo:
"flex border text-slate-50 border-slate-700 backdrop-blur-lg bg-gradient-to-br from-[rgba(15,23,42,0.7)_40%] to-[#3730a3_120%]",
"text-slate-50 border-slate-700 bg-gradient-to-br from-[rgba(15,23,42,0.7)_40%] to-[#3730a3_120%]",
light: "shadow",
bmp: "flex border text-white border-[rgba(239,191,4,0.25)] bg-[#111D55]",
},
size: {
sm: "min-w-[25%] max-w-[25%]",
md: "min-w-[50%] max-w-[50%]",
lg: "min-w-[75%] max-w-[75%]",
xl: "min-w-full max-w-full",
bmp: "text-white border-[rgba(239,191,4,0.25)] bg-[#111D55]",
},
},
defaultVariants: {
variant: "bmp",
size: "md",
},
})
const subscribeToViewportWidth = (onStoreChange: () => void) => {
window.addEventListener("resize", onStoreChange)
return () => window.removeEventListener("resize", onStoreChange)
export function getActiveTimelineStep(
scrollLeft: number,
maxScroll: number,
stepsLength: number,
) {
if (stepsLength <= 1 || maxScroll <= 0) return 0
const progress = Math.min(1, Math.max(0, scrollLeft / maxScroll))
return Math.round(progress * (stepsLength - 1))
}
const getViewportWidth = () => window.innerWidth
export function getTimelineStepScrollLeft(
index: number,
maxScroll: number,
stepsLength: number,
) {
if (stepsLength <= 1 || maxScroll <= 0) return 0
const getServerViewportWidth = () => 0
function useViewportWidth() {
return React.useSyncExternalStore(
subscribeToViewportWidth,
getViewportWidth,
getServerViewportWidth
)
const safeIndex = Math.min(stepsLength - 1, Math.max(0, index))
return (safeIndex / (stepsLength - 1)) * maxScroll
}
interface ContainerScrollContextValue {
scrollYProgress: MotionValue<number>
export function getTimelineScrollBehavior(prefersReducedMotion: boolean): ScrollBehavior {
return prefersReducedMotion ? "auto" : "smooth"
}
interface ProcessCardProps
extends HTMLMotionProps<"div">,
VariantProps<typeof processCardVariants> {
itemsLength: number
index: number
}
extends React.HTMLAttributes<HTMLLIElement>,
VariantProps<typeof processCardVariants> {}
const ContainerScrollContext = React.createContext<
ContainerScrollContextValue | undefined
>(undefined)
function useContainerScrollContext() {
const context = React.useContext(ContainerScrollContext)
if (!context) {
throw new Error(
"useContainerScrollContext must be used within a ContainerScroll Component"
)
}
return context
}
export const ContainerScroll = ({
children,
className,
style,
...props
}: React.HtmlHTMLAttributes<HTMLDivElement>) => {
const scrollRef = React.useRef<HTMLDivElement>(null)
const { scrollYProgress } = useScroll({
target: scrollRef,
offset: ["start start", "end end"],
})
return (
<ContainerScrollContext.Provider value={{ scrollYProgress }}>
<div
ref={scrollRef}
className={cn("relative min-h-[120vh]", className)}
style={{ position: "relative", ...style }}
{...props}
>
{children}
</div>
</ContainerScrollContext.Provider>
)
}
export const ContainerSticky = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
export const ProcessCard = React.forwardRef<HTMLLIElement, ProcessCardProps>(
({ className, variant, ...props }, ref) => (
<li
ref={ref}
className={cn("sticky left-0 top-0 w-full overflow-hidden", className)}
className={cn(processCardVariants({ variant }), className)}
{...props}
/>
))
ContainerSticky.displayName = "ContainerSticky"
),
)
ProcessCard.displayName = "ProcessCard"
export const ProcessCardTitle = React.forwardRef<
HTMLDivElement,
@@ -133,37 +80,3 @@ export const ProcessCardBody = React.forwardRef<
/>
))
ProcessCardBody.displayName = "ProcessCardBody"
export const ProcessCard: React.FC<ProcessCardProps> = ({
className,
style,
variant,
size,
itemsLength,
index,
...props
}) => {
const { scrollYProgress } = useContainerScrollContext()
const start = index / itemsLength
const end = start + 1 / itemsLength
const viewportWidth = useViewportWidth()
const [ref, { width }] = useMeasure()
const x = useTransform(
scrollYProgress,
[start, end],
[viewportWidth, -((width ?? 0) * index) + 64 * index]
)
return (
<motion.div
ref={ref}
style={{
x: index > 0 ? x : 0,
...style,
}}
className={cn(processCardVariants({ variant, size }), className)}
{...props}
/>
)
}
ProcessCard.displayName = "ProcessCard"

View File

@@ -15,3 +15,86 @@ test.describe('Frontend', () => {
await expect(heading).toHaveText('Payload Website Template')
})
})
test.describe('Participation process timeline', () => {
for (const viewport of [
{ width: 1440, height: 900 },
{ width: 1024, height: 768 },
{ width: 768, height: 1024 },
{ width: 390, height: 844 },
]) {
test(`keeps all steps reachable at ${viewport.width}px`, async ({ page }) => {
await page.setViewportSize(viewport)
await page.goto('http://localhost:3000/teilnahme#schritte')
const timeline = page.locator('#schritte')
const eligibility = page.locator('#voraussetzungen')
const steps = timeline.locator('[data-testid^="timeline-step-"]')
await expect(timeline).toBeVisible()
if (viewport.width < 768) {
await expect(timeline.locator('h3')).toHaveCount(4)
} else {
await expect(steps).toHaveCount(4)
const nextButton = timeline.getByRole('button', { name: 'Nächster Schritt', exact: true })
const visibleTrackRatio = async (index: number) => steps.nth(index).evaluate((card) => {
const track = card.closest('[data-testid="timeline-track"]')
if (!track) return 0
const cardRect = card.getBoundingClientRect()
const trackRect = track.getBoundingClientRect()
const visibleWidth = Math.max(
0,
Math.min(cardRect.right, trackRect.right) - Math.max(cardRect.left, trackRect.left),
)
return visibleWidth / cardRect.width
})
for (let index = 0; index < 4; index += 1) {
await expect.poll(() => visibleTrackRatio(index)).toBeGreaterThan(0.55)
if (index < 3) {
await nextButton.click()
await expect(timeline.locator('[data-testid="timeline-progress-label"]')).toHaveText(
`${String(index + 2).padStart(2, '0')} / 04`,
)
}
}
const cardsOverlap = await steps.evaluateAll((cards) => cards.some((card, index) => {
const nextCard = cards[index + 1]
return nextCard ? card.getBoundingClientRect().right > nextCard.getBoundingClientRect().left : false
}))
expect(cardsOverlap).toBe(false)
expect(await timeline.evaluate((element) => element.getBoundingClientRect().height)).toBeLessThan(900)
}
const sectionGap = await page.evaluate(() => {
const timelineElement = document.querySelector('#schritte')
const eligibilityElement = document.querySelector('#voraussetzungen')
if (!timelineElement || !eligibilityElement) return Number.POSITIVE_INFINITY
return eligibilityElement.getBoundingClientRect().top - timelineElement.getBoundingClientRect().bottom
})
expect(Math.abs(sectionGap)).toBeLessThanOrEqual(2)
})
}
test('uses a fully readable non-animated reduced-motion interaction', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.setViewportSize({ width: 1024, height: 768 })
await page.goto('http://localhost:3000/teilnahme#schritte')
const timeline = page.locator('#schritte')
await timeline.getByRole('button', { name: 'Nächster Schritt', exact: true }).click()
await expect(timeline.locator('[data-testid="timeline-progress-label"]')).toHaveText('02 / 04')
await expect.poll(() => timeline.locator('[data-testid="timeline-step-2"]').evaluate((card) => {
const track = card.closest('[data-testid="timeline-track"]')
if (!track) return 0
const cardRect = card.getBoundingClientRect()
const trackRect = track.getBoundingClientRect()
return Math.max(0, Math.min(cardRect.right, trackRect.right) - Math.max(cardRect.left, trackRect.left)) / cardRect.width
})).toBeGreaterThan(0.55)
expect(await timeline.locator('[data-testid="timeline-track"]').evaluate((element) => (
getComputedStyle(element).scrollBehavior
))).toBe('auto')
})
})

View File

@@ -0,0 +1,91 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import WegZurAuszeichnungSection from '@/spa/components/WegZurAuszeichnungSection'
import {
getActiveTimelineStep,
getTimelineScrollBehavior,
getTimelineStepScrollLeft,
} from '@/spa/components/ui/process-timeline'
vi.mock('@/spa/hooks/useIsMobile', () => ({
useIsMobile: () => false,
}))
let reduceMotion = false
describe('participation timeline', () => {
beforeEach(() => {
reduceMotion = false
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
addEventListener: vi.fn(),
matches: query === '(prefers-reduced-motion: reduce)' && reduceMotion,
media: query,
removeEventListener: vi.fn(),
})),
})
})
afterEach(() => cleanup())
it('maps the real horizontal scroll range to deterministic steps', () => {
expect(getActiveTimelineStep(0, 600, 4)).toBe(0)
expect(getActiveTimelineStep(200, 600, 4)).toBe(1)
expect(getActiveTimelineStep(400, 600, 4)).toBe(2)
expect(getActiveTimelineStep(600, 600, 4)).toBe(3)
expect(getTimelineStepScrollLeft(2, 600, 4)).toBe(400)
expect(getTimelineStepScrollLeft(99, 600, 4)).toBe(600)
})
it('updates progress from native track scroll state', () => {
render(<WegZurAuszeichnungSection />)
const track = screen.getByTestId('timeline-track')
Object.defineProperties(track, {
clientWidth: { configurable: true, value: 400 },
scrollLeft: { configurable: true, value: 400, writable: true },
scrollWidth: { configurable: true, value: 1000 },
})
fireEvent.scroll(track)
expect(screen.getByTestId('timeline-progress-label').textContent).toContain('03 / 04')
expect(screen.getByTestId('timeline-progress').style.width).toBe('75%')
expect(screen.getAllByTestId(/timeline-step-/)).toHaveLength(4)
})
it('uses immediate scrolling in reduced-motion mode while keeping controls operable', () => {
reduceMotion = true
render(<WegZurAuszeichnungSection />)
const track = screen.getByTestId('timeline-track')
const scrollTo = vi.fn()
Object.defineProperties(track, {
clientWidth: { configurable: true, value: 400 },
scrollTo: { configurable: true, value: scrollTo },
scrollWidth: { configurable: true, value: 1000 },
})
fireEvent.click(screen.getByRole('button', { name: 'Nächster Schritt' }))
expect(getTimelineScrollBehavior(true)).toBe('auto')
expect(scrollTo).toHaveBeenCalledWith({ behavior: 'auto', left: 200 })
expect(screen.getByTestId('timeline-progress-label').textContent).toContain('02 / 04')
})
it('supports direct keyboard navigation across the track', () => {
render(<WegZurAuszeichnungSection />)
const track = screen.getByTestId('timeline-track')
const scrollTo = vi.fn()
Object.defineProperties(track, {
clientWidth: { configurable: true, value: 400 },
scrollTo: { configurable: true, value: scrollTo },
scrollWidth: { configurable: true, value: 1000 },
})
fireEvent.keyDown(track, { key: 'End' })
expect(scrollTo).toHaveBeenCalledWith({ behavior: 'smooth', left: 600 })
expect(screen.getByTestId('timeline-progress-label').textContent).toContain('04 / 04')
})
})