fix(participation): repair process timeline
This commit is contained in:
@@ -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,31 +152,29 @@ 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 }}>
|
||||
{/* Section header */}
|
||||
<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>
|
||||
<p className="type-small text-role-inverse-secondary" style={{ margin: 0 }}>
|
||||
{fallbackText(section.description, participationContent.process.description)}
|
||||
</p>
|
||||
</div>
|
||||
</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 style={{ fontFamily: FF, fontSize: 9, color: 'rgba(255,255,255,0.50)', letterSpacing: '0.1em' }}>{fallbackText(section.progressLabel, participationContent.process.progressLabel)}</span>
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ContainerSticky>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
ref={ref}
|
||||
className={cn("sticky left-0 top-0 w-full overflow-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContainerSticky.displayName = "ContainerSticky"
|
||||
export const ProcessCard = React.forwardRef<HTMLLIElement, ProcessCardProps>(
|
||||
({ className, variant, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn(processCardVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
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"
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
91
tests/int/participation-timeline.int.spec.tsx
Normal file
91
tests/int/participation-timeline.int.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user