diff --git a/src/components/common/CreatorOnboardingForm.tsx b/src/components/common/CreatorOnboardingForm.tsx index a7cb78f..41891af 100644 --- a/src/components/common/CreatorOnboardingForm.tsx +++ b/src/components/common/CreatorOnboardingForm.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { FormInput } from './FormInput'; +import Stepper from './Stepper'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -16,6 +17,25 @@ export interface CreatorOnboardingFormProps { className?: string; } +const ONBOARDING_STEPS: Array<{ + field: keyof CreatorOnboardingFormData; + label: string; +}> = [ + { field: 'name', label: 'Creator name' }, + { field: 'email', label: 'Email' }, + { field: 'bio', label: 'Bio' }, + { field: 'category', label: 'Category' }, +]; + +const getStartingStep = (data: CreatorOnboardingFormData) => { + const firstIncompleteIndex = ONBOARDING_STEPS.findIndex( + step => !data[step.field].trim() + ); + return firstIncompleteIndex === -1 + ? ONBOARDING_STEPS.length + : firstIncompleteIndex + 1; +}; + export const CreatorOnboardingForm: React.FC< CreatorOnboardingFormProps > = ({ onSubmit, initialData, className }) => { @@ -28,6 +48,7 @@ export const CreatorOnboardingForm: React.FC< const [isDirty, setIsDirty] = useState(false); const [touched, setTouched] = useState>({}); + const [currentStep, setCurrentStep] = useState(() => getStartingStep(formData)); const initialDataRef = React.useRef(formData); @@ -40,6 +61,7 @@ export const CreatorOnboardingForm: React.FC< }; setFormData(initialDataRef.current); setIsDirty(false); + setCurrentStep(getStartingStep(initialDataRef.current)); }, [initialData]); useEffect(() => { @@ -62,6 +84,10 @@ export const CreatorOnboardingForm: React.FC< const handleChange = (field: keyof CreatorOnboardingFormData, value: string) => { setFormData(prev => ({ ...prev, [field]: value })); setTouched(prev => ({ ...prev, [field]: true })); + const stepIndex = ONBOARDING_STEPS.findIndex(step => step.field === field); + if (stepIndex >= 0) { + setCurrentStep(stepIndex + 1); + } }; const handleSubmit = (e: React.FormEvent) => { @@ -78,34 +104,49 @@ export const CreatorOnboardingForm: React.FC< setFormData(initialDataRef.current); setTouched({}); setIsDirty(false); + setCurrentStep(getStartingStep(initialDataRef.current)); }; return (
+ step.label)} + clickableSteps={false} + ariaLabel="Creator onboarding progress" + /> + handleChange('name', value)} + onFocus={() => setCurrentStep(1)} placeholder="Your creator name" required touched={touched.name} /> handleChange('email', value)} + onFocus={() => setCurrentStep(2)} placeholder="your@email.com" required touched={touched.email} /> handleChange('bio', value)} + onFocus={() => setCurrentStep(3)} placeholder="Tell us about yourself..." touched={touched.bio} rows={4} @@ -114,9 +155,11 @@ export const CreatorOnboardingForm: React.FC< /> handleChange('category', value)} + onFocus={() => setCurrentStep(4)} placeholder="e.g., Art, Music, Tech" touched={touched.category} /> diff --git a/src/components/common/FormInput.tsx b/src/components/common/FormInput.tsx index 2c4826c..afe01d3 100644 --- a/src/components/common/FormInput.tsx +++ b/src/components/common/FormInput.tsx @@ -17,6 +17,7 @@ interface FormInputProps { maxLength?: number; autoComplete?: string; id?: string; + onFocus?: () => void; // Prefix and suffix elements prefix?: React.ReactNode; suffix?: React.ReactNode; @@ -40,6 +41,7 @@ export const FormInput: React.FC = ({ maxLength, autoComplete, id, + onFocus, prefix, suffix, wrapperClassName = '', @@ -151,6 +153,7 @@ export const FormInput: React.FC = ({ disabled, maxLength, autoComplete, + onFocus, className: cn( 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-green-400/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', diff --git a/src/components/common/Stepper.tsx b/src/components/common/Stepper.tsx index 4014355..2e017f4 100644 --- a/src/components/common/Stepper.tsx +++ b/src/components/common/Stepper.tsx @@ -4,23 +4,27 @@ import { cn } from '@/lib/utils'; interface StepperProps { currentStep: number; totalSteps: number; + steps?: string[]; onStepClick?: (step: number) => void; disabledSteps?: number[]; className?: string; size?: 'sm' | 'md' | 'lg'; variant?: 'default' | 'rounded' | 'pills'; clickableSteps?: boolean; + ariaLabel?: string; } const Stepper: React.FC = ({ currentStep, totalSteps, + steps, onStepClick, disabledSteps = [], className = '', size = 'md', variant = 'pills', clickableSteps = true, + ariaLabel = 'Step progress', }) => { const sizeClasses = { sm: 'h-1', @@ -56,48 +60,75 @@ const Stepper: React.FC = ({ } }; + const safeCurrentStep = Math.min(Math.max(currentStep, 1), totalSteps); + return ( -
+
+ return ( +
  • + {isClickable ? ( +
  • + ); + })} + + ); }; + export default Stepper; diff --git a/src/components/common/__tests__/CreatorOnboardingForm.test.tsx b/src/components/common/__tests__/CreatorOnboardingForm.test.tsx new file mode 100644 index 0000000..e43f1ca --- /dev/null +++ b/src/components/common/__tests__/CreatorOnboardingForm.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import CreatorOnboardingForm from '@/components/common/CreatorOnboardingForm'; + +describe('CreatorOnboardingForm', () => { + it('announces the current onboarding step and updates it as fields receive focus', () => { + render(); + + const progress = screen.getByRole('navigation', { + name: /creator onboarding progress: step 1 of 4/i, + }); + + expect( + within(progress).getByRole('listitem', { + name: /creator name, step 1 of 4, current/i, + }) + ).toHaveAttribute('aria-current', 'step'); + expect( + within(progress).getByRole('listitem', { + name: /email, step 2 of 4, upcoming/i, + }) + ).toBeInTheDocument(); + + fireEvent.focus(screen.getByRole('textbox', { name: /email/i })); + + expect( + screen.getByRole('navigation', { + name: /creator onboarding progress: step 2 of 4/i, + }) + ).toBeInTheDocument(); + expect( + within(progress).getByRole('listitem', { + name: /creator name, step 1 of 4, completed/i, + }) + ).toBeInTheDocument(); + expect( + within(progress).getByRole('listitem', { + name: /email, step 2 of 4, current/i, + }) + ).toHaveAttribute('aria-current', 'step'); + }); +});