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"
|
||||
|
||||
Reference in New Issue
Block a user