fix(participation): repair process timeline
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FileText, Search, UserPlus, Trophy } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, FileText, Search, UserPlus, Trophy } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
ContainerScroll,
|
getActiveTimelineStep,
|
||||||
ContainerSticky,
|
getTimelineScrollBehavior,
|
||||||
|
getTimelineStepScrollLeft,
|
||||||
ProcessCard,
|
ProcessCard,
|
||||||
ProcessCardBody,
|
ProcessCardBody,
|
||||||
ProcessCardTitle,
|
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>)}</>;
|
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 }) {
|
export default function WegZurAuszeichnungSection({ content }: { content?: ProcessContent }) {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const prefersReducedMotion = usePrefersReducedMotion();
|
||||||
|
const trackRef = React.useRef<HTMLOListElement>(null);
|
||||||
|
const [activeStep, setActiveStep] = React.useState(0);
|
||||||
const section = { ...participationContent.process, ...(content || {}) };
|
const section = { ...participationContent.process, ...(content || {}) };
|
||||||
const steps = fallbackArray(section.steps, participationContent.process.steps);
|
const steps = fallbackArray(section.steps, participationContent.process.steps);
|
||||||
const stepPrefix = fallbackText(section.stepPrefix, participationContent.process.stepPrefix);
|
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) ──
|
// ── MOBILE: stacked vertical list (no scroll-jack, no horizontal overflow) ──
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
@@ -94,31 +152,29 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContainerScroll id="schritte" className="h-[300vh]" style={{ background: NAVY }}>
|
<section id="schritte" aria-labelledby="process-heading" style={{ background: NAVY }}>
|
||||||
<ContainerSticky className="top-0 h-screen flex flex-col">
|
|
||||||
|
|
||||||
{/* Section header */}
|
{/* 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>
|
<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>
|
<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)} />
|
<Lines text={fallbackText(section.heading, participationContent.process.heading)} />
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="type-small text-role-inverse-secondary" style={{ margin: 0 }}>
|
<p className="type-small text-role-inverse-secondary" style={{ margin: 0 }}>
|
||||||
{fallbackText(section.description, participationContent.process.description)}
|
{fallbackText(section.description, participationContent.process.description)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll hint – prominent */}
|
{/* Controls and real progress state */}
|
||||||
<div style={{ padding: '20px 80px', display: 'flex', alignItems: 'center', gap: 14, flexShrink: 0 }}>
|
<div className="process-timeline-controls" style={{ padding: '20px 80px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
{/* Animated chevron stack */}
|
<div aria-hidden="true" style={{ display: 'flex', gap: 2 }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
{[0, 1, 2].map((i) => (
|
||||||
{[0, 1, 2].map(i => (
|
|
||||||
<svg
|
<svg
|
||||||
key={i}
|
key={i}
|
||||||
width="12" height="7" viewBox="0 0 12 7" fill="none"
|
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" />
|
<polyline points="1,1 6,6 11,1" stroke={GOLD} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</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)' }}>
|
<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)}
|
{fallbackText(section.scrollHint, participationContent.process.scrollHint)}
|
||||||
</span>
|
</span>
|
||||||
{/* Progress bar */}
|
|
||||||
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,0.07)', position: 'relative', overflow: 'hidden', maxWidth: 200 }}>
|
<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>
|
</div>
|
||||||
<span style={{ fontFamily: FF, fontSize: 9, color: 'rgba(255,255,255,0.50)', letterSpacing: '0.1em' }}>{fallbackText(section.progressLabel, participationContent.process.progressLabel)}</span>
|
<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 }}>
|
||||||
</div>
|
{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 */}
|
{/* Native horizontal carousel: wheel/trackpad, touch, controls, and keyboard all work. */}
|
||||||
<div className="flex flex-nowrap flex-1 items-stretch px-20 pb-16 gap-0">
|
<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) => {
|
{steps.map((item, index) => {
|
||||||
const Icon = ICONS[item.icon as keyof typeof ICONS] || FileText;
|
const Icon = ICONS[item.icon as keyof typeof ICONS] || FileText;
|
||||||
return (
|
return (
|
||||||
<ProcessCard
|
<ProcessCard
|
||||||
key={`${item.step}-${index}`}
|
key={`${item.step}-${index}`}
|
||||||
itemsLength={steps.length}
|
|
||||||
index={index}
|
|
||||||
variant="bmp"
|
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 */}
|
{/* 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]">
|
<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, '')}
|
{fallbackText(item.desc, '')}
|
||||||
</p>
|
</p>
|
||||||
{/* Step progress indicator */}
|
{/* Step progress indicator */}
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div aria-hidden="true" style={{ display: 'flex', gap: 4 }}>
|
||||||
{steps.map((_, i) => (
|
{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>
|
</div>
|
||||||
</ProcessCardBody>
|
</ProcessCardBody>
|
||||||
</ProcessCard>
|
</ProcessCard>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</ol>
|
||||||
</ContainerSticky>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes scrollBounce {
|
.process-timeline-card-shell {
|
||||||
0%, 100% { transform: translateY(0); opacity: inherit; }
|
padding: 0 80px 52px;
|
||||||
50% { transform: translateY(3px); }
|
}
|
||||||
|
.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>
|
`}</style>
|
||||||
</ContainerScroll>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,117 +2,64 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { useMeasure } from "@uidotdev/usehooks"
|
|
||||||
import { VariantProps, cva } from "class-variance-authority"
|
import { VariantProps, cva } from "class-variance-authority"
|
||||||
import {
|
|
||||||
HTMLMotionProps,
|
|
||||||
MotionValue,
|
|
||||||
motion,
|
|
||||||
useScroll,
|
|
||||||
useTransform,
|
|
||||||
} from "motion/react"
|
|
||||||
|
|
||||||
import { cn } from "@/spa/lib/utils"
|
import { cn } from "@/spa/lib/utils"
|
||||||
|
|
||||||
const processCardVariants = cva("flex border backdrop-blur-lg", {
|
const processCardVariants = cva("flex border", {
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
indigo:
|
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",
|
light: "shadow",
|
||||||
bmp: "flex border text-white border-[rgba(239,191,4,0.25)] bg-[#111D55]",
|
bmp: "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",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "bmp",
|
variant: "bmp",
|
||||||
size: "md",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const subscribeToViewportWidth = (onStoreChange: () => void) => {
|
export function getActiveTimelineStep(
|
||||||
window.addEventListener("resize", onStoreChange)
|
scrollLeft: number,
|
||||||
return () => window.removeEventListener("resize", onStoreChange)
|
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
|
const safeIndex = Math.min(stepsLength - 1, Math.max(0, index))
|
||||||
|
return (safeIndex / (stepsLength - 1)) * maxScroll
|
||||||
function useViewportWidth() {
|
|
||||||
return React.useSyncExternalStore(
|
|
||||||
subscribeToViewportWidth,
|
|
||||||
getViewportWidth,
|
|
||||||
getServerViewportWidth
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContainerScrollContextValue {
|
export function getTimelineScrollBehavior(prefersReducedMotion: boolean): ScrollBehavior {
|
||||||
scrollYProgress: MotionValue<number>
|
return prefersReducedMotion ? "auto" : "smooth"
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProcessCardProps
|
interface ProcessCardProps
|
||||||
extends HTMLMotionProps<"div">,
|
extends React.HTMLAttributes<HTMLLIElement>,
|
||||||
VariantProps<typeof processCardVariants> {
|
VariantProps<typeof processCardVariants> {}
|
||||||
itemsLength: number
|
|
||||||
index: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContainerScrollContext = React.createContext<
|
export const ProcessCard = React.forwardRef<HTMLLIElement, ProcessCardProps>(
|
||||||
ContainerScrollContextValue | undefined
|
({ className, variant, ...props }, ref) => (
|
||||||
>(undefined)
|
<li
|
||||||
|
ref={ref}
|
||||||
function useContainerScrollContext() {
|
className={cn(processCardVariants({ variant }), className)}
|
||||||
const context = React.useContext(ContainerScrollContext)
|
{...props}
|
||||||
if (!context) {
|
/>
|
||||||
throw new Error(
|
),
|
||||||
"useContainerScrollContext must be used within a ContainerScroll Component"
|
)
|
||||||
)
|
ProcessCard.displayName = "ProcessCard"
|
||||||
}
|
|
||||||
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 ProcessCardTitle = React.forwardRef<
|
export const ProcessCardTitle = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@@ -133,37 +80,3 @@ export const ProcessCardBody = React.forwardRef<
|
|||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
ProcessCardBody.displayName = "ProcessCardBody"
|
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')
|
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