Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/components/common/CreatorOnboardingForm.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 }) => {
Expand All @@ -28,6 +48,7 @@ export const CreatorOnboardingForm: React.FC<

const [isDirty, setIsDirty] = useState(false);
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [currentStep, setCurrentStep] = useState(() => getStartingStep(formData));

const initialDataRef = React.useRef(formData);

Expand All @@ -40,6 +61,7 @@ export const CreatorOnboardingForm: React.FC<
};
setFormData(initialDataRef.current);
setIsDirty(false);
setCurrentStep(getStartingStep(initialDataRef.current));
}, [initialData]);

useEffect(() => {
Expand All @@ -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) => {
Expand All @@ -78,34 +104,49 @@ export const CreatorOnboardingForm: React.FC<
setFormData(initialDataRef.current);
setTouched({});
setIsDirty(false);
setCurrentStep(getStartingStep(initialDataRef.current));
};

return (
<form onSubmit={handleSubmit} className={cn('space-y-6', className)}>
<Stepper
currentStep={currentStep}
totalSteps={ONBOARDING_STEPS.length}
steps={ONBOARDING_STEPS.map(step => step.label)}
clickableSteps={false}
ariaLabel="Creator onboarding progress"
/>

<FormInput
id="creator-onboarding-name"
label="Creator Name"
value={formData.name}
onChange={value => handleChange('name', value)}
onFocus={() => setCurrentStep(1)}
placeholder="Your creator name"
required
touched={touched.name}
/>

<FormInput
id="creator-onboarding-email"
label="Email"
type="email"
value={formData.email}
onChange={value => handleChange('email', value)}
onFocus={() => setCurrentStep(2)}
placeholder="your@email.com"
required
touched={touched.email}
/>

<FormInput
id="creator-onboarding-bio"
label="Bio"
type="textarea"
value={formData.bio}
onChange={value => handleChange('bio', value)}
onFocus={() => setCurrentStep(3)}
placeholder="Tell us about yourself..."
touched={touched.bio}
rows={4}
Expand All @@ -114,9 +155,11 @@ export const CreatorOnboardingForm: React.FC<
/>

<FormInput
id="creator-onboarding-category"
label="Category"
value={formData.category}
onChange={value => handleChange('category', value)}
onFocus={() => setCurrentStep(4)}
placeholder="e.g., Art, Music, Tech"
touched={touched.category}
/>
Expand Down
3 changes: 3 additions & 0 deletions src/components/common/FormInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface FormInputProps {
maxLength?: number;
autoComplete?: string;
id?: string;
onFocus?: () => void;
// Prefix and suffix elements
prefix?: React.ReactNode;
suffix?: React.ReactNode;
Expand All @@ -40,6 +41,7 @@ export const FormInput: React.FC<FormInputProps> = ({
maxLength,
autoComplete,
id,
onFocus,
prefix,
suffix,
wrapperClassName = '',
Expand Down Expand Up @@ -151,6 +153,7 @@ export const FormInput: React.FC<FormInputProps> = ({
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',

Expand Down
107 changes: 69 additions & 38 deletions src/components/common/Stepper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<StepperProps> = ({
currentStep,
totalSteps,
steps,
onStepClick,
disabledSteps = [],
className = '',
size = 'md',
variant = 'pills',
clickableSteps = true,
ariaLabel = 'Step progress',
}) => {
const sizeClasses = {
sm: 'h-1',
Expand Down Expand Up @@ -56,48 +60,75 @@ const Stepper: React.FC<StepperProps> = ({
}
};

const safeCurrentStep = Math.min(Math.max(currentStep, 1), totalSteps);

return (
<div className={cn('flex items-center', gapClasses[size], className)}>
<nav
className={cn('flex flex-col gap-2', className)}
aria-label={`${ariaLabel}: step ${safeCurrentStep} of ${totalSteps}`}
>
<div className="sr-only" aria-live="polite" aria-atomic="true">
Step {currentStep} of {totalSteps}
Step {safeCurrentStep} of {totalSteps}
</div>
{Array.from({ length: totalSteps }, (_, index) => {
const stepNumber = index + 1;
const isActive = stepNumber <= currentStep;
const isDisabled = disabledSteps.includes(stepNumber);
const isClickable = clickableSteps && onStepClick && !isDisabled;

const StepElement = isClickable ? 'button' : 'div';
<ol className={cn('flex items-center', gapClasses[size])}>
{Array.from({ length: totalSteps }, (_, index) => {
const stepNumber = index + 1;
const isCompleted = stepNumber < safeCurrentStep;
const isCurrent = stepNumber === safeCurrentStep;
const isDisabled = disabledSteps.includes(stepNumber);
const isClickable = clickableSteps && onStepClick && !isDisabled;
const label = steps?.[index] ?? `Step ${stepNumber}`;
const status = isCompleted
? 'completed'
: isCurrent
? 'current'
: 'upcoming';
const stepClassName = cn(
'block transition-all duration-300',
sizeClasses[size],
steps ? 'w-full' : widthClasses[size],
radiusClasses[variant],
isCompleted && 'bg-blue-600',
isCurrent && 'bg-blue-600 ring-2 ring-blue-200 ring-offset-2',
status === 'upcoming' && 'bg-gray-300',
isClickable && [
'cursor-pointer hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
status === 'upcoming' && 'hover:bg-gray-400',
],
isDisabled && 'cursor-not-allowed opacity-50'
);
const stepAriaLabel = `${label}, step ${stepNumber} of ${totalSteps}, ${status}${
isClickable ? ', click to navigate' : ''
}`;

return (
<StepElement
key={stepNumber}
onClick={() => handleStepClick(stepNumber)}
disabled={isDisabled}
className={cn(
'transition-all duration-300',
sizeClasses[size],
widthClasses[size],
radiusClasses[variant],
isActive ? 'bg-blue-600' : 'bg-gray-300',
isClickable && [
'cursor-pointer hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
!isActive && 'hover:bg-gray-400',
],
isDisabled && 'cursor-not-allowed opacity-50'
)}
role="progressbar"
aria-valuenow={currentStep}
aria-valuemin={1}
aria-valuemax={totalSteps}
aria-label={`Step ${stepNumber} of ${totalSteps}${
isClickable ? ', click to navigate' : ''
}`}
title={isClickable ? `Go to step ${stepNumber}` : undefined}
/>
);
})}
</div>
return (
<li
key={stepNumber}
className="flex-1"
aria-current={!isClickable && isCurrent ? 'step' : undefined}
aria-label={!isClickable ? stepAriaLabel : undefined}
>
{isClickable ? (
<button
type="button"
onClick={() => handleStepClick(stepNumber)}
className={stepClassName}
aria-current={isCurrent ? 'step' : undefined}
aria-label={stepAriaLabel}
title={`Go to step ${stepNumber}`}
/>
) : (
<div
className={stepClassName}
aria-hidden="true"
/>
)}
</li>
);
})}
</ol>
</nav>
);
};

export default Stepper;
43 changes: 43 additions & 0 deletions src/components/common/__tests__/CreatorOnboardingForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CreatorOnboardingForm />);

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');
});
});
Loading