This commit is contained in:
Philippe Torrel
2026-08-24 11:56:41 +02:00
parent a5e74fd729
commit 821c839574
71 changed files with 19772 additions and 248 deletions

View File

@@ -0,0 +1,12 @@
import React from 'react';
import MultiStepForm from './MultiStepForm';
function App() {
return (
<div className="App">
<MultiStepForm />
</div>
);
}
export default App;

View File

@@ -0,0 +1,125 @@
import { useState } from 'react';
import ProgressIndicator from './components/ProgressIndicator';
import NavigationButtons from './components/NavigationButtons';
import AccountInfo from './steps/AccountInfo';
import ActivityInfo from './steps/ActivityInfo';
import GoalSetting from './steps/GoalSetting';
import { validate } from './utils/validation';
function MultiStepForm() {
const [stepIndex, setStepIndex] = useState(1);
const [errors, setErrors] = useState({});
const [formData, setFormData] = useState({
fullName: '',
email: '',
password: '',
activityLevel: 'never',
energyLevel: 'low',
goals: [],
timeCommitment: '15',
});
const formSteps = [
{ id: 1, name: 'Account Info', component: AccountInfo },
{ id: 2, name: 'Activity Info', component: ActivityInfo },
{ id: 3, name: 'Goal Setting', component: GoalSetting },
];
// ===== Event Handlers =====
const handleChange = (e) => {
const { name, value } = e.target;
setFormData({ ...formData, [name]: value });
};
const handleCheckboxChange = (field, id, isChecked) => {
setFormData((prev) => ({
...prev,
[field]: isChecked
? [...prev[field], id]
: prev[field].filter((item) => item !== id),
}));
};
// ===== Form Navigation =====
const handleNext = () => {
if (handleValidation()) {
setStepIndex(stepIndex + 1);
}
};
const handlePrev = () => {
setErrors({});
setStepIndex(stepIndex - 1);
};
// ===== Form Validation & Submission =====
const handleValidation = () => {
const validationErrors = validate(formData, stepIndex);
setErrors(validationErrors);
return Object.keys(validationErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (handleValidation()) {
alert('Form submitted successfully!');
console.log('Submitted Data:', formData);
}
};
return (
<div className="min-h-screen bg-gray-100">
<div className="pt-32 mx-4">
<div className="overflow-hidden rounded-lg bg-white shadow max-w-[600px] mx-auto">
<div className="px-4 py-5 sm:p-6">
<h1 className="text-2xl font-bold text-center my-6">
Let's get you started!
</h1>
<ProgressIndicator steps={formSteps} currentStep={stepIndex} />
<form onSubmit={handleSubmit} className="relative mt-10">
{formSteps.map((s) => {
const StepComponent = s.component;
return (
<div
key={s.id}
className={`transition-opacity duration-500 ${
stepIndex === s.id
? 'opacity-100'
: 'opacity-0 absolute w-full'
}`}
aria-hidden={stepIndex !== s.id}
>
{stepIndex === s.id && (
<StepComponent
formData={formData}
handleChange={handleChange}
handleCheckboxChange={handleCheckboxChange}
errors={errors}
/>
)}
</div>
);
})}
{/* Navigation Buttons */}
<NavigationButtons
currentStep={stepIndex}
totalSteps={formSteps.length}
handlePrev={handlePrev}
handleNext={handleNext}
handleSubmit={handleSubmit}
/>
</form>
</div>
</div>
</div>
</div>
);
}
export default MultiStepForm;

View File

@@ -0,0 +1,44 @@
import React from 'react';
const FormCheckboxes = ({ legend, required, options, onChange, error }) => {
return (
<div>
<label className="block text-sm/6 font-medium text-gray-900">
{legend} {required && <span className="text-red-500">*</span>}
</label>
<fieldset className="mt-4">
<legend className="sr-only">{legend}</legend>
<div className="space-y-5">
{options.map((option) => (
<div key={option.id} className="relative flex items-start">
<div className="flex h-6 items-center">
<input
id={option.id}
name={option.name}
type="checkbox"
aria-describedby={`${option.id}-description`}
className="size-4 rounded border-gray-300 text-teal-600 focus:ring-teal-600"
onChange={(e) => onChange(option.id, e.target.checked)}
checked={option.checked}
/>
</div>
<div className="ml-3 text-sm">
<label
htmlFor={option.id}
className="font-medium text-gray-900"
>
{option.label}
</label>
</div>
</div>
))}
</div>
</fieldset>
<p className="mt-2 text-sm text-red-600">{error}</p>
</div>
);
};
export default FormCheckboxes;

View File

@@ -0,0 +1,47 @@
import React from 'react';
const FormInput = ({
htmlFor,
label,
id,
name,
value,
onChange,
error,
placeholder,
required = false,
type = 'text',
}) => {
return (
<div>
<label
htmlFor={htmlFor}
className="block text-sm/6 font-medium text-gray-900"
>
{label} {required && <span className="text-red-500">*</span>}
</label>
<div className="mt-2">
<input
type={type}
id={id}
name={name}
value={value}
onChange={onChange}
className={`block w-full rounded-md border-0 py-1.5 px-2.5 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm/6 ${
error
? 'text-red-900 ring-red-400 placeholder:text-red-300 focus:ring-red-500'
: 'text-gray-900 ring-gray-300 placeholder:text-gray-400 focus:ring-teal-600'
}`}
placeholder={placeholder}
aria-required={required}
aria-invalid={error ? 'true' : 'false'}
/>
</div>
<p className="mt-2 text-sm text-red-600">{error}</p>
</div>
);
};
export default FormInput;

View File

@@ -0,0 +1,44 @@
import React from 'react';
const FormSelect = ({
id,
name,
options,
defaultValue,
onChange,
label,
required = false,
className = '',
labelClassName = '',
error,
}) => {
return (
<div>
{label && (
<label
htmlFor={id}
className={`block text-sm font-medium text-gray-900 ${labelClassName}`}
>
{label} {required && <span className="text-red-500">*</span>}
</label>
)}
<select
id={id}
name={name}
defaultValue={defaultValue}
onChange={onChange}
className={`mt-2 block w-full rounded-md border-0 py-2.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-teal-600 sm:text-sm ${className}`}
>
{options.map((option, index) => (
<option key={index} value={option.value}>
{option.label}
</option>
))}
</select>
<p className="mt-2 text-sm text-red-600">{error}</p>
</div>
);
};
export default FormSelect;

View File

@@ -0,0 +1,43 @@
import React from 'react';
function NavigationButtons({
currentStep,
totalSteps,
handlePrev,
handleNext,
handleSubmit,
}) {
return (
<div className="flex justify-between mt-10">
{currentStep > 1 && (
<button
type="button"
onClick={handlePrev}
className="px-8 py-2.5 bg-gray-200 text-gray-700 text-sm font-semibold rounded-md hover:bg-gray-300 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600"
>
Previous
</button>
)}
{currentStep < totalSteps && (
<button
type="button"
onClick={handleNext}
className="ml-auto px-11 py-2.5 bg-teal-600 text-sm font-semibold text-white shadow-sm rounded-md hover:bg-teal-700 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-600"
>
Next
</button>
)}
{currentStep === totalSteps && (
<button
type="submit"
onClick={handleSubmit}
className="ml-auto px-10 py-2.5 bg-teal-600 text-sm font-semibold text-white shadow-sm rounded-md hover:bg-teal-700 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-600"
>
Submit
</button>
)}
</div>
);
}
export default NavigationButtons;

View File

@@ -0,0 +1,33 @@
import React from 'react';
function ProgressIndicator({ steps, currentStep }) {
return (
<div className="flex items-center justify-between mb-8">
{steps.map((step, index) => (
<div key={step.id} className="flex-1">
<div className="flex items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center ${
currentStep >= step.id
? 'bg-teal-600 text-white'
: 'bg-gray-300 text-gray-700'
}`}
aria-current={currentStep === step.id ? 'step' : undefined}
>
{step.id}
</div>
{index !== steps.length - 1 && (
<div
className={`flex-1 h-1 ${
currentStep > step.id ? 'bg-teal-600' : 'bg-gray-300'
}`}
></div>
)}
</div>
</div>
))}
</div>
);
}
export default ProgressIndicator;

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,60 @@
import FormInput from '../components/FormInput';
function AccountInfo({ formData, handleChange, errors }) {
return (
<div>
<h2 className="text-xl font-semibold mb-4">
Welcome to BrightPath Wellness!
</h2>
<p className="mb-6 text-gray-700">
Lets create your account and begin your wellness journey.
</p>
<div className="mb-4">
<FormInput
htmlFor="fullName"
label="Full Name"
type="text"
id="fullName"
name="fullName"
value={''}
onChange={handleChange}
error={errors.fullName}
placeholder="Enter your full name"
required={true}
/>
</div>
<div className="mb-4">
<FormInput
htmlFor="email"
label="Email"
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
error={errors.email}
placeholder="Enter your email address"
required={true}
/>
</div>
<div className="mb-4">
<FormInput
htmlFor="password"
label="Password"
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="Create a password"
required={true}
/>
</div>
</div>
);
}
export default AccountInfo;

View File

@@ -0,0 +1,45 @@
import FormSelect from '../components/FormSelect';
function ActivityInfo({ handleChange, errors }) {
return (
<div>
<h2 className="text-xl font-semibold mb-4">Activity & Energy</h2>
<p className="mb-6 text-gray-700">
Tell us a little about your activity level and energy.
</p>
<div className="mb-4">
<FormSelect
id="activityLevel"
name="activityLevel"
options={[
{ value: 'never', label: 'Never' },
{ value: 'rarely', label: 'Rarely' },
{ value: '1-2 times a week', label: '1-2 times a week' },
{ value: '3+ times a week', label: '3+ times a week' },
]}
onChange={handleChange}
required={true}
error={errors.activityLevel}
/>
</div>
<div className="mb-4">
<FormSelect
id="energyLevel"
name="energyLevel"
options={[
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
]}
onChange={handleChange}
required={true}
error={errors.energyLevel}
/>
</div>
</div>
);
}
export default ActivityInfo;

View File

@@ -0,0 +1,65 @@
import FormSelect from '../components/FormSelect';
import FormCheckboxes from '../components/FormCheckboxes';
function GoalSetting({ formData, handleChange, handleCheckboxChange, errors }) {
return (
<div>
<h2 className="text-xl font-semibold mb-4">Activity & Energy</h2>
<p className="mb-6 text-gray-700">
Tell us a little about your activity level and energy.
</p>
<div className="mb-10">
<FormCheckboxes
legend="What are your goals?"
options={[
{
id: 'weightLoss',
name: 'goals',
label: 'Weight Loss',
checked: formData.goals.includes('weightLoss'),
},
{
id: 'muscleGain',
name: 'goals',
label: 'Muscle Gain',
checked: formData.goals.includes('musleGain'),
},
{
id: 'improveHealth',
name: 'goals',
label: 'Improve Health',
checked: formData.goals.includes('improveHealt'),
},
{
id: 'boostEnergy',
name: 'goals',
label: 'Boost Energy',
checked: formData.goals.includes('boostEnergy'),
},
]}
onChange={(id, isChecked) => handleCheckboxChange('goals', id)}
required={true}
error={errors.goals}
/>
</div>
<div className="mb-4">
<FormSelect
id="timeCommitment"
name="timeCommitment"
options={[
{ value: '15', label: '15 minutes' },
{ value: '30', label: '30 minutes' },
{ value: '60', label: '1 hour' },
]}
onChange={handleChange}
label="How much time can you commit each day?"
required={true}
/>
</div>
</div>
);
}
export default GoalSetting;

View File

@@ -0,0 +1,96 @@
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const PASSWORD_MIN_LENGTH = 6;
// Helper Functions
export const isEmpty = (value) => {
return !value || !value.trim();
};
export const isValidEmail = (email) => {
return EMAIL_REGEX.test(email);
};
export const hasMinLength = (value, min) => {
return value.length >= min;
};
export const hasTwoWords = (fullName) => {
return fullName.trim().split(' ').length >= 2;
};
// Main Validation Function
export const validate = (formData, currentStep) => {
const errors = {};
const {
fullName,
email,
password,
activityLevel,
energyLevel,
goals,
timeCommitment,
} = formData;
switch (currentStep) {
case 1:
// Full Name Validations
if (isEmpty(fullName)) {
errors.fullName = 'Full Name is required';
} else if (!hasTwoWords(fullName)) {
errors.fullName = 'Full Name must contain at least two words';
}
// Email Validations
if (isEmpty(email)) {
errors.email = 'Email is required';
} else if (!isValidEmail(email)) {
errors.email = 'Email address is invalid';
}
// Password Validations
if (isEmpty(password)) {
errors.password = 'Password is required';
} else {
if (!hasMinLength(password, PASSWORD_MIN_LENGTH)) {
errors.password = `Password must be at least ${PASSWORD_MIN_LENGTH} characters`;
}
}
break;
case 2:
// Activity Level Validation
if (isEmpty(activityLevel)) {
errors.activityLevel = 'Activity Level is required';
}
// Energy Level Validation
if (isEmpty(energyLevel)) {
errors.energyLevel = 'Energy Level is required';
}
break;
case 3:
// Goals Validation
if (!Array.isArray(goals) || goals.length === 0) {
errors.goals = 'At least one goal is required';
} else {
goals.forEach((goal, index) => {
if (isEmpty(goal)) {
errors[`goals.${index}`] = 'Goal cannot be empty';
}
});
}
// Time Commitment Validation
if (isEmpty(timeCommitment)) {
errors.timeCommitment = 'Time Commitment is required';
}
break;
default:
break;
}
return errors;
};