From ebecf66e6fcb8514d5206bfc80ad36f4c9939e4e Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 31 Jul 2026 15:40:21 +0200 Subject: [PATCH] fix(participation): repair process timeline --- .../components/WegZurAuszeichnungSection.tsx | 217 +++++++++++++++--- src/spa/components/ui/process-timeline.tsx | 155 +++---------- tests/e2e/frontend.e2e.spec.ts | 83 +++++++ tests/int/participation-timeline.int.spec.tsx | 91 ++++++++ 4 files changed, 393 insertions(+), 153 deletions(-) create mode 100644 tests/int/participation-timeline.int.spec.tsx diff --git a/src/spa/components/WegZurAuszeichnungSection.tsx b/src/spa/components/WegZurAuszeichnungSection.tsx index 5808815..306b8ef 100644 --- a/src/spa/components/WegZurAuszeichnungSection.tsx +++ b/src/spa/components/WegZurAuszeichnungSection.tsx @@ -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) => {i > 0 &&
}{line}
)}; } +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(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) => { + 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,31 +152,29 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce } return ( - - +
- {/* Section header */} -
+ {/* Section header */} +
{fallbackText(section.eyebrow, participationContent.process.eyebrow)} -

+

{fallbackText(section.description, participationContent.process.description)}

-
+
- {/* Scroll hint – prominent */} -
- {/* Animated chevron stack */} -
- {[0, 1, 2].map(i => ( + {/* Controls and real progress state */} +
+
); } diff --git a/src/spa/components/ui/process-timeline.tsx b/src/spa/components/ui/process-timeline.tsx index f8637ca..b145e11 100644 --- a/src/spa/components/ui/process-timeline.tsx +++ b/src/spa/components/ui/process-timeline.tsx @@ -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 +export function getTimelineScrollBehavior(prefersReducedMotion: boolean): ScrollBehavior { + return prefersReducedMotion ? "auto" : "smooth" } interface ProcessCardProps - extends HTMLMotionProps<"div">, - VariantProps { - itemsLength: number - index: number -} + extends React.HTMLAttributes, + VariantProps {} -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) => { - const scrollRef = React.useRef(null) - const { scrollYProgress } = useScroll({ - target: scrollRef, - offset: ["start start", "end end"], - }) - return ( - -
- {children} -
-
- ) -} - -export const ContainerSticky = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) -ContainerSticky.displayName = "ContainerSticky" +export const ProcessCard = React.forwardRef( + ({ className, variant, ...props }, ref) => ( +
  • + ), +) +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 = ({ - 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 ( - 0 ? x : 0, - ...style, - }} - className={cn(processCardVariants({ variant, size }), className)} - {...props} - /> - ) -} -ProcessCard.displayName = "ProcessCard" diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index d636b34..1d2cc8a 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -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') + }) +}) diff --git a/tests/int/participation-timeline.int.spec.tsx b/tests/int/participation-timeline.int.spec.tsx new file mode 100644 index 0000000..e1fba50 --- /dev/null +++ b/tests/int/participation-timeline.int.spec.tsx @@ -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() + 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() + 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() + 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') + }) +})