import React, { useState, useEffect, useId, useCallback, KeyboardEvent } from 'react'; import { motion, MotionConfig, SpringOptions } from 'framer-motion'; // ===== TYP-DEFINITIONEN ===== export interface Step { id: string; title: string; subtitle?: string; optional?: boolean; } export interface StepSetupProps { /** Array der Schritte mit ID, Titel und optionalen Eigenschaften */ steps: Step[]; /** Kontrollierter aktueller Schritt-Index */ current?: number; /** Unkontrollierter Standard-Schritt-Index */ defaultCurrent?: number; /** Callback bei Schrittänderung */ onChange?: (index: number) => void; /** Ausrichtung des Steppers */ orientation?: 'horizontal' | 'vertical'; /** Ob Steps anklickbar sind */ clickable?: boolean; /** Größe der Step-Komponente */ size?: 'sm' | 'md' | 'lg'; /** Animation aktivieren/deaktivieren */ animated?: boolean; /** Zusätzliche CSS-Klassen */ className?: string; } export interface StepState { currentStep: number; isControlled: boolean; } // ===== HOOK FÜR ZUSTANDSVERWALTUNG ===== export const useStepSetup = (props: StepSetupProps) => { const { steps, current, defaultCurrent = 0, onChange, clickable = true } = props; const [internalStep, setInternalStep] = useState(defaultCurrent); const isControlled = current !== undefined; const currentStep = isControlled ? current : internalStep; // Clamp den Schritt-Index auf gültigen Bereich const clampedStep = Math.max(0, Math.min(currentStep, steps.length - 1)); const setStep = useCallback((newStep: number) => { const clampedNewStep = Math.max(0, Math.min(newStep, steps.length - 1)); if (!isControlled) { setInternalStep(clampedNewStep); } onChange?.(clampedNewStep); }, [isControlled, onChange, steps.length]); const goToNext = useCallback(() => { if (clampedStep < steps.length - 1) { setStep(clampedStep + 1); } }, [clampedStep, steps.length, setStep]); const goToPrev = useCallback(() => { if (clampedStep > 0) { setStep(clampedStep - 1); } }, [clampedStep, setStep]); const goToStep = useCallback((index: number) => { if (clickable) { setStep(index); } }, [clickable, setStep]); // Warnung bei Duplicate IDs (nur in Development) useEffect(() => { if (process.env.NODE_ENV !== 'production') { const stepIds = steps.map(step => step.id); const duplicateIds = stepIds.filter((id, index) => stepIds.indexOf(id) !== index); if (duplicateIds.length > 0) { console.warn( `StepSetup: Duplicate step IDs found: ${duplicateIds.join(', ')}. ` + `Step IDs should be unique.` ); } } }, [steps]); return { currentStep: clampedStep, setStep, goToNext, goToPrev, goToStep, isControlled }; }; // ===== ANIMATIONS-KONFIGURATION ===== const getAnimationConfig = (animated: boolean): { reduced: { duration: number }; normal: SpringOptions } => ({ reduced: { duration: 0 }, // Keine Animation normal: { stiffness: 500, damping: 30, mass: 0.5 } }); // ===== HILFSFUNKTIONEN ===== /** * Berechnet die Step-Größenklassen basierend auf der size-Prop */ const getSizeClasses = (size: StepSetupProps['size'] = 'md') => { const sizes = { sm: { step: 'w-8 h-8 text-sm', icon: 'w-4 h-4', title: 'text-sm', subtitle: 'text-xs' }, md: { step: 'w-10 h-10 text-base', icon: 'w-5 h-5', title: 'text-base', subtitle: 'text-sm' }, lg: { step: 'w-12 h-12 text-lg', icon: 'w-6 h-6', title: 'text-lg', subtitle: 'text-base' } }; return sizes[size]; }; /** * Prüft ob prefers-reduced-motion aktiv ist */ const prefersReducedMotion = (): boolean => { if (typeof window === 'undefined') return false; return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }; // ===== STEP ICON KOMPONENTE ===== interface StepIconProps { stepIndex: number; currentStep: number; size: StepSetupProps['size']; animated: boolean; } const StepNumber: React.FC<{ number: number; isCurrent?: boolean; isCompleted?: boolean }> = ({ number, isCurrent = false, isCompleted = false }) => { const baseClasses = ` w-6 h-6 flex items-center justify-center rounded-full text-xs font-medium transition-all duration-200 `; if (isCompleted) { return (
); } if (isCurrent) { return (
{number}
); } return (
{number}
); }; const StepIcon: React.FC = ({ stepIndex, currentStep, size, animated }) => { const isCompleted = stepIndex < currentStep; const isCurrent = stepIndex === currentStep; const sizeClasses = getSizeClasses(size); const animationConfig = getAnimationConfig(animated); const shouldAnimate = animated && !prefersReducedMotion(); const baseClasses = ` ${sizeClasses.step} flex items-center justify-center rounded-full border-2 font-medium transition-all duration-200 `; if (isCompleted) { const completedClasses = ` ${baseClasses} border-blue-500 bg-white `; const iconContent = shouldAnimate ? ( ) : ( ); return (
{iconContent}
); } if (isCurrent) { const currentClasses = ` ${baseClasses} border-blue-500 bg-white `; const stepNumber = shouldAnimate ? ( ) : ( ); return
{stepNumber}
; } const upcomingClasses = ` ${baseClasses} border-gray-300 bg-white `; return (
); }; // ===== HAUPTKOMPONENTE ===== /** * Eine zugängliche, animierte Step/Progress-Komponente mit Unterstützung für * kontrollierte und unkontrollierte Verwendung. * * @example * ```tsx * console.log('Step changed:', index)} * /> * ``` */ const StepSetup: React.FC = (props) => { const { steps, orientation = 'horizontal', clickable = true, size = 'md', animated = true, className = '' } = props; const { currentStep, goToStep } = useStepSetup(props); const listId = useId(); const shouldAnimate = animated && !prefersReducedMotion(); const sizeClasses = getSizeClasses(size); // Fallback für leere Steps if (!steps || steps.length === 0) { return (
No steps available
); } // Tastatur-Navigation Handler const handleKeyDown = ( event: KeyboardEvent, stepIndex: number ) => { if (!clickable) return; const isHorizontal = orientation === 'horizontal'; switch (event.key) { case 'Enter': case ' ': event.preventDefault(); goToStep(stepIndex); break; case 'ArrowRight': case 'ArrowDown': if ((isHorizontal && event.key === 'ArrowRight') || (!isHorizontal && event.key === 'ArrowDown')) { event.preventDefault(); const nextStep = Math.min(stepIndex + 1, steps.length - 1); goToStep(nextStep); } break; case 'ArrowLeft': case 'ArrowUp': if ((isHorizontal && event.key === 'ArrowLeft') || (!isHorizontal && event.key === 'ArrowUp')) { event.preventDefault(); const prevStep = Math.max(stepIndex - 1, 0); goToStep(prevStep); } break; case 'Home': event.preventDefault(); goToStep(0); break; case 'End': event.preventDefault(); goToStep(steps.length - 1); break; } }; // Container-Klassen basierend auf Ausrichtung const containerClasses = ` flex ${orientation === 'vertical' ? 'flex-col' : 'flex-row'} ${orientation === 'horizontal' ? 'items-center justify-center gap-8' : 'gap-6'} ${className} `; const StepContent = shouldAnimate ? motion.div : 'div'; return ( ); }; export default StepSetup;