- {[0, 1, 2].map(i => (
+ {/* Controls and real progress state */}
+
+ {/* Native horizontal carousel: wheel/trackpad, touch, controls, and keyboard all work. */}
+
+
{steps.map((item, index) => {
const Icon = ICONS[item.icon as keyof typeof ICONS] || FileText;
return (
{/* Left accent strip with step number */}
@@ -175,24 +264,88 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
{fallbackText(item.desc, '')}
{/* Step progress indicator */}
-
+
{steps.map((_, i) => (
-
+
))}
);
})}
-
-
+
+
-
+
);
}
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')
+ })
+})