Skip to content

Commit 42228e2

Browse files
Merge pull request #71 from grid-labs-tech/refactor/reusable-stepper-component
refactor(portal): create reusable Stepper component
2 parents b9a528f + 20c023b commit 42228e2

8 files changed

Lines changed: 380 additions & 291 deletions

File tree

portal/src/pages/applications/CreateApplication.tsx

Lines changed: 160 additions & 291 deletions
Large diffs are not rendered by default.

portal/src/shared/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export { ProtectedRoute } from './ProtectedRoute'
66
export { AdminRoute } from './AdminRoute'
77
export { default as DataTable } from './DataTable'
88
export { default as ActionMenu } from './ActionMenu'
9+
export { Stepper, StepHeader, useStepper, getStepStatus, isStepClickable } from './stepper'
910
export type { BreadcrumbItem } from './Breadcrumbs'
1011
export type { Column } from './DataTable'
1112
export type { ActionMenuItem } from './ActionMenu'
13+
export type { Step, StepStatus, StepperProps, StepHeaderProps } from './stepper'
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Check, ChevronUp, ChevronDown } from 'lucide-react'
2+
import type { StepStatus } from './types'
3+
4+
export interface StepHeaderProps {
5+
number: number
6+
title: string
7+
status: StepStatus
8+
summary?: string
9+
onClick?: () => void
10+
isClickable?: boolean
11+
}
12+
13+
export function StepHeader({ number, title, status, summary, onClick, isClickable }: StepHeaderProps) {
14+
const isActive = status === 'active'
15+
const isCompleted = status === 'completed'
16+
17+
return (
18+
<button
19+
type="button"
20+
onClick={onClick}
21+
disabled={!isClickable}
22+
className={`w-full flex items-start gap-4 p-4 text-left transition-colors ${
23+
isClickable ? 'cursor-pointer hover:bg-slate-50' : 'cursor-default'
24+
} ${isActive ? 'bg-white' : ''}`}
25+
>
26+
{/* Step number/icon */}
27+
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-colors ${
28+
isCompleted
29+
? 'bg-blue-600 text-white'
30+
: isActive
31+
? 'bg-blue-600 text-white'
32+
: 'bg-slate-200 text-slate-500'
33+
}`}>
34+
{isCompleted ? <Check size={16} /> : number}
35+
</div>
36+
37+
{/* Step title and summary */}
38+
<div className="flex-1 min-w-0">
39+
<h3 className={`text-base font-semibold ${
40+
isActive ? 'text-slate-900' : isCompleted ? 'text-slate-700' : 'text-slate-400'
41+
}`}>
42+
{title}
43+
</h3>
44+
{isCompleted && summary && (
45+
<p className="text-sm text-slate-500 mt-0.5 truncate">{summary}</p>
46+
)}
47+
{!isActive && !isCompleted && (
48+
<p className="text-sm text-slate-400 mt-0.5">Complete the previous step to continue</p>
49+
)}
50+
</div>
51+
52+
{/* Expand indicator */}
53+
{isClickable && (
54+
<div className="flex-shrink-0 text-slate-400">
55+
{isActive ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
56+
</div>
57+
)}
58+
</button>
59+
)
60+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { StepHeader } from './StepHeader'
2+
import { getStepStatus, isStepClickable } from './utils'
3+
import type { Step } from './types'
4+
5+
export interface StepperProps {
6+
steps: Step[]
7+
currentStep: number
8+
completedSteps: number[]
9+
onStepChange: (step: number) => void
10+
onStepComplete: (step: number) => void
11+
showContinueButton?: boolean
12+
continueButtonText?: string
13+
}
14+
15+
export function Stepper({
16+
steps,
17+
currentStep,
18+
completedSteps,
19+
onStepChange,
20+
onStepComplete,
21+
showContinueButton = true,
22+
continueButtonText = 'Continue',
23+
}: StepperProps) {
24+
const handleGoToStep = (step: number) => {
25+
if (isStepClickable(step, currentStep, completedSteps)) {
26+
onStepChange(step)
27+
}
28+
}
29+
30+
const continueToNextStep = () => {
31+
const currentStepConfig = steps.find(s => s.id === currentStep)
32+
const isValid = currentStepConfig?.validate ? currentStepConfig.validate() : true
33+
34+
if (isValid) {
35+
onStepComplete(currentStep)
36+
if (currentStep < steps.length) {
37+
onStepChange(currentStep + 1)
38+
}
39+
}
40+
}
41+
42+
return (
43+
<div className="space-y-0">
44+
{steps.map((step) => {
45+
const status = getStepStatus(step.id, currentStep, completedSteps)
46+
const isActive = status === 'active'
47+
const clickable = isStepClickable(step.id, currentStep, completedSteps)
48+
49+
return (
50+
<div key={step.id} className="border-b border-slate-200 last:border-b-0">
51+
<StepHeader
52+
number={step.id}
53+
title={step.title}
54+
status={status}
55+
summary={step.summary}
56+
onClick={() => handleGoToStep(step.id)}
57+
isClickable={clickable}
58+
/>
59+
60+
{/* Step content */}
61+
{isActive && (
62+
<div className="px-4 pb-6 pt-2">
63+
<div className="ml-12">
64+
{step.content}
65+
66+
{/* Continue button */}
67+
{showContinueButton && currentStep < steps.length && (
68+
<div className="mt-6">
69+
<button
70+
type="button"
71+
onClick={continueToNextStep}
72+
className="px-5 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
73+
>
74+
{continueButtonText}
75+
</button>
76+
</div>
77+
)}
78+
</div>
79+
</div>
80+
)}
81+
</div>
82+
)
83+
})}
84+
</div>
85+
)
86+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export { Stepper } from './Stepper'
2+
export { StepHeader } from './StepHeader'
3+
export { useStepper } from './useStepper'
4+
export { getStepStatus, isStepClickable } from './utils'
5+
export type { Step, StepStatus } from './types'
6+
export type { StepperProps } from './Stepper'
7+
export type { StepHeaderProps } from './StepHeader'
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { ReactNode } from 'react'
2+
3+
export type StepStatus = 'pending' | 'active' | 'completed'
4+
5+
export interface Step {
6+
id: number
7+
title: string
8+
summary?: string
9+
content: ReactNode
10+
validate?: () => boolean
11+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useState } from 'react'
2+
3+
// Hook to manage stepper state
4+
export function useStepper(initialStep: number = 1) {
5+
const [currentStep, setCurrentStep] = useState(initialStep)
6+
const [completedSteps, setCompletedSteps] = useState<number[]>([])
7+
8+
const goToStep = (step: number) => {
9+
setCurrentStep(step)
10+
}
11+
12+
const completeStep = (step: number) => {
13+
if (!completedSteps.includes(step)) {
14+
setCompletedSteps(prev => [...prev, step])
15+
}
16+
}
17+
18+
const resetStepper = () => {
19+
setCurrentStep(initialStep)
20+
setCompletedSteps([])
21+
}
22+
23+
const isStepCompleted = (step: number) => completedSteps.includes(step)
24+
25+
return {
26+
currentStep,
27+
completedSteps,
28+
goToStep,
29+
completeStep,
30+
resetStepper,
31+
isStepCompleted,
32+
}
33+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { StepStatus } from './types'
2+
3+
// Utility function to determine step status
4+
export function getStepStatus(
5+
stepId: number,
6+
currentStep: number,
7+
completedSteps: number[]
8+
): StepStatus {
9+
if (currentStep === stepId) return 'active'
10+
if (completedSteps.includes(stepId)) return 'completed'
11+
return 'pending'
12+
}
13+
14+
// Utility function to check if step is clickable
15+
export function isStepClickable(
16+
stepId: number,
17+
currentStep: number,
18+
completedSteps: number[]
19+
): boolean {
20+
return stepId < currentStep || completedSteps.includes(stepId - 1) || stepId === 1
21+
}

0 commit comments

Comments
 (0)