diff --git a/site/content/docs/react/components/stepper/a-ss.html b/site/content/docs/react/components/stepper/a-ss.html new file mode 100644 index 0000000..083d6d1 --- /dev/null +++ b/site/content/docs/react/components/stepper/a-ss.html @@ -0,0 +1,12 @@ +--- +--- + +
  • + Import +
  • +
  • + Properties +
  • +
  • + Events +
  • \ No newline at end of file diff --git a/site/content/docs/react/components/stepper/a.html b/site/content/docs/react/components/stepper/a.html new file mode 100644 index 0000000..9fab41e --- /dev/null +++ b/site/content/docs/react/components/stepper/a.html @@ -0,0 +1,779 @@ +--- +--- + + +
    + +
    + +

    + Import +

    + + + {{< twsnippet/no-demo id="api-example41" >}} + + +
    + + +
    + + +
    + +

    + Properties +

    + +

    + TEStepper +

    + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Name + + Type + + Default + Description
    + activeStep + + Number + + - + + Controls the active step. + In most cases the value should be managed with onChange handler. +
    + customValidation + + (validableElement: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) => boolean; + + - + + Sets custom validation for each validable element in linear stepper. +
    + defaultStep + + Number + + - + + Sets default step. Does not change the active step. +
    + linear + + bolean + + false + + Linear stepper prevents going back to the previous step after it was completed. +
    + theme + + object + + {} + + Allows to change the Tailwind classes used in the component. +
    + type + + 'vertical' | 'horizontal' + + 'horizontal' + + Sets stepper view mode. +
    +
    +
    +
    +
    + +

    + TEStepperStep +

    + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Name + + Type + + Default + Description
    + theme + + object + + {} + + Allows to change the Tailwind classes used in the component. +
    + headIcon + + ReactNode + + '' + + Sets the icon in the step head. +
    + headText + + ReactNode + + '' + + Sets the text in the step head. +
    +
    +
    +
    +
    +
    + + +
    + +
    + +

    + Classes +

    + +

    Custom classes can be passed via theme prop. Create an object with classes like below and pass it to the prop.

    + +

    + TEStepper +

    + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + +
    + Name + + Default + Description
    + stepperHorizontal + + relative m-0 flex list-none justify-between overflow-hidden p-0 transition-[height] duration-200 ease-in-out + + Sets styles to the TEStepper element in horizontal mode. +
    + stepperVertical + + relative m-0 w-full list-none overflow-hidden p-0 transition-[height] duration-200 ease-in-out + + Sets styles to the TEStepper element in vertical mode. +
    +
    +
    +
    +
    + +

    + TEStepperStep +

    + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Name + + Default + Description
    + stepperStep + + w-[4.5rem] flex-auto + + Sets styles to the stepperStep element. +
    + stepperStepVertical + + relative h-fit after:absolute after:left-[2.45rem] after:top-[3.6rem] after:mt-px after:h-[calc(100%-2.45rem)] after:w-px after:bg-[#e0e0e0] dark:after:bg-neutral-600 + + Sets styles to the stepperStep element in vertical mode. +
    + stepperLastStepVertical + + relative h-fit + + Sets styles to the last stepperStep element in vertical mode. +
    + disabledStep + + pointer-events-none + + Sets styles to disabled step in noEditable mode. +
    + stepperHeadHorizontal + + flex cursor-pointer items-center leading-[1.3rem] no-underline before:mr-2 before:h-px before:w-full before:flex-1 before:bg-[#e0e0e0] before:content-[''] after:ml-2 after:h-px after:w-full after:flex-1 after:bg-[#e0e0e0] after:content-[''] hover:bg-[#f9f9f9] focus:outline-none dark:before:bg-neutral-600 dark:after:bg-neutral-600 dark:hover:bg-[#3b3b3b] + + Sets styles to the stepperHead element in horizontal mode. +
    + stepperFirstStepHeadHorizontal + + flex cursor-pointer items-center pl-2 leading-[1.3rem] no-underline after:ml-2 after:h-px after:w-full after:flex-1 after:bg-[#e0e0e0] after:content-[''] hover:bg-[#f9f9f9] focus:outline-none dark:after:bg-neutral-600 dark:hover:bg-[#3b3b3b] + + Sets styles to the stepperHead element in horizontal mode for the first step. +
    + stepperLastStepHeadHorizontal + + flex cursor-pointer items-center pr-2 leading-[1.3rem] no-underline before:mr-2 before:h-px before:w-full before:flex-1 before:bg-[#e0e0e0] before:content-[''] hover:bg-[#f9f9f9] focus:outline-none dark:before:bg-neutral-600 dark:after:bg-neutral-600 dark:hover:bg-[#3b3b3b] + + Sets styles to the stepperHead element in horizontal mode for the last step. +
    + stepperHeadVertical + + flex cursor-pointer items-center p-6 leading-[1.3rem] no-underline hover:bg-[#f9f9f9] focus:outline-none dark:hover:bg-[#3b3b3b] + + Sets styles to the stepperHead element in vertical mode. +
    + stepperHeadIconHorizontal + + my-6 mr-2 flex h-[1.938rem] w-[1.938rem] items-center justify-center rounded-full bg-[#6d6d6d] text-sm font-medium text-[#fff] dark:bg-[#757575] + + Sets styles to the stepperHeadIcon element in horizontal mode. +
    + stepperHeadIconVertical + + mr-3 flex h-[1.938rem] w-[1.938rem] items-center justify-center rounded-full bg-[#6d6d6d] text-sm font-medium text-[#fff] dark:bg-[#757575] + + Sets styles to the stepperHeadIcon element in vertical mode. +
    + stepperHeadIconCompletedBg + + !bg-success-100 !text-success-700 dark:!bg-[#04201f] dark:!text-[#72c894] + + Sets background color applied to the stepperHeadIcon element when step is completed. +
    + stepperHeadIconActiveBg + + !bg-primary-100 !text-primary-700 dark:!bg-[#0c1728] dark:!text-[#628dd5] + + Sets background color applied to the stepperHeadIcon element when the step is active. +
    + stepperHeadIconInvalidBg + + !bg-danger-100 !text-danger-700 dark:!bg-[#2c0f14] dark:!text-[#e37083] + + Sets background color applied to the stepperHeadIcon element when the step is invalid. +
    + stepperHeadIconDisabledBg + + !bg-[#6d6d6d] !text-neutral-300 dark:!bg-[#757575] + + Sets background color applied to the stepperHeadIcon element when the step is disabled. +
    + stepperHeadText + + text-neutral-500 after:flex after:text-[0.8rem] dark:text-neutral-300 + + Sets styles to the stepperHeadText element. +
    + stepperHeadTextActive + + font-medium after:flex after:text-[0.8rem] after:content-[data-content] + + Sets styles to the stepperHeadText element when step is active. +
    + stepperContentWrapper + + transition-all duration-500 ease-in-out overflow-hidden + + Sets styles to the stepperContent wrapper element. +
    + stepperContent + + absolute left-0 w-full p-4 transition-all duration-500 ease-in-out translate-0 + + Sets styles to the stepperContent element. +
    + stepperVerticalContent + + transition-[height,_margin-bottom,_padding-top,_padding-bottom] left-0 overflow-hidden pl-[3.75rem] pr-6 duration-300 ease-in-out + + Sets styles to the stepperContent element in vertical mode. +
    + stepperContentTranslateLeft + + -translate-x-[150%] + + Additional animation style to the stepperContent element when step is active and it is on the left side of the screen. +
    + stepperContentTranslateRight + + translate-x-[150%] + + Additional animation style to the stepperContent element when step is active and it is on the right side of the screen. +
    +
    +
    +
    +
    +
    + + +
    + + +
    + +

    + Events +

    + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + +
    + Name + + Type + + Default + Description
    + onChange + + (prevStepId: number, targetStepId: number) => void; + + - + + Event fires when the stepper demands to change step. +
    + onInvalid + + (prevStepId: number, targetStepId: number) => void; + + - + + Event fires when the stepper demands to change step, but linear option prevents it. +
    +
    +
    +
    +
    + + + {{< twsnippet/no-demo id="api-example6" >}} + + + +
    + +
    \ No newline at end of file diff --git a/site/content/docs/react/components/stepper/index-ss.html b/site/content/docs/react/components/stepper/index-ss.html new file mode 100644 index 0000000..5238d28 --- /dev/null +++ b/site/content/docs/react/components/stepper/index-ss.html @@ -0,0 +1,21 @@ +--- +--- + +
  • + Basic example +
  • +
  • + Controlled step +
  • +
  • + Vertical +
  • +
  • + No editable +
  • +
  • + Linear +
  • +
  • + Linear with custom validation +
  • diff --git a/site/content/docs/react/components/stepper/index.html b/site/content/docs/react/components/stepper/index.html new file mode 100644 index 0000000..7128596 --- /dev/null +++ b/site/content/docs/react/components/stepper/index.html @@ -0,0 +1,823 @@ +--- +title: "Stepper" +date: 2023-11-20T16:00:58+02:00 +draft: false +main_title: "Stepper" +subheading: "Tailwind CSS React Stepper" +seo_title: "Tailwind CSS React Stepper - Free Examples & Tutorial" +description: "Use responsive stepper component with helper examples for stepper ui, stepper form, vertical stepper, progress steps & more. Free download, open-source license." +image: "https://tecdn.b-cdn.net/img/docs/components/stepper.webp" +video: "https://www.youtube.com/watch?v=-GmnyjgI4Jc&ab_channel=Keepcoding" +url: "docs/react/components/stepper/" +menu: + components: + name: "Stepper" +--- + + +
    + +

    + Basic example +

    + +

    + Use horizontally aligned timeline component to show a series of data in a chronological order. +

    + + + {{< twsnippet/demo-iframe id="example1" iframe="/components/stepper/examples/stepper-basic-example" title="Stepper Basic Example" >}} + + + +
    + + +
    + +
    + + + +
    + +

    + Controlled stepper +

    + +

    + Use the activeStep prop to control the stepper. +

    + + + {{< twsnippet/demo-iframe id="example2" iframe="/components/stepper/examples/stepper-controlled-step" title="Stepper controlled step" >}} + + + + +
    + + + +
    + +

    + Vertical stepper +

    + +

    + Add type='vertical' to change stepper orientation. +

    + + + {{< twsnippet/demo-iframe id="example3" iframe="/components/stepper/examples/stepper-vertical-example" title="Stepper vertical" >}} + + + +
    + + + +
    + +

    + No editable stepper +

    + +

    + Add noEditable to disable editing a completed step. +

    + + + {{< twsnippet/demo-iframe id="example4" iframe="/components/stepper/examples/stepper-no-editable-example" title="Stepper no editable" >}} + + + +
    + + + +
    + +

    + Linear stepper +

    + +

    + Add linear to enable step validation before proceeding to next step. +

    + + + {{< twsnippet/demo-iframe id="example5" iframe="/components/stepper/examples/stepper-linear-example" title="Stepper linear" >}} + + + +
    + + + +
    + +

    + Linear stepper with custom validation +

    + +

    + Use customValidation to enable custom step validation before proceeding to next step. +

    + + + {{< twsnippet/demo-iframe id="example6" iframe="/components/stepper/examples/stepper-linear-custom-validation-example" title="Stepper linear" >}} + + + +
    + + +
    + +

    + If you are looking for more advanced options, try + Bootstrap Stepper + from MDBootstrap. +

    + diff --git a/site/static/search-react.json b/site/static/search-react.json index 4d72ed4..cb4c4c7 100644 --- a/site/static/search-react.json +++ b/site/static/search-react.json @@ -137,6 +137,12 @@ "keywords": ["loading"], "category": "Components" }, + { + "href": "/docs/react/components/stepper/", + "name": "Stepper", + "keywords": ["stepper", "progress", "steps", "wizard"], + "category": "Components" + }, { "href": "/docs/react/forms/checkbox/", "name": "Checkbox", diff --git a/src/demo/pages.tsx b/src/demo/pages.tsx index ed11b84..9271c70 100644 --- a/src/demo/pages.tsx +++ b/src/demo/pages.tsx @@ -58,6 +58,7 @@ import ToastsPage from "./pages/components/toasts/ToastsPage"; import SelectPage from "./pages/forms/select/SelectPage"; import CarouselPage from "./pages/components/carousel/CarouselPage"; import VideoCarouselPage from "./pages/components/video-carousel/VideoCarouselPage"; +import StepperPage from "./pages/components/stepper/StepperPage"; //examples pages import ButtonExamples from "./pages/components/buttons/exampleList"; @@ -94,6 +95,7 @@ import ToastsExamples from "./pages/components/toasts/exampleList"; import SelectExamples from "./pages/forms/select/exampleList"; import CarouselExamples from "./pages/components/carousel/exampleList"; import VideoCarouselExamples from "./pages/components/video-carousel/exampleList"; +import StepperExamples from "./pages/components/stepper/exampleList"; interface Pages { name: string; @@ -197,6 +199,11 @@ const componentsPages: Pages[] = [ path: "/components/video-carousel", element: , }, + { + name: "stepper", + path: "/components/stepper", + element: , + }, ]; const contentStylesPages: Pages[] = [ @@ -408,6 +415,7 @@ export const examplesPages: Pages[] = [ ...SelectExamples, ...CarouselExamples, ...VideoCarouselExamples, + ...StepperExamples, ]; export default demoPages; diff --git a/src/demo/pages/components/stepper/StepperPage.tsx b/src/demo/pages/components/stepper/StepperPage.tsx new file mode 100644 index 0000000..4db7b89 --- /dev/null +++ b/src/demo/pages/components/stepper/StepperPage.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import StepperControlledStep from "./examples/StepperControlledStep"; +import StepperVerticalExample from "./examples/StepperVerticalExample"; +import StepperBasicExample from "./examples/StepperBasicExample"; +import StepperNoEditable from "./examples/StepperNoEditable"; +import StepperLinearExample from "./examples/StepperLinear"; +import StepperLinearCustomValidationExample from "./examples/StepperLinearCustomValidation"; + +const StepperPage = () => { + return ( +
    +
    +

    + Basic example +

    +
    + +
    + +
    + +

    + Controlled active step +

    + +
    + +
    + +
    + +

    + Vertical stepper +

    + +
    + +
    + +
    + +

    + Stepper with no editable steps +

    + +
    + +
    + +
    + +

    + Linear stepper +

    + +
    + +
    + +
    + +

    + Linear stepper with custom validation +

    + +
    + +
    +
    +
    + ); +}; + +export default StepperPage; diff --git a/src/demo/pages/components/stepper/exampleList.tsx b/src/demo/pages/components/stepper/exampleList.tsx new file mode 100644 index 0000000..aa8f7d0 --- /dev/null +++ b/src/demo/pages/components/stepper/exampleList.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import StepperBasicExample from "./examples/StepperBasicExample"; +import StepperControlledStep from "./examples/StepperControlledStep"; +import StepperVerticalExample from "./examples/StepperVerticalExample"; +import StepperLinearExample from "./examples/StepperLinear"; +import StepperLinearCustomValidationExample from "./examples/StepperLinearCustomValidation"; +import StepperNoEditable from "./examples/StepperNoEditable"; + +export default [ + { + name: "StepperBasicExample", + path: "/components/stepper/examples/stepper-basic-example", + element: , + }, + { + name: "StepperControlledStep", + path: "/components/stepper/examples/stepper-controlled-step", + element: , + }, + { + name: "StepperVerticalExample", + path: "/components/stepper/examples/stepper-vertical-example", + element: , + }, + { + name: "StepperNoEditable", + path: "/components/stepper/examples/stepper-no-editable-example", + element: , + }, + { + name: "StepperLinearExample", + path: "/components/stepper/examples/stepper-linear-example", + element: , + }, + { + name: "StepperLinearCustomValidationExample", + path: "/components/stepper/examples/stepper-linear-custom-validation-example", + element: , + }, +]; diff --git a/src/demo/pages/components/stepper/examples/StepperBasicExample.tsx b/src/demo/pages/components/stepper/examples/StepperBasicExample.tsx new file mode 100644 index 0000000..18cd984 --- /dev/null +++ b/src/demo/pages/components/stepper/examples/StepperBasicExample.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { TEStepper, TEStepperStep } from "tw-elements-react"; + +export default function StepperBasicExample(): JSX.Element { + return ( + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. + + + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi + ut aliquip ex ea commodo consequat. + + + Duis aute irure dolor in reprehenderit in voluptate velit esse cillum + dolore eu fugiat nulla pariatur. + + + ); +} diff --git a/src/demo/pages/components/stepper/examples/StepperControlledStep.tsx b/src/demo/pages/components/stepper/examples/StepperControlledStep.tsx new file mode 100644 index 0000000..ca6d7dc --- /dev/null +++ b/src/demo/pages/components/stepper/examples/StepperControlledStep.tsx @@ -0,0 +1,28 @@ +import React, { useState } from "react"; +import { TEStepper, TEStepperStep } from "tw-elements-react"; + +export default function StepperControlledStep(): JSX.Element { + const [activeStep, setActiveStep] = useState(1); + + return ( + { + setActiveStep(next); + }} + > + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. + + + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi + ut aliquip ex ea commodo consequat. + + + Duis aute irure dolor in reprehenderit in voluptate velit esse cillum + dolore eu fugiat nulla pariatur. + + + ); +} diff --git a/src/demo/pages/components/stepper/examples/StepperLinear.tsx b/src/demo/pages/components/stepper/examples/StepperLinear.tsx new file mode 100644 index 0000000..a92161f --- /dev/null +++ b/src/demo/pages/components/stepper/examples/StepperLinear.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { TEStepper, TEStepperStep, TEInput } from "tw-elements-react"; + +export default function StepperLinearExample() { + return ( +
    + + +

    + Enter your email and start the adventure of your life! +

    + +
    + +
    +
    + +

    + You're almost there! Just a few more details! +

    +
    + +
    +
    + +
    + +
    + +
    +
    + +
    +

    + You're all set! We will contact you soon! +

    +
    +
    +
    +
    + ); +} diff --git a/src/demo/pages/components/stepper/examples/StepperLinearCustomValidation.tsx b/src/demo/pages/components/stepper/examples/StepperLinearCustomValidation.tsx new file mode 100644 index 0000000..4b3fe54 --- /dev/null +++ b/src/demo/pages/components/stepper/examples/StepperLinearCustomValidation.tsx @@ -0,0 +1,218 @@ +import React, { useState } from "react"; +import { TEStepper, TEStepperStep, TEInput } from "tw-elements-react"; + +const validTheme = { + notchLeadingDefault: "border-green-300 dark:border-green-600", + notchMiddleDefault: "border-green-300 dark:border-green-600", + notchTrailingDefault: "border-green-300 dark:border-green-600", + focusedNotchLeadingDefault: + "shadow-[-1px_0_0_#10b339,_0_1px_0_0_#10b339,_0_-1px_0_0_#10b339] border-green-300 dark:border-green-600", + focusedNotchMiddleDefault: + "shadow-[0_1px_0_0_#10b339] border-green-300 dark:border-green-600", + focusedNotchTrailingDefault: + "shadow-[1px_0_0_#10b339,_0_1px_0_0_#10b339,_0_-1px_0_0_#10b339] border-green-300 dark:border-green-600", + labelDefault: "text-green-500 dark:text-green-600", +}; + +const invalidTheme = { + notchLeadingDefault: "border-red-300 dark:border-red-600", + notchMiddleDefault: "border-red-300 dark:border-red-600", + notchTrailingDefault: "border-red-300 dark:border-red-600", + focusedNotchLeadingDefault: + "shadow-[-1px_0_0_#f87171,_0_1px_0_0_#f87171,_0_-1px_0_0_#f87171] border-red-300 dark:border-red-600", + focusedNotchMiddleDefault: + "shadow-[0_1px_0_0_#f87171] border-red-300 dark:border-red-600", + focusedNotchTrailingDefault: + "shadow-[1px_0_0_#f87171,_0_1px_0_0_#f87171,_0_-1px_0_0_#f87171] border-red-300 dark:border-red-600", + labelDefault: "text-red-300 dark:text-red-600", +}; + +type formItemsValidityTypes = { + email: boolean | null; + firstName: boolean | null; + lastName: boolean | null; + phone: boolean | null; +}; + +export default function StepperLinearCustomValidationExample() { + const [formItemsValidity, setFormItemsValidity] = + useState({ + email: false, + firstName: false, + lastName: false, + phone: false, + }); + + const [validatedSteps, setValidatedSteps] = useState({ + step1: false, + step2: false, + step3: false, + }); + + return ( +
    + { + setValidatedSteps({ + ...validatedSteps, + ["step" + nextStep]: false, + }); + }} + onInvalid={(stepId) => { + setValidatedSteps({ + ...validatedSteps, + ["step" + stepId]: true, + }); + }} + linear + customValidation={(el) => { + let isValid = true; + + switch (el.name) { + case "email": + const isDomainExample = el.value.split("@")[1] === "example.com"; + isValid = isDomainExample; + !isValid && + el.setCustomValidity( + "Please enter a valid email address with @example.com domain" + ); + + el.reportValidity(); + setFormItemsValidity((prev) => { + return { + ...prev, + email: isValid, + }; + }); + break; + + case "firstName": + isValid = el.checkValidity(); + setFormItemsValidity((prev) => { + return { + ...prev, + firstName: isValid, + }; + }); + + break; + + case "lastName": + isValid = el.checkValidity(); + setFormItemsValidity((prev) => { + return { + ...prev, + lastName: isValid, + }; + }); + + break; + + case "phone": + isValid = el.checkValidity(); + setFormItemsValidity((prev) => { + return { + ...prev, + phone: isValid, + }; + }); + + break; + } + + return isValid; + }} + > + +

    + Enter your email and start the adventure of your life! +

    + +
    + { + e.target.setCustomValidity(" "); + }} + onInput={(e) => { + (e.target as HTMLInputElement).setCustomValidity(" "); + }} + required + theme={ + !validatedSteps.step1 + ? {} + : formItemsValidity.email + ? validTheme + : invalidTheme + } + /> +
    +
    + +

    + You're almost there! Just a few more details! +

    +
    + +
    +
    + +
    + +
    + +
    +
    + +
    +

    + You're all set! We will contact you soon! +

    +
    +
    +
    +
    + ); +} diff --git a/src/demo/pages/components/stepper/examples/StepperNoEditable.tsx b/src/demo/pages/components/stepper/examples/StepperNoEditable.tsx new file mode 100644 index 0000000..986b1a5 --- /dev/null +++ b/src/demo/pages/components/stepper/examples/StepperNoEditable.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { TEStepper, TEStepperStep } from "tw-elements-react"; + +export default function StepperNoEditable() { + return ( + + +

    + After changing the active step, the previous steps can't be accessed + anymore. +

    +
    + +

    + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Pariatur, + tempora esse iusto tempore quod, aspernatur incidunt minima magnam, + quaerat aut vel expedita illum molestias repellendus asperiores id + suscipit saepe. Maxime. +

    +
    + +

    + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam + voluptatum, quod, voluptate quia, ipsam illum quibusdam dolorum + voluptatibus laboriosam quae voluptates? Quisquam, voluptatibus + voluptas. Quisquam, voluptatibus voluptas. Quisquam, voluptatibus + voluptas. +

    +
    +
    + ); +} diff --git a/src/demo/pages/components/stepper/examples/StepperVerticalExample.tsx b/src/demo/pages/components/stepper/examples/StepperVerticalExample.tsx new file mode 100644 index 0000000..c5fc0a7 --- /dev/null +++ b/src/demo/pages/components/stepper/examples/StepperVerticalExample.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { TEStepper, TEStepperStep } from "tw-elements-react"; + +export default function StepperVerticalExample(): JSX.Element { + return ( + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. + + + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi + ut aliquip ex ea commodo consequat. + + + Duis aute irure dolor in reprehenderit in voluptate velit esse cillum + dolore eu fugiat nulla pariatur. + + + ); +} diff --git a/src/lib/components/Stepper/Stepper.tsx b/src/lib/components/Stepper/Stepper.tsx new file mode 100644 index 0000000..33b82ad --- /dev/null +++ b/src/lib/components/Stepper/Stepper.tsx @@ -0,0 +1,154 @@ +import clsx from "clsx"; +import React, { useRef, useState, useMemo } from "react"; +import StepperContext from "./StepperContext"; +import { StepperStepProps } from "./StepperStep/types"; +import StepperTheme from "./stepperTheme"; +import useActiveValue from "../../hooks/useActiveValue"; +import type { StepperProps } from "./types"; +import { validateStepContent, checkStepsBetweenValidity } from "./utils/utils"; + +interface StepsValidity { + [key: string]: { + wasValidated: boolean; + isValid: boolean; + }; +} + +const TEStepper: React.FC = ({ + theme: customTheme, + className, + defaultStep = 1, + activeStep: activeStepProp, + children, + noEditable = false, + onChange, + onInvalid, + type = "horizontal", + linear, + style, + customValidation, +}) => { + const theme = { + ...StepperTheme, + ...customTheme, + }; + const vertical = type === "vertical"; + const classes = clsx( + vertical ? theme.stepperVertical : theme.stepperHorizontal, + className + ); + const [activeStepState, setActiveStepState] = useState(defaultStep); + const activeStep = useActiveValue(activeStepProp, activeStepState); + const stepperRef = useRef(null); + const [activeStepContent, setActiveStepContent] = + useState(null); + const [stepperHeight, setStepperHeight] = useState("auto"); + + const childrenArray = useMemo(() => { + return React.Children.toArray( + children + ) as React.ReactElement[]; + }, [children]); + + const [stepsValidity, setStepsValidity] = useState(() => { + if (!linear) { + return {}; + } + + const obj: StepsValidity = {}; + + childrenArray.forEach((_, i) => { + obj["step" + Number(i + 1)] = { + wasValidated: false, + isValid: false, + }; + }); + + return obj; + }); + + const onChangeHandler = (targetStepId: number) => { + if (noEditable && targetStepId < activeStep) { + return; + } + + if (linear) { + if (!activeStepContent) { + return; + } + + const isGoingForward = activeStep < targetStepId; + + const isCurrentStepValid = validateStepContent( + activeStepContent as HTMLElement, + customValidation + ); + + const isStepsBetweenValid = checkStepsBetweenValidity( + activeStep, + targetStepId, + stepsValidity, + setStepsValidity + ); + + setStepsValidity((prev) => { + return { + ...prev, + + ["step" + activeStep]: { + wasValidated: true, + isValid: isCurrentStepValid, + }, + + ["step" + targetStepId]: { + wasValidated: prev![`step${targetStepId}`].wasValidated, + isValid: false, + }, + }; + }); + if ((!isCurrentStepValid || !isStepsBetweenValid) && isGoingForward) { + onInvalid?.(activeStep, targetStepId); + return; + } + } + onChange?.(activeStep, targetStepId); + setActiveStepState(targetStepId); + }; + return ( + <> + +
      + {childrenArray.map((ChildComponent, index: number) => { + return React.cloneElement(ChildComponent, { + itemId: index + 1, + activeStep, + key: "stepper-step-" + index, + onChange: onChangeHandler, + }); + })} +
    +
    + + ); +}; + +export type { StepsValidity }; +export default TEStepper; diff --git a/src/lib/components/Stepper/StepperContext.ts b/src/lib/components/Stepper/StepperContext.ts new file mode 100644 index 0000000..6b8f6ac --- /dev/null +++ b/src/lib/components/Stepper/StepperContext.ts @@ -0,0 +1,32 @@ +import { createContext } from "react"; +import { StepsValidity } from "./Stepper"; + +interface StepperContextProps { + activeStep: number; + onChange?: (id: number) => void; + stepperRef: React.RefObject | null; + stepsValidity: StepsValidity | null; + stepperHeight: string; + setStepperHeight: (height: string) => void; + vertical: boolean; + stepsAmount: number; + linear?: boolean; + setActiveStepContent: React.Dispatch>; + noEditable?: boolean; +} + +const StepperContext = createContext({ + activeStep: 1, + onChange: () => {}, + stepperRef: null, + stepsValidity: null, + stepperHeight: "0", + setStepperHeight: () => {}, + vertical: false, + stepsAmount: 0, + linear: false, + setActiveStepContent: () => {}, + noEditable: false, +}); + +export default StepperContext; diff --git a/src/lib/components/Stepper/StepperStep/StepperStep.tsx b/src/lib/components/Stepper/StepperStep/StepperStep.tsx new file mode 100644 index 0000000..e5f7b78 --- /dev/null +++ b/src/lib/components/Stepper/StepperStep/StepperStep.tsx @@ -0,0 +1,143 @@ +import React, { useState, useMemo, useRef, useContext, useEffect } from "react"; +import clx from "clsx"; +import StepperStepTheme from "./stepperStepTheme"; +import StepperContext from "../StepperContext"; +import { StepperStepProps } from "./types"; +import useHeadIconClasses from "../hooks/useHeadIconClasses"; +import useIsStepCompleted from "../hooks/useIsStepCompleted"; +import useStepperHeight from "../hooks/useHorizontalStepperHeight"; +import { getTranslateDirection } from "../utils/utils"; +import useHeadClasses from "../hooks/useHeadClasses"; + +const TEStepperStep: React.FC = ({ + theme: customTheme, + className, + itemId = 1, + headIcon = "", + headText = "", + children, + style, +}) => { + const [isDisabled, setIsDisabled] = useState(false); + const stepRef = useRef(null); + const headRef = useRef(null); + const contentRef = useRef(null); + const { + activeStep, + noEditable, + onChange, + vertical, + linear, + stepsAmount, + setActiveStepContent, + stepsValidity, + } = useContext(StepperContext); + + const isInvalid = useMemo(() => { + if (!linear) { + return false; + } + return ( + stepsValidity?.["step" + itemId].wasValidated && + !stepsValidity?.["step" + itemId].isValid + ); + }, [stepsValidity, itemId]); + + const animationDirection = useMemo(() => { + return getTranslateDirection(activeStep, itemId); + }, [activeStep, itemId]); + + const isActive = useMemo(() => { + return activeStep === itemId; + }, [activeStep, itemId]); + + const isLastStep = useMemo(() => { + return itemId === stepsAmount; + }, [itemId, stepsAmount]); + + const isCompleted = useIsStepCompleted(activeStep, isActive, itemId); + const theme = { + ...StepperStepTheme, + ...customTheme, + }; + + const headIconClasses = useHeadIconClasses( + theme, + vertical, + isActive, + isCompleted, + isInvalid, + isDisabled + ); + + const headTextClasses = clx( + isActive ? theme.stepperHeadTextActive : theme.stepperHeadText, + isDisabled && theme.disabledStep + ); + + useEffect(() => { + if (isCompleted && noEditable && !isActive) { + setIsDisabled(true); + } + }, [isCompleted, noEditable, isActive]); + + const stepperHeadClasses = clx(useHeadClasses(theme, itemId)); + const stepperStepClasses = clx( + vertical + ? isLastStep + ? theme.stepperLastStepVertical + : theme.stepperStepVertical + : theme.stepperStep, + isDisabled && theme.disabledStep, + className + ); + + const dynamicAnimationDirection: string = `stepperContentTranslate${animationDirection}`; + const stepperContentWrapperClasses = clx( + theme.stepperContentWrapper, + isActive ? "visible" : "invisible", + vertical ? "grid" : "block", + isActive ? "pb-6" : "pb-0" + ); + const stepperContentClasses = clx( + vertical ? theme.stepperVerticalContent : theme.stepperContent, + !vertical && theme[dynamicAnimationDirection as keyof typeof theme] + ); + + useEffect(() => { + if (isActive && contentRef.current && setActiveStepContent) { + setActiveStepContent(contentRef.current); + } + }, [isActive, contentRef, children]); + + const headClickHandler = () => { + itemId != activeStep && onChange?.(itemId); + }; + + useStepperHeight(isActive, headRef, contentRef, vertical, children); + + return ( +
  • +
    + {headIcon} + {headText} +
    +
    +
    + {children} +
    +
    +
  • + ); +}; + +export default TEStepperStep; diff --git a/src/lib/components/Stepper/StepperStep/stepperStepTheme.ts b/src/lib/components/Stepper/StepperStep/stepperStepTheme.ts new file mode 100644 index 0000000..3bcd87e --- /dev/null +++ b/src/lib/components/Stepper/StepperStep/stepperStepTheme.ts @@ -0,0 +1,66 @@ +interface StepperStepThemeProps { + stepperStep?: string; + stepperStepVertical?: string; + stepperLastStepVertical?: string; + disabledStep?: string; + stepperHeadHorizontal?: string; + stepperFirstStepHeadHorizontal?: string; + stepperLastStepHeadHorizontal?: string; + stepperHeadVertical?: string; + stepperHeadIconHorizontal?: string; + stepperHeadIconVertical?: string; + stepperHeadIconActiveBg?: string; + stepperHeadIconInvalidBg?: string; + stepperHeadIconDisabledBg?: string; + stepperHeadIconCompletedBg?: string; + stepperHeadText?: string; + stepperHeadTextActive?: string; + stepperContent?: string; + stepperContentTranslateLeft?: string; + stepperContentTranslateRight?: string; + stepperVerticalContent?: string; + stepperContentWrapper?: string; +} + +const StepperStepTheme: StepperStepThemeProps = { + stepperStep: "w-[4.5rem] flex-auto", + stepperStepVertical: + "relative h-fit after:absolute after:left-[2.45rem] after:top-[3.6rem] after:mt-px after:h-[calc(100%-2.45rem)] after:w-px after:bg-[#e0e0e0] dark:after:bg-neutral-600", + stepperLastStepVertical: "relative h-fit", + disabledStep: "pointer-events-none", + stepperHeadHorizontal: + "flex cursor-pointer items-center leading-[1.3rem] no-underline before:mr-2 before:h-px before:w-full before:flex-1 before:bg-[#e0e0e0] before:content-[''] after:ml-2 after:h-px after:w-full after:flex-1 after:bg-[#e0e0e0] after:content-[''] hover:bg-[#f9f9f9] focus:outline-none dark:before:bg-neutral-600 dark:after:bg-neutral-600 dark:hover:bg-[#3b3b3b]", + stepperFirstStepHeadHorizontal: + "flex cursor-pointer items-center pl-2 leading-[1.3rem] no-underline after:ml-2 after:h-px after:w-full after:flex-1 after:bg-[#e0e0e0] after:content-[''] hover:bg-[#f9f9f9] focus:outline-none dark:after:bg-neutral-600 dark:hover:bg-[#3b3b3b]", + stepperLastStepHeadHorizontal: + "flex cursor-pointer items-center pr-2 leading-[1.3rem] no-underline before:mr-2 before:h-px before:w-full before:flex-1 before:bg-[#e0e0e0] before:content-[''] hover:bg-[#f9f9f9] focus:outline-none dark:before:bg-neutral-600 dark:after:bg-neutral-600 dark:hover:bg-[#3b3b3b]", + stepperHeadVertical: + "flex cursor-pointer items-center p-6 leading-[1.3rem] no-underline hover:bg-[#f9f9f9] focus:outline-none dark:hover:bg-[#3b3b3b]", + stepperHeadIconHorizontal: + "my-6 mr-2 flex h-[1.938rem] w-[1.938rem] items-center justify-center rounded-full bg-[#6d6d6d] text-sm font-medium text-[#fff] dark:bg-[#757575]", + stepperHeadIconVertical: + "mr-3 flex h-[1.938rem] w-[1.938rem] items-center justify-center rounded-full bg-[#6d6d6d] text-sm font-medium text-[#fff] dark:bg-[#757575]", + stepperHeadIconCompletedBg: + "!bg-success-100 !text-success-700 dark:!bg-[#04201f] dark:!text-[#72c894]", + stepperHeadIconActiveBg: + "!bg-primary-100 !text-primary-700 dark:!bg-[#0c1728] dark:!text-[#628dd5]", + stepperHeadIconInvalidBg: + "!bg-danger-100 !text-danger-700 dark:!bg-[#2c0f14] dark:!text-[#e37083]", + stepperHeadIconDisabledBg: + "!bg-[#6d6d6d] !text-neutral-300 dark:!bg-[#757575]", + stepperHeadText: + "text-neutral-500 after:flex after:text-[0.8rem] dark:text-neutral-300", + stepperHeadTextActive: + "font-medium after:flex after:text-[0.8rem] after:content-[data-content]", + stepperContentWrapper: + "transition-all duration-500 ease-in-out overflow-hidden", + stepperContent: + "absolute left-0 w-full p-4 transition-all duration-500 ease-in-out translate-0", + stepperContentTranslateLeft: "-translate-x-[150%]", + stepperContentTranslateRight: "translate-x-[150%]", + stepperVerticalContent: + "transition-[height,_margin-bottom,_padding-top,_padding-bottom] left-0 overflow-hidden pl-[3.75rem] pr-6 duration-300 ease-in-out", +}; + +export default StepperStepTheme; +export type { StepperStepThemeProps }; diff --git a/src/lib/components/Stepper/StepperStep/types.ts b/src/lib/components/Stepper/StepperStep/types.ts new file mode 100644 index 0000000..fb8fa7f --- /dev/null +++ b/src/lib/components/Stepper/StepperStep/types.ts @@ -0,0 +1,13 @@ +import React from "react"; +import { BaseComponent } from "../../../types/baseComponent"; + +import type { StepperStepThemeProps } from "./stepperStepTheme"; + +interface StepperStepProps extends BaseComponent { + theme?: StepperStepThemeProps; + itemId?: number; + headIcon?: React.ReactNode; + headText?: React.ReactNode; +} + +export type { StepperStepProps }; diff --git a/src/lib/components/Stepper/hooks/useHeadClasses.ts b/src/lib/components/Stepper/hooks/useHeadClasses.ts new file mode 100644 index 0000000..cd44ee9 --- /dev/null +++ b/src/lib/components/Stepper/hooks/useHeadClasses.ts @@ -0,0 +1,27 @@ +import { useContext, useMemo } from "react"; +import StepperContext from "../StepperContext"; +import type { StepperStepThemeProps } from "../StepperStep/stepperStepTheme"; + +export default function useHeadClasses( + theme: StepperStepThemeProps, + itemId: number +) { + const { stepsAmount, vertical } = useContext(StepperContext); + + const isFirstStep = useMemo(() => itemId === 1, [itemId]); + const isLastStep = useMemo( + () => itemId === stepsAmount, + [itemId, stepsAmount] + ); + + if (vertical) { + return theme.stepperHeadVertical; + } + if (isFirstStep) { + return theme.stepperFirstStepHeadHorizontal; + } + if (isLastStep) { + return theme.stepperLastStepHeadHorizontal; + } + return theme.stepperHeadHorizontal; +} diff --git a/src/lib/components/Stepper/hooks/useHeadIconClasses.ts b/src/lib/components/Stepper/hooks/useHeadIconClasses.ts new file mode 100644 index 0000000..a67da18 --- /dev/null +++ b/src/lib/components/Stepper/hooks/useHeadIconClasses.ts @@ -0,0 +1,43 @@ +import clsx from "clsx"; + +const useHeadIconClasses = ( + theme: any, + vertical: boolean, + isActive: boolean, + isCompleted: boolean, + isInvalid?: boolean, + isDisabled?: boolean +): string => { + const { + stepperHeadIconHorizontal, + stepperHeadIconVertical, + stepperHeadIconActiveBg, + stepperHeadIconCompletedBg, + stepperHeadIconInvalidBg, + stepperHeadIconDisabledBg, + } = theme; + + const headIconTheme = vertical + ? stepperHeadIconVertical + : stepperHeadIconHorizontal; + + if (isInvalid) { + return clsx(headIconTheme, stepperHeadIconInvalidBg); + } + + if (isActive) { + return clsx(headIconTheme, stepperHeadIconActiveBg); + } + + if (isDisabled) { + return clsx(headIconTheme, stepperHeadIconDisabledBg); + } + + if (isCompleted) { + return clsx(headIconTheme, stepperHeadIconCompletedBg); + } + + return headIconTheme; +}; + +export default useHeadIconClasses; diff --git a/src/lib/components/Stepper/hooks/useHorizontalStepperHeight.ts b/src/lib/components/Stepper/hooks/useHorizontalStepperHeight.ts new file mode 100644 index 0000000..dad07c0 --- /dev/null +++ b/src/lib/components/Stepper/hooks/useHorizontalStepperHeight.ts @@ -0,0 +1,46 @@ +import { useEffect, useContext, RefObject } from "react"; +import StepperContext from "../StepperContext"; + +const useStepperHeight = ( + isActive: boolean, + headRef: RefObject, + stepRef: RefObject, + vertical: boolean, + children: React.ReactNode | React.ReactNode[] +) => { + const { setStepperHeight } = useContext(StepperContext); + + useEffect(() => { + if (vertical) { + return; + } + const headHeight = headRef.current?.offsetHeight || 0; + + const handleResize = (entries: Array) => { + if (!isActive || !stepRef.current) { + return; + } + const stepHeight = entries[0].contentRect.height; + const computed = window.getComputedStyle(stepRef.current as Element); + + const offsetY = + parseFloat(computed.paddingTop) + + parseFloat(computed.paddingBottom) + + parseFloat(computed.marginBottom) + + parseFloat(computed.marginTop); + + setStepperHeight(`${stepHeight + offsetY + headHeight}px`); + }; + + const observer = new ResizeObserver((entries) => { + handleResize(entries); + }); + + observer.observe(stepRef.current as Element); + return () => { + observer.disconnect(); + }; + }, [isActive, children]); +}; + +export default useStepperHeight; diff --git a/src/lib/components/Stepper/hooks/useIsStepCompleted.ts b/src/lib/components/Stepper/hooks/useIsStepCompleted.ts new file mode 100644 index 0000000..7af5ffe --- /dev/null +++ b/src/lib/components/Stepper/hooks/useIsStepCompleted.ts @@ -0,0 +1,23 @@ +import { useState, useEffect, useRef } from "react"; + +const useIsStepCompleted = ( + activeStep: number, + isActive: boolean, + itemId: number +) => { + const [isCompleted, setIsCompleted] = useState(false); + const wasActive = useRef(isActive || false); + + useEffect(() => { + if (isActive) { + wasActive.current = isActive; + } + if (wasActive.current && activeStep >= itemId) { + setIsCompleted(true); + } + }, [isActive]); + + return isCompleted; +}; + +export default useIsStepCompleted; diff --git a/src/lib/components/Stepper/stepperTheme.ts b/src/lib/components/Stepper/stepperTheme.ts new file mode 100644 index 0000000..10be936 --- /dev/null +++ b/src/lib/components/Stepper/stepperTheme.ts @@ -0,0 +1,14 @@ +interface StepperThemeProps { + stepperHorizontal?: string; + stepperVertical?: string; +} + +const StepperTheme: StepperThemeProps = { + stepperHorizontal: + "relative m-0 flex list-none justify-between overflow-hidden p-0 transition-[height] duration-200 ease-in-out", + stepperVertical: + "relative m-0 w-full list-none overflow-hidden p-0 transition-[height] duration-200 ease-in-out", +}; + +export type { StepperThemeProps }; +export default StepperTheme; diff --git a/src/lib/components/Stepper/types.ts b/src/lib/components/Stepper/types.ts new file mode 100644 index 0000000..55c8b1e --- /dev/null +++ b/src/lib/components/Stepper/types.ts @@ -0,0 +1,23 @@ +import { BaseComponent } from "../../types/baseComponent"; +import type { StepperStepProps } from "./StepperStep/types"; +import type { StepperThemeProps } from "./stepperTheme"; + +type customValidationType = ( + validableElement: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement +) => boolean; + +type StepperProps = Omit & { + activeStep?: number; + defaultStep?: number; + linear?: boolean; + theme?: StepperThemeProps; + type?: "horizontal" | "vertical"; + onChange?: (prevStepId: number, targetStepId: number) => void; + onInvalid?: (prevStepId: number, targetStepId: number) => void; + children: + | React.ReactElement[] + | React.ReactElement; + customValidation?: customValidationType; +}; + +export type { StepperProps, StepperThemeProps, customValidationType }; diff --git a/src/lib/components/Stepper/utils/utils.ts b/src/lib/components/Stepper/utils/utils.ts new file mode 100644 index 0000000..c1b0751 --- /dev/null +++ b/src/lib/components/Stepper/utils/utils.ts @@ -0,0 +1,86 @@ +import type { customValidationType } from "../../Stepper/types"; +import type { StepsValidity } from "../Stepper"; + +const getTranslateDirection = (activeStep: number, step: number) => { + if (activeStep > step) { + return "Left"; + } + if (activeStep < step) { + return "Right"; + } +}; + +const validateStepContent = ( + stepContent: HTMLElement, + customValidation?: customValidationType +): boolean => { + let isFormValid = true; + const validableElements: HTMLInputElement[] = Array.from( + stepContent.querySelectorAll( + "input[required], select[required], textarea[required]" + ) + ); + + if (customValidation) { + validableElements.forEach((el) => { + const isElementValid = customValidation(el); + if (!isElementValid) { + isFormValid = false; + } + }); + } + + // in this case we expect native-like validation, which means we'll stop the check + // after the first invalid element is found + if (!customValidation) { + validableElements.every((el) => { + const isElementValid = el.checkValidity(); + if (!isElementValid) { + isFormValid = false; + el.reportValidity(); + } + return isElementValid; + }); + } + + return isFormValid; +}; + +/** + * Checks the validity of steps between the active step and the target step. + * @return {boolean} Returns true if all the steps between the active step and the target step ID are valid, otherwise false + */ +const checkStepsBetweenValidity = ( + activeStep: number, + targetStepId: number, + stepsValidity: StepsValidity, + setStepsValidity: React.Dispatch> +) => { + if (activeStep > targetStepId) { + [activeStep, targetStepId] = [targetStepId, activeStep]; + } + + for (let step = activeStep + 1; step < targetStepId; step++) { + setStepsValidity((prev: StepsValidity) => { + return { + ...prev, + [`step${step}`]: { + ...prev[`step${step}`], + wasValidated: true, + }, + }; + }); + + if (!stepsValidity[`step${step}`].isValid) { + return false; + } + } + + return true; +}; + +export { + getTranslateDirection, + validateStepContent, + checkStepsBetweenValidity, +}; diff --git a/src/lib/hooks/useActiveValue.ts b/src/lib/hooks/useActiveValue.ts new file mode 100644 index 0000000..38f68f8 --- /dev/null +++ b/src/lib/hooks/useActiveValue.ts @@ -0,0 +1,13 @@ +import { useMemo } from "react"; + +const useActiveValue = (propValue: any, stateValue: any) => { + return useMemo(() => { + if (propValue !== undefined) { + return propValue; + } + + return stateValue; + }, [propValue, stateValue]); +}; + +export default useActiveValue; diff --git a/src/lib/index.tsx b/src/lib/index.tsx index 8d575e7..01d5910 100644 --- a/src/lib/index.tsx +++ b/src/lib/index.tsx @@ -27,6 +27,8 @@ import TEToast from "./components/Toasts/Toast"; import TESelect from "./forms/Select/Select"; import TECarousel from "./components/Carousel/Carousel"; import TECarouselItem from "./components/Carousel/CarouselItem/CarouselItem"; +import TEStepper from "./components/Stepper/Stepper"; +import TEStepperStep from "./components/Stepper/StepperStep/StepperStep"; export { TECollapse, @@ -58,4 +60,6 @@ export { TESelect, TECarousel, TECarouselItem, + TEStepper, + TEStepperStep, };