Update readmes and imporve UI
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
|
||||
export interface WizardStep {
|
||||
id: string;
|
||||
@@ -50,6 +50,8 @@ export const WizardModal: React.FC<WizardModalProps> = ({
|
||||
}) => {
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [validationSuccess, setValidationSuccess] = useState(false);
|
||||
|
||||
const currentStep = steps[currentStepIndex];
|
||||
const isFirstStep = currentStepIndex === 0;
|
||||
@@ -65,6 +67,8 @@ export const WizardModal: React.FC<WizardModalProps> = ({
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setCurrentStepIndex(0);
|
||||
setValidationError(null);
|
||||
setValidationSuccess(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
@@ -80,6 +84,8 @@ export const WizardModal: React.FC<WizardModalProps> = ({
|
||||
}, [steps.length]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setValidationError(null);
|
||||
setValidationSuccess(false);
|
||||
setCurrentStepIndex(prev => Math.max(prev - 1, 0));
|
||||
}, []);
|
||||
|
||||
@@ -88,17 +94,26 @@ export const WizardModal: React.FC<WizardModalProps> = ({
|
||||
const step = steps[currentStepIndex];
|
||||
const lastStep = currentStepIndex === steps.length - 1;
|
||||
|
||||
// Clear previous validation messages
|
||||
setValidationError(null);
|
||||
setValidationSuccess(false);
|
||||
|
||||
// Validate current step if validator exists
|
||||
if (step.validate) {
|
||||
setIsValidating(true);
|
||||
try {
|
||||
const isValid = await step.validate();
|
||||
if (!isValid) {
|
||||
setValidationError('Por favor, completa todos los campos requeridos correctamente.');
|
||||
setIsValidating(false);
|
||||
return;
|
||||
}
|
||||
// Show brief success indicator
|
||||
setValidationSuccess(true);
|
||||
setTimeout(() => setValidationSuccess(false), 1000);
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error);
|
||||
setValidationError(error instanceof Error ? error.message : 'Error de validación. Por favor, verifica los campos.');
|
||||
setIsValidating(false);
|
||||
return;
|
||||
}
|
||||
@@ -112,6 +127,41 @@ export const WizardModal: React.FC<WizardModalProps> = ({
|
||||
}
|
||||
}, [steps, currentStepIndex, handleComplete]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't handle keyboard events if user is typing in an input/textarea
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
handleClose();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (!isFirstStep && !isValidating) {
|
||||
e.preventDefault();
|
||||
handleBack();
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'Enter':
|
||||
if (!isValidating) {
|
||||
e.preventDefault();
|
||||
handleNext();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, isFirstStep, isValidating, handleClose, handleBack, handleNext]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const StepComponent = currentStep.component;
|
||||
@@ -132,61 +182,113 @@ export const WizardModal: React.FC<WizardModalProps> = ({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 bg-[var(--bg-primary)] border-b border-[var(--border-secondary)]">
|
||||
<div className="sticky top-0 z-10 bg-[var(--bg-primary)] border-b border-[var(--border-secondary)] shadow-sm">
|
||||
{/* Title Bar */}
|
||||
<div className="flex items-center justify-between p-6 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-between p-4 sm:p-6 pb-3 sm:pb-4">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{icon && (
|
||||
<div className="w-10 h-10 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center text-[var(--color-primary)]">
|
||||
<div className="w-10 h-10 sm:w-12 sm:h-12 flex-shrink-0 rounded-xl bg-gradient-to-br from-[var(--color-primary)]/10 to-[var(--color-primary)]/5 flex items-center justify-center text-[var(--color-primary)] shadow-sm">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-lg sm:text-xl font-bold text-[var(--text-primary)] truncate">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-0.5">
|
||||
{currentStep.description || `Step ${currentStepIndex + 1} of ${steps.length}`}
|
||||
<p className="text-xs sm:text-sm text-[var(--text-secondary)] mt-0.5 truncate">
|
||||
{currentStep.description || `Paso ${currentStepIndex + 1} de ${steps.length}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors"
|
||||
className="p-2 flex-shrink-0 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-all hover:scale-110 active:scale-95"
|
||||
aria-label="Cerrar"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="px-6 pb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<button
|
||||
onClick={() => index < currentStepIndex && goToStep(index)}
|
||||
disabled={index > currentStepIndex}
|
||||
className={`flex-1 h-2 rounded-full transition-all duration-300 ${
|
||||
index < currentStepIndex
|
||||
? 'bg-[var(--color-success)] cursor-pointer hover:bg-[var(--color-success)]/80'
|
||||
: index === currentStepIndex
|
||||
? 'bg-[var(--color-primary)]'
|
||||
: 'bg-[var(--bg-tertiary)] cursor-not-allowed'
|
||||
}`}
|
||||
title={step.title}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{/* Enhanced Progress Bar */}
|
||||
<div className="px-4 sm:px-6 pb-4">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 mb-2.5">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = index < currentStepIndex;
|
||||
const isCurrent = index === currentStepIndex;
|
||||
const isUpcoming = index > currentStepIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex-1 group relative"
|
||||
>
|
||||
<button
|
||||
onClick={() => isCompleted && goToStep(index)}
|
||||
disabled={!isCompleted}
|
||||
className={`w-full h-2.5 rounded-full transition-all duration-300 relative overflow-hidden ${
|
||||
isCompleted
|
||||
? 'bg-[var(--color-success)] cursor-pointer hover:bg-[var(--color-success)]/80 hover:h-3'
|
||||
: isCurrent
|
||||
? 'bg-[var(--color-primary)] shadow-md'
|
||||
: 'bg-[var(--bg-tertiary)] cursor-not-allowed'
|
||||
}`}
|
||||
aria-label={`${step.title} - ${isCompleted ? 'Completado' : isCurrent ? 'En progreso' : 'Pendiente'}`}
|
||||
>
|
||||
{isCurrent && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-shimmer" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Tooltip on hover */}
|
||||
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded shadow-lg text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20">
|
||||
{step.title}
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-[var(--border-secondary)]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-[var(--text-secondary)]">
|
||||
<span className="font-medium">{currentStep.title}</span>
|
||||
<span>{currentStepIndex + 1} / {steps.length}</span>
|
||||
<div className="flex items-center justify-between text-xs sm:text-sm">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className="font-semibold text-[var(--text-primary)] truncate">{currentStep.title}</span>
|
||||
{currentStep.isOptional && (
|
||||
<span className="px-2 py-0.5 text-xs bg-[var(--bg-secondary)] text-[var(--text-tertiary)] rounded-full flex-shrink-0">
|
||||
Opcional
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[var(--text-tertiary)] font-medium ml-2 flex-shrink-0">
|
||||
{currentStepIndex + 1} / {steps.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="p-6 overflow-y-auto" style={{ maxHeight: 'calc(90vh - 220px)' }}>
|
||||
<div className="p-4 sm:p-6 overflow-y-auto" style={{ maxHeight: 'calc(90vh - 220px)' }}>
|
||||
{/* Validation Messages */}
|
||||
{validationError && (
|
||||
<div className="mb-4 p-3 sm:p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3 animate-slideDown">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-red-800">{validationError}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setValidationError(null)}
|
||||
className="flex-shrink-0 text-red-400 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationSuccess && (
|
||||
<div className="mb-4 p-3 sm:p-4 bg-green-50 border border-green-200 rounded-lg flex items-center gap-3 animate-slideDown">
|
||||
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0" />
|
||||
<p className="text-sm font-medium text-green-800">¡Validación exitosa!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StepComponent
|
||||
onNext={handleNext}
|
||||
onBack={handleBack}
|
||||
@@ -202,52 +304,75 @@ export const WizardModal: React.FC<WizardModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<div className="sticky bottom-0 border-t border-[var(--border-secondary)] bg-[var(--bg-secondary)]/50 backdrop-blur-sm px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{/* Back Button */}
|
||||
<div className="sticky bottom-0 border-t border-[var(--border-secondary)] bg-[var(--bg-secondary)]/80 backdrop-blur-md px-4 sm:px-6 py-3 sm:py-4 shadow-lg">
|
||||
{/* Keyboard Shortcuts Hint */}
|
||||
<div className="hidden md:flex items-center justify-center gap-4 text-xs text-[var(--text-tertiary)] mb-2 pb-2 border-b border-[var(--border-secondary)]/50">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded text-[10px] font-mono">ESC</kbd>
|
||||
Cerrar
|
||||
</span>
|
||||
{!isFirstStep && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded text-[10px] font-mono">←</kbd>
|
||||
Atrás
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded text-[10px] font-mono">→</kbd>
|
||||
<kbd className="px-1.5 py-0.5 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded text-[10px] font-mono">ENTER</kbd>
|
||||
{isLastStep ? 'Completar' : 'Siguiente'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 sm:gap-3">
|
||||
{/* Back Button */}
|
||||
{!isFirstStep ? (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
disabled={isValidating}
|
||||
className="px-4 py-2.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2"
|
||||
className="px-3 sm:px-4 py-2.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-all font-medium disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-1.5 sm:gap-2 active:scale-95"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Back
|
||||
<span className="hidden sm:inline">Atrás</span>
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Skip Button (for optional steps) */}
|
||||
{currentStep.isOptional && !isLastStep && (
|
||||
<button
|
||||
onClick={() => setCurrentStepIndex(prev => prev + 1)}
|
||||
disabled={isValidating}
|
||||
className="px-4 py-2.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors font-medium disabled:opacity-50"
|
||||
className="px-3 sm:px-4 py-2.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-all font-medium disabled:opacity-50 text-sm active:scale-95"
|
||||
>
|
||||
Skip This Step
|
||||
Saltar
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Next/Complete Button */}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={isValidating}
|
||||
className="px-6 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium inline-flex items-center gap-2 min-w-[140px] justify-center"
|
||||
className="px-4 sm:px-6 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] disabled:opacity-50 disabled:cursor-not-allowed transition-all font-semibold inline-flex items-center gap-2 min-w-[100px] sm:min-w-[140px] justify-center shadow-md hover:shadow-lg active:scale-95"
|
||||
>
|
||||
{isValidating ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Validating...
|
||||
<span className="hidden sm:inline">Validando...</span>
|
||||
</>
|
||||
) : isLastStep ? (
|
||||
<>
|
||||
Complete
|
||||
Completar
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Next
|
||||
<span className="hidden sm:inline">Siguiente</span>
|
||||
<span className="sm:hidden">Sig.</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
@@ -273,12 +398,36 @@ export const WizardModal: React.FC<WizardModalProps> = ({
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
.animate-slideUp {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
.animate-slideDown {
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user