Skip to content

Commit

Permalink
implement stepper component (#389)
Browse files Browse the repository at this point in the history
* implement stepper component
  • Loading branch information
jay-deshmukh authored Oct 8, 2024
1 parent 0163e44 commit b517a35
Show file tree
Hide file tree
Showing 20 changed files with 486 additions and 9 deletions.
2 changes: 1 addition & 1 deletion lib/index.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion lib/index.css.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion lib/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion lib/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion lib/tyk-ui.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion lib/tyk-ui.css.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion lib/tyk-ui.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion lib/tyk-ui.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tyk-technologies/tyk-ui",
"version": "4.4.1",
"version": "4.4.2",
"description": "Tyk UI - ui reusable components",
"main": "src/index.js",
"scripts": {
Expand Down
1 change: 1 addition & 0 deletions src/common/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
@import '../../components/Tabs/Tabs.css';
@import '../../components/Toast/Toast.css';
@import '../../components/Tooltip/Tooltip.css';
@import '../../components/Stepper/stepper.css';

/* -- Form Components */
@import '../../form/components/Combobox2/Combobox.css';
Expand Down
35 changes: 35 additions & 0 deletions src/components/Stepper/Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@

```jsx
import React from 'react';

const ExampleStepper = () => {
const handleFinish = () => {
console.log('All steps completed!');
};

const validateStep = (stepId) => {
return true;
};

return (
<Stepper
onFinish={handleFinish}
stepValidator={validateStep}
stepErrMessage="Please complete all required fields before proceeding."
>
<Stepper.Step id="personal-info" title="Step-1" description="Enter your name">
<input type="text" placeholder="Full Name" />
</Stepper.Step>

<Stepper.Step id="address" title="Step-2" description="Provide your address">
<input type="text" placeholder="Street Address" />
</Stepper.Step>

<Stepper.Step id="review" title="Step-3" description="Review your information">
<p>Please review your entered information before submitting.</p>
</Stepper.Step>
</Stepper>
);
};

<ExampleStepper />
108 changes: 108 additions & 0 deletions src/components/Stepper/Stepper.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import React from "react";
import Stepper from "./index";

function StepperComponent({ validator, onFinish: mockOnFinish }) {
const onFinish = () => {
console.log("Stepper finished");
};

const stepValidator = (stepId) => {
return stepId !== "step2";
};

return (
<div className="stepper-wrapper">
<Stepper
onFinish={mockOnFinish || onFinish}
stepValidator={validator || stepValidator}
stepErrMessage="Validation failed"
>
<Stepper.Step id="step1" title="Step 1" description="First step">
Step 1 content
</Stepper.Step>
<Stepper.Step id="step2" title="Step 2" description="Second step">
Step 2 content
</Stepper.Step>
<Stepper.Step id="step3" title="Step 3" description="Third step">
Step 3 content
</Stepper.Step>
</Stepper>
</div>
);
}

describe("Stepper", () => {
it("should render the initial step correctly", () => {
cy.mount(<StepperComponent />)
.get(".step-container")
.should("have.length", 3)
.get(".step-number.active")
.should("contain", "1")
.get(".step-title")
.first()
.should("have.text", "Step 1");
});

it("should navigate to the next step when Continue is clicked", () => {
cy.mount(<StepperComponent />)
.get(".stepper-buttons button")
.contains("Continue")
.click()
.get(".step-number.active")
.should("contain", "2");
});

it("should show error message when validation fails", () => {
cy.mount(<StepperComponent />)
.get(".stepper-buttons button")
.contains("Continue")
.click()
.get(".stepper-buttons button")
.contains("Continue")
.click()
.get(".error-message")
.should("be.visible")
.and("have.text", "Validation failed");
});

it("should allow navigation back to previous step", () => {
cy.mount(<StepperComponent />)
.get(".stepper-buttons button")
.contains("Continue")
.click()
.get(".stepper-buttons button")
.contains("Back")
.click()
.get(".step-number.active")
.should("contain", "1");
});

it("should mark completed steps correctly", () => {
cy.mount(<StepperComponent />)
.get(".stepper-buttons button")
.contains("Continue")
.click()
.get(".step-number.completed")
.should("have.length", 1)
.and("contain", "1");
});

it("should show Finish button on last step", () => {
const onFinish = cy.stub().as("onFinish");
cy.mount(<StepperComponent validator={() => true} onFinish={onFinish} />)
.get(".stepper-buttons button")
.contains("Continue")
.click()
.get(".stepper-buttons button")
.contains("Continue")
.click()
.get(".stepper-buttons button")
.contains("Finish")
.should("be.visible")
.click()
.get('button:contains("Finish")')
.click()
.get("@onFinish")
.should("have.been.called");
});
});
13 changes: 13 additions & 0 deletions src/components/Stepper/StepperContext.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React, { createContext, useContext } from "react";

const StepperContext = createContext();

export const StepperProvider = StepperContext.Provider;

export const useStepper = () => {
const context = useContext(StepperContext);
if (!context) {
throw new Error("useStepper must be used within a Stepper component");
}
return context;
};
88 changes: 88 additions & 0 deletions src/components/Stepper/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React, { useState, useMemo } from "react";
import PropTypes from 'prop-types';
import { StepperProvider } from "./StepperContext";
import StepList from "./js/StepList";
import StepperButtons from "./js/StepperButtons";
import "./stepper.css";

export const Stepper = ({
children,
onFinish,
stepValidator,
stepErrMessage = "ERROR",
}) => {
const [activeStep, setActiveStep] = useState(0);
const [errors, setErrors] = useState({});
const [validationAttempted, setValidationAttempted] = useState(false);

const steps = useMemo(() => {
return React.Children.toArray(children).filter(
(child) => child.type.name === "Step"
);
}, [children]);

const contextValue = {
activeStep,
setActiveStep,
errors,
setErrors,
steps,
onFinish,
stepValidator,
stepErrMessage,
validationAttempted,
setValidationAttempted,
};

return (
<StepperProvider value={contextValue}>
<div className="stepper-container">
<StepList />
<StepperButtons />
</div>
</StepperProvider>
);
};

export const Step = ({ children }) => {
return <>{children}</>;
};

Stepper.Step = Step;


Stepper.propTypes = {
/**
* The steps of the stepper. Should be Stepper.Step components.
*/
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.element),
PropTypes.element
]).isRequired,

/**
* Function to be called when the stepper is finished.
*/
onFinish: PropTypes.func.isRequired,

/**
* Function to validate each step. Should return true if valid, false otherwise.
*/
stepValidator: PropTypes.func,

/**
* Error message to display when a step is invalid.
*/
stepErrMessage: PropTypes.string
};

Stepper.defaultProps = {
stepErrMessage: 'ERROR'
};

Step.propTypes = {
children: PropTypes.node.isRequired
};


export default Stepper;
37 changes: 37 additions & 0 deletions src/components/Stepper/js/StepItem.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';
import { useStepper } from '../StepperContext';
import StepNumber from './StepNumber';

const StepItem = ({
step,
index,
isActive,
isCompleted,
hasError,
isLastStep
}) => {
const { errors } = useStepper();
const stepError = errors[index];

return (
<div className="tyk-stepper">
<div className={`step-container ${hasError ? 'step-error' : ''}`}>
{!isLastStep && <div className={`stepper-line `} />}
<StepNumber
number={index + 1}
isCompleted={isCompleted}
isActive={isActive}
hasError={hasError}
/>
<div className="step-content">
<h3 className="step-title">{step.props.title}</h3>
<p className="step-description">{step.props.description}</p>
{isActive && React.cloneElement(step, { stepIndex: index })}
{stepError && <p className="error-message">{stepError}</p>}
</div>
</div>
</div>
);
};

export default StepItem;
25 changes: 25 additions & 0 deletions src/components/Stepper/js/StepList.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from "react";
import { useStepper } from '../StepperContext';
import StepItem from './StepItem';

const StepList = () => {
const { steps, activeStep, errors } = useStepper();

return (
<div>
{steps.map((step, index) => (
<StepItem
key={step}
step={step}
index={index}
isActive={index === activeStep}
isCompleted={index < activeStep}
hasError={!!errors[index]}
isLastStep={index === steps.length - 1}
/>
))}
</div>
);
};

export default StepList;
17 changes: 17 additions & 0 deletions src/components/Stepper/js/StepNumber.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react';

const StepNumber = ({ number, isCompleted, isActive, hasError }) => {
const classNames = ['step-number'];

if (hasError) {
classNames.push('error');
} else if (isCompleted) {
classNames.push('completed');
} else if (isActive) {
classNames.push('active');
}

return <div className={classNames.join(' ')}>{hasError ? '!' : number}</div>;
};

export default StepNumber;
Loading

0 comments on commit b517a35

Please sign in to comment.