Skip to content
Open
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
117 changes: 116 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ You can create a custom card component for greater control over the design:
| `prevStep` | | A function to go back to the previous step in the onboarding process. |
| `arrow` | | Returns an SVG object, the orientation is controlled by the steps side prop |
| `skipTour` | | A function to skip the tour |
| `validationError` | `string \| null` | Current validation error message (if any) |
| `isValidating` | `boolean` | Whether validation is currently in progress |

```tsx
'use client';
Expand All @@ -180,6 +182,8 @@ export const CustomCard = ({
prevStep,
skipTour,
arrow,
validationError,
isValidating,
}: CardComponentProps) => {
return (
<div>
Expand All @@ -191,8 +195,15 @@ export const CustomCard = ({
</h2>
<p>{step.content}</p>
<button onClick={prevStep}>Previous</button>
<button onClick={nextStep}>Next</button>
<button onClick={nextStep} disabled={isValidating}>
{isValidating ? 'Validating...' : 'Next'}
</button>
<button onClick={skipTour}>Skip</button>
{validationError && (
<div style={{ color: 'red', marginTop: '0.5rem' }}>
{validationError}
</div>
)}
{arrow}
</div>
);
Expand Down Expand Up @@ -239,9 +250,113 @@ const steps: Tour[] = [
| `nextRoute` | `string` | Optional. The route to navigate to when moving to the next step. |
| `prevRoute` | `string` | Optional. The route to navigate to when moving to the previous step. |
| `viewportID` | `string` | Optional. The id of the viewport element to use for positioning. If not provided, the document body will be used. |
| `validation` | `StepValidation` | Optional. Custom validation function to ensure user completes required actions before proceeding. See [Validation System](#validation-system) for details. |

> **Note** `NextStep` handles card cutoff from screen sides. If side is right or left and card is out of the viewport, side would be switched to `top`. If side is top or bottom and card is out of the viewport, then side would be flipped between top and bottom.

### Validation System

NextStep includes a powerful validation system that allows you to ensure users complete required actions before proceeding to the next step. This is particularly useful for steps that require users to open modals, fill forms, or perform specific interactions.

#### Basic Usage

Add a `validation` object to any step that requires validation:

```tsx
{
icon: '📋',
title: 'Open Modal',
content: 'Click the button to open the modal',
selector: '#modal-button',
validation: {
validate: () => {
// Your validation logic here
return isModalOpen; // must return boolean or Promise<boolean>
},
errorMessage: 'Please open the modal before continuing',
required: true, // optional, defaults to true
},
}
```

#### Validation Interface

```tsx
interface StepValidation {
validate: () => boolean | Promise<boolean>;
errorMessage?: string;
required?: boolean;
}
```

#### Examples

**Check if modal is open:**
```tsx
validation: {
validate: () => {
const modal = document.querySelector('.modal');
return modal && !modal.classList.contains('hidden');
},
errorMessage: 'The modal needs to be open to continue',
}
```

**Check form field:**
```tsx
validation: {
validate: () => {
const input = document.querySelector('#email-input') as HTMLInputElement;
return input && input.value.trim().length > 0;
},
errorMessage: 'Please fill in the email field',
}
```

**Async validation:**
```tsx
validation: {
validate: async () => {
try {
const response = await fetch('/api/validate');
return response.ok;
} catch {
return false;
}
},
errorMessage: 'Validation failed. Please try again.',
}
```

**React state validation:**
```tsx
const [isDropdownOpen, setIsDropdownOpen] = useState(false);

const steps: Tour[] = [
{
tour: 'dropdown-tour',
steps: [
{
validation: {
validate: () => isDropdownOpen,
errorMessage: 'Open the dropdown to continue',
},
},
],
},
];
```

#### Behavior

- **Validation triggers** when user clicks "Next"
- **Visual feedback** shows "Validating..." during validation
- **Error messages** appear in the card if validation fails
- **Automatic cleanup** of error states when changing steps
- **Performance optimized** - validations only run when needed

For more detailed information, see the [Validation System Documentation](./src/docs/validation-feature.md).

### Target Anything

Target anything in your app using the element's `id` attribute.
Expand Down
21 changes: 15 additions & 6 deletions dist/DefaultCard.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
const DefaultCard = ({ step, currentStep, totalSteps, nextStep, prevStep, skipTour, arrow, }) => {
const DefaultCard = ({ step, currentStep, totalSteps, nextStep, prevStep, skipTour, arrow, validationError, isValidating, }) => {
return (_jsxs("div", { style: {
backgroundColor: 'white',
borderRadius: '0.5rem',
Expand All @@ -12,7 +12,15 @@ const DefaultCard = ({ step, currentStep, totalSteps, nextStep, prevStep, skipTo
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '1rem',
}, children: [_jsx("h2", { style: { fontSize: '1.125rem', fontWeight: 'bold' }, children: step.title }), step.icon && _jsx("span", { style: { fontSize: '1.5rem' }, children: step.icon })] }), _jsx("div", { style: { marginBottom: '1rem', fontSize: '0.875rem' }, children: step.content }), _jsx("div", { style: {
}, children: [_jsx("h2", { style: { fontSize: '1.125rem', fontWeight: 'bold' }, children: step.title }), step.icon && _jsx("span", { style: { fontSize: '1.5rem' }, children: step.icon })] }), _jsx("div", { style: { marginBottom: '1rem', fontSize: '0.875rem' }, children: step.content }), validationError && (_jsx("div", { style: {
marginBottom: '1rem',
padding: '0.75rem',
backgroundColor: '#FEF2F2',
border: '1px solid #FECACA',
borderRadius: '0.375rem',
color: '#DC2626',
fontSize: '0.875rem',
}, children: validationError })), _jsx("div", { style: {
marginBottom: '1rem',
backgroundColor: '#E5E7EB',
borderRadius: '9999px',
Expand Down Expand Up @@ -44,15 +52,16 @@ const DefaultCard = ({ step, currentStep, totalSteps, nextStep, prevStep, skipTo
borderRadius: '0.375rem',
cursor: 'pointer',
display: step.showControls ? 'block' : 'none',
}, children: "Finish" })) : (_jsx("button", { onClick: nextStep, style: {
}, children: "Finish" })) : (_jsx("button", { onClick: nextStep, disabled: isValidating, style: {
padding: '0.5rem 1rem',
fontWeight: '500',
color: 'white',
backgroundColor: '#2563EB',
backgroundColor: isValidating ? '#9CA3AF' : '#2563EB',
borderRadius: '0.375rem',
cursor: 'pointer',
cursor: isValidating ? 'not-allowed' : 'pointer',
display: step.showControls ? 'block' : 'none',
}, children: "Next" }))] }), arrow, skipTour && currentStep < totalSteps - 1 && (_jsx("button", { onClick: skipTour, style: {
opacity: isValidating ? 0.7 : 1,
}, children: isValidating ? 'Validando...' : 'Next' }))] }), arrow, skipTour && currentStep < totalSteps - 1 && (_jsx("button", { onClick: skipTour, style: {
marginTop: '1rem',
fontSize: '0.75rem',
width: '100%',
Expand Down
46 changes: 44 additions & 2 deletions dist/NextStepReact.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ const NextStepReact = ({ children, steps, shadowRgb = '0, 0, 0', shadowOpacity =
const [viewport, setViewport] = useState(null);
const [viewportRect, setViewportRect] = useState(null);
const [scrollableParent, setScrollableParent] = useState(null);
const [validationError, setValidationError] = useState(null);
const [isValidating, setIsValidating] = useState(false);
const router = navigationAdapter();
// - -
// Handle pop state
Expand Down Expand Up @@ -84,6 +86,12 @@ const NextStepReact = ({ children, steps, shadowRgb = '0, 0, 0', shadowOpacity =
}
}, [currentTour, onStart, isNextStepVisible]);
// - -
// Clear validation error when step changes
useEffect(() => {
setValidationError(null);
setIsValidating(false);
}, [currentStep]);
// - -
// Initialize
useEffect(() => {
if (isNextStepVisible && currentTourSteps) {
Expand Down Expand Up @@ -380,8 +388,42 @@ const NextStepReact = ({ children, steps, shadowRgb = '0, 0, 0', shadowOpacity =
};
}, [currentStep, currentTour]);
// - -
// Validation Helper
const validateCurrentStep = async () => {
if (!currentTourSteps || currentStep === undefined)
return true;
const currentStepData = currentTourSteps[currentStep];
if (!currentStepData.validation)
return true;
setIsValidating(true);
setValidationError(null);
try {
const isValid = await currentStepData.validation.validate();
if (!isValid) {
setValidationError(currentStepData.validation.errorMessage || 'Ação necessária não foi executada');
return false;
}
return true;
}
catch (error) {
setValidationError('Erro durante a validação');
if (!disableConsoleLogs) {
console.error('Validation error:', error);
}
return false;
}
finally {
setIsValidating(false);
}
};
// - -
// Step Controls
const nextStep = () => {
const nextStep = async () => {
// Validate current step before proceeding
const isValid = await validateCurrentStep();
if (!isValid) {
return; // Stop if validation fails
}
if (currentTourSteps && currentStep < currentTourSteps.length - 1) {
try {
const nextStepIndex = currentStep + 1;
Expand Down Expand Up @@ -875,7 +917,7 @@ const NextStepReact = ({ children, steps, shadowRgb = '0, 0, 0', shadowOpacity =
minWidth: 'min-content',
pointerEvents: 'auto',
zIndex: 999,
}, children: CardComponent ? (_jsx(CardComponent, { step: currentTourSteps?.[currentStep], currentStep: currentStep, totalSteps: currentTourSteps?.length ?? 0, nextStep: nextStep, prevStep: prevStep, arrow: _jsx(CardArrow, { isVisible: !!(currentTourSteps?.[currentStep]?.selector && displayArrow) }), skipTour: skipTour })) : (_jsx(DefaultCard, { step: currentTourSteps?.[currentStep], currentStep: currentStep, totalSteps: currentTourSteps?.length ?? 0, nextStep: nextStep, prevStep: prevStep, arrow: _jsx(CardArrow, { isVisible: !!(currentTourSteps?.[currentStep]?.selector && displayArrow) }), skipTour: skipTour })) }) })] }) })), pointerPosition &&
}, children: CardComponent ? (_jsx(CardComponent, { step: currentTourSteps?.[currentStep], currentStep: currentStep, totalSteps: currentTourSteps?.length ?? 0, nextStep: nextStep, prevStep: prevStep, arrow: _jsx(CardArrow, { isVisible: !!(currentTourSteps?.[currentStep]?.selector && displayArrow) }), skipTour: skipTour, validationError: validationError, isValidating: isValidating })) : (_jsx(DefaultCard, { step: currentTourSteps?.[currentStep], currentStep: currentStep, totalSteps: currentTourSteps?.length ?? 0, nextStep: nextStep, prevStep: prevStep, arrow: _jsx(CardArrow, { isVisible: !!(currentTourSteps?.[currentStep]?.selector && displayArrow) }), skipTour: skipTour, validationError: validationError, isValidating: isValidating })) }) })] }) })), pointerPosition &&
isNextStepVisible &&
currentTourSteps?.[currentStep]?.viewportID &&
scrollableParent && (_jsx(DynamicPortal, { children: _jsx(motion.div, { "data-name": "nextstep-overlay2", initial: "hidden", animate: isNextStepVisible ? 'visible' : 'hidden', variants: variants, transition: { duration: 0.5 }, style: {
Expand Down
2 changes: 1 addition & 1 deletion dist/adapters/react-router/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';
'use no memo';
import { useNavigate, useLocation } from 'react-router';
import { useNavigate, useLocation } from 'react-router-dom';
export const useReactRouterAdapter = () => {
const navigate = useNavigate();
const location = useLocation();
Expand Down
2 changes: 2 additions & 0 deletions dist/examples/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as ValidationExample } from './validation-example';
export { default as SimpleValidationExample } from './simple-validation';
2 changes: 2 additions & 0 deletions dist/examples/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as ValidationExample } from './validation-example';
export { default as SimpleValidationExample } from './simple-validation';
3 changes: 3 additions & 0 deletions dist/examples/simple-validation.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import React from 'react';
declare const SimpleValidationExample: React.FC;
export default SimpleValidationExample;
72 changes: 72 additions & 0 deletions dist/examples/simple-validation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import NextStep from '../NextStep';
const SimpleValidationExample = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const steps = [
{
tour: 'simple-validation',
steps: [
{
icon: '👋',
title: 'Bem-vindo',
content: 'Este exemplo mostra como usar validações simples.',
selector: '#welcome',
},
{
icon: '📋',
title: 'Abrir Modal',
content: 'Clique no botão para abrir o modal. Você não poderá continuar até que o modal esteja aberto.',
selector: '#modal-button',
validation: {
validate: () => isModalOpen,
errorMessage: 'Você precisa abrir o modal antes de continuar!',
},
},
{
icon: '✅',
title: 'Sucesso!',
content: 'Parabéns! Você completou a validação com sucesso.',
selector: '#success',
},
],
},
];
return (_jsx("div", { style: { padding: '2rem', fontFamily: 'Arial, sans-serif' }, children: _jsx(NextStep, { steps: steps, children: _jsxs("div", { children: [_jsx("h1", { children: "Exemplo Simples de Valida\u00E7\u00E3o" }), _jsx("div", { style: { marginBottom: '2rem' }, children: _jsx("button", { id: "welcome", style: {
padding: '0.75rem 1.5rem',
backgroundColor: '#2563EB',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: 'pointer',
fontSize: '1rem',
}, children: "Iniciar Tour" }) }), _jsxs("div", { style: { marginBottom: '2rem' }, children: [_jsx("button", { id: "modal-button", onClick: () => setIsModalOpen(!isModalOpen), style: {
padding: '0.75rem 1.5rem',
backgroundColor: isModalOpen ? '#10B981' : '#F59E0B',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: 'pointer',
fontSize: '1rem',
}, children: isModalOpen ? 'Modal Aberto ✓' : 'Abrir Modal' }), isModalOpen && (_jsxs("div", { style: {
marginTop: '1rem',
padding: '1rem',
backgroundColor: '#F3F4F6',
border: '1px solid #D1D5DB',
borderRadius: '0.375rem',
}, children: [_jsx("h3", { children: "Modal Aberto" }), _jsx("p", { children: "Agora voc\u00EA pode continuar o tour!" }), _jsx("button", { onClick: () => setIsModalOpen(false), style: {
padding: '0.5rem 1rem',
backgroundColor: '#DC2626',
color: 'white',
border: 'none',
borderRadius: '0.25rem',
cursor: 'pointer',
}, children: "Fechar Modal" })] }))] }), _jsxs("div", { id: "success", style: {
padding: '1rem',
backgroundColor: '#D1FAE5',
border: '1px solid #A7F3D0',
borderRadius: '0.375rem',
color: '#065F46',
}, children: [_jsx("h3", { children: "\uD83C\uDF89 Parab\u00E9ns!" }), _jsx("p", { children: "Voc\u00EA completou o tour com valida\u00E7\u00E3o!" })] })] }) }) }));
};
export default SimpleValidationExample;
3 changes: 3 additions & 0 deletions dist/examples/validation-example.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import React from 'react';
declare const ValidationExample: React.FC;
export default ValidationExample;
Loading