268 lines
7.3 KiB
TypeScript
Executable file
268 lines
7.3 KiB
TypeScript
Executable file
/**
|
|
* StepIndicator Component
|
|
*
|
|
* Visual progress indicator for multi-step forms.
|
|
* Displays step numbers, labels, and completion status with connecting lines.
|
|
*/
|
|
|
|
import type React from 'react';
|
|
|
|
import styled, { css, type DefaultTheme } from 'styled-components';
|
|
|
|
export interface StepIndicatorProps {
|
|
/** Array of step labels */
|
|
steps: string[];
|
|
/** Current active step (0-indexed) */
|
|
currentStep: number;
|
|
/** Array of completed step indices */
|
|
completedSteps?: number[];
|
|
/** Display variant */
|
|
variant?: 'horizontal' | 'vertical';
|
|
/** Callback when a step is clicked */
|
|
onStepClick?: (stepIndex: number) => void;
|
|
}
|
|
|
|
const Container = styled.div<{ $variant: 'horizontal' | 'vertical' }>`
|
|
display: flex;
|
|
flex-direction: ${(props) => (props.$variant === 'vertical' ? 'column' : 'row')};
|
|
gap: ${(props) => (props.$variant === 'vertical' ? props.theme.spacing.lg : 0)};
|
|
width: 100%;
|
|
|
|
@media (max-width: 768px) {
|
|
flex-direction: column;
|
|
gap: ${(props: { theme: DefaultTheme }) => props.theme.spacing.lg};
|
|
}
|
|
`;
|
|
|
|
const StepWrapper = styled.div<{ $variant: 'horizontal' | 'vertical' }>`
|
|
display: flex;
|
|
flex-direction: ${(props) => (props.$variant === 'vertical' ? 'row' : 'column')};
|
|
align-items: center;
|
|
flex: 1;
|
|
position: relative;
|
|
|
|
&:not(:last-child) {
|
|
${(props) =>
|
|
props.$variant === 'horizontal' &&
|
|
css`
|
|
&::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 14px;
|
|
left: calc(50% + 20px);
|
|
right: calc(-50% + 20px);
|
|
height: 2px;
|
|
background: ${props.theme.colors.border};
|
|
z-index: 0;
|
|
|
|
@media (max-width: 768px) {
|
|
display: none;
|
|
}
|
|
}
|
|
`}
|
|
|
|
${(props) =>
|
|
props.$variant === 'vertical' &&
|
|
css`
|
|
&::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 32px;
|
|
left: 14px;
|
|
bottom: -${props.theme.spacing.lg};
|
|
width: 2px;
|
|
background: ${props.theme.colors.border};
|
|
z-index: 0;
|
|
}
|
|
`}
|
|
}
|
|
`;
|
|
|
|
const StepCircle = styled.div<{
|
|
$active: boolean;
|
|
$completed: boolean;
|
|
$clickable: boolean;
|
|
}>`
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: ${(props: { theme: DefaultTheme }) => props.theme.typography.fontSize.sm};
|
|
font-weight: ${(props: { theme: DefaultTheme }) => props.theme.typography.fontWeight.bold};
|
|
position: relative;
|
|
z-index: 1;
|
|
transition: all ${(props: { theme: DefaultTheme }) => props.theme.transitions.normal};
|
|
cursor: ${(props) => (props.$clickable ? 'pointer' : 'default')};
|
|
flex-shrink: 0;
|
|
|
|
${({ $completed, $active, theme }) => {
|
|
if ($completed) {
|
|
return css`
|
|
background: ${theme.colors.success};
|
|
border: 2px solid ${theme.colors.success};
|
|
color: #ffffff;
|
|
|
|
${theme.extensions?.cyberpunk &&
|
|
css`
|
|
box-shadow: 0 0 10px ${theme.colors.success}66;
|
|
`}
|
|
`;
|
|
}
|
|
|
|
if ($active) {
|
|
return css`
|
|
background: ${theme.colors.primary};
|
|
border: 2px solid ${theme.colors.primary};
|
|
color: #ffffff;
|
|
|
|
${theme.extensions?.cyberpunk &&
|
|
css`
|
|
box-shadow: ${theme.extensions.cyberpunk.neonGlow.magenta};
|
|
`}
|
|
`;
|
|
}
|
|
|
|
return css`
|
|
background: ${theme.colors.surface};
|
|
border: 2px solid ${theme.colors.border};
|
|
color: ${theme.colors.text.secondary};
|
|
`;
|
|
}}
|
|
|
|
&:hover {
|
|
${(props) =>
|
|
props.$clickable &&
|
|
css`
|
|
transform: scale(1.1);
|
|
box-shadow: ${props.theme.shadows.md};
|
|
`}
|
|
}
|
|
`;
|
|
|
|
const CheckIcon = styled.span`
|
|
font-size: ${(props: { theme: DefaultTheme }) => props.theme.typography.fontSize.base};
|
|
line-height: 1;
|
|
`;
|
|
|
|
const StepLabel = styled.div<{
|
|
$active: boolean;
|
|
$completed: boolean;
|
|
$variant: 'horizontal' | 'vertical';
|
|
}>`
|
|
font-size: ${(props: { theme: DefaultTheme }) => props.theme.typography.fontSize.sm};
|
|
font-weight: ${(props) =>
|
|
props.$active
|
|
? props.theme.typography.fontWeight.semibold
|
|
: props.theme.typography.fontWeight.normal};
|
|
color: ${({ $completed, $active, theme }) => {
|
|
if ($completed) {
|
|
return theme.colors.success;
|
|
}
|
|
|
|
if ($active) {
|
|
return theme.colors.primary;
|
|
}
|
|
|
|
return theme.colors.text.secondary;
|
|
}};
|
|
text-align: ${(props) => (props.$variant === 'vertical' ? 'left' : 'center')};
|
|
margin: ${(props) =>
|
|
props.$variant === 'vertical'
|
|
? `0 0 0 ${props.theme.spacing.md}`
|
|
: `${props.theme.spacing.sm} 0 0 0`};
|
|
transition: color ${(props: { theme: DefaultTheme }) => props.theme.transitions.normal};
|
|
|
|
@media (max-width: 768px) {
|
|
text-align: left;
|
|
margin: 0 0 0 ${(props: { theme: DefaultTheme }) => props.theme.spacing.md};
|
|
}
|
|
`;
|
|
|
|
const StepContent = styled.div<{ $variant: 'horizontal' | 'vertical' }>`
|
|
display: flex;
|
|
flex-direction: ${(props) => (props.$variant === 'vertical' ? 'row' : 'column')};
|
|
align-items: ${(props) => (props.$variant === 'vertical' ? 'center' : 'center')};
|
|
|
|
@media (max-width: 768px) {
|
|
flex-direction: row;
|
|
align-items: center;
|
|
}
|
|
`;
|
|
|
|
/**
|
|
* StepIndicator displays progress through multi-step processes.
|
|
* Shows numbered steps with labels, highlights current step, and marks completed steps.
|
|
*
|
|
* @example
|
|
* // Basic horizontal step indicator
|
|
* <StepIndicator
|
|
* steps={['Personal Info', 'Address', 'Payment', 'Review']}
|
|
* currentStep={1}
|
|
* completedSteps={[0]}
|
|
* />
|
|
*
|
|
* @example
|
|
* // Vertical step indicator with click handling
|
|
* <StepIndicator
|
|
* steps={['Step 1', 'Step 2', 'Step 3']}
|
|
* currentStep={1}
|
|
* completedSteps={[0]}
|
|
* variant="vertical"
|
|
* onStepClick={(index) => console.log('Clicked step', index)}
|
|
* />
|
|
*/
|
|
export const StepIndicator: FC<StepIndicatorProps> = ({
|
|
steps,
|
|
currentStep,
|
|
completedSteps = [],
|
|
variant = 'horizontal',
|
|
onStepClick,
|
|
}) => {
|
|
const isStepCompleted = (index: number): boolean => completedSteps.includes(index);
|
|
|
|
const isStepClickable = (index: number): boolean =>
|
|
!!onStepClick && (isStepCompleted(index) || index === currentStep);
|
|
|
|
const handleStepClick = (index: number) => {
|
|
if (isStepClickable(index)) {
|
|
onStepClick?.(index);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Container
|
|
$variant={variant}
|
|
role="progressbar"
|
|
aria-valuenow={currentStep + 1}
|
|
aria-valuemin={1}
|
|
aria-valuemax={steps.length}
|
|
>
|
|
{steps.map((label, index) => {
|
|
const isActive = index === currentStep;
|
|
const isCompleted = isStepCompleted(index);
|
|
const isClickable = isStepClickable(index);
|
|
|
|
return (
|
|
<StepWrapper key={label} $variant={variant} onClick={() => handleStepClick(index)}>
|
|
<StepContent $variant={variant}>
|
|
<StepCircle
|
|
$active={isActive}
|
|
$completed={isCompleted}
|
|
$clickable={isClickable}
|
|
aria-label={`Step ${index + 1}: ${label}`}
|
|
aria-current={isActive ? 'step' : undefined}
|
|
>
|
|
{isCompleted ? <CheckIcon>✓</CheckIcon> : index + 1}
|
|
</StepCircle>
|
|
<StepLabel $active={isActive} $completed={isCompleted} $variant={variant}>
|
|
{label}
|
|
</StepLabel>
|
|
</StepContent>
|
|
</StepWrapper>
|
|
);
|
|
})}
|
|
</Container>
|
|
);
|
|
};
|