Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(stepper): new stepper component #318

Open
wants to merge 129 commits into
base: main
Choose a base branch
from

Conversation

damianricobelli
Copy link
Contributor

@damianricobelli damianricobelli commented May 8, 2023

Hi! In this opportunity I present a new component: Stepper.

The idea of this in its beginnings was to make it as modular and flexible as possible for development.

A basic example of the application is this:

const steps = [
  { label: "Step 1" },
  { label: "Step 2" },
  { label: "Step 3" },
] satisfies StepConfig[]

export const StepperDemo = () => {
  const {
    nextStep,
    prevStep,
    resetSteps,
    setStep,
    activeStep,
    isDisabledStep,
    isLastStep,
    isOptionalStep,
  } = useStepper({
    initialStep: 0,
    steps,
  })

  return (
    <>
      <Steps activeStep={activeStep}>
        {steps.map((step, index) => (
          <Step index={index} key={index} {...step}>
            <div className="bg-muted h-40 w-full p-4">
              <p>Step {index + 1} content</p>
            </div>
          </Step>
        ))}
      </Steps>
      <div className="flex items-center justify-end gap-2">
        {activeStep === steps.length ? (
          <>
            <h2>All steps completed!</h2>
            <Button onClick={resetSteps}>Reset</Button>
          </>
        ) : (
          <>
            <Button disabled={isDisabledStep} onClick={prevStep}>
              Prev
            </Button>
            <Button onClick={nextStep}>
              {isLastStep ? "Finish" : isOptionalStep ? "Skip" : "Next"}
            </Button>
          </>
        )}
      </div>
    </>
  )
}

Here is a complete video of the different use cases:

Grabacion.de.pantalla.2023-05-08.a.la.s.13.14.45.mov

@vercel
Copy link

vercel bot commented May 8, 2023

@damianricobelli is attempting to deploy a commit to the shadcn-pro Team on Vercel.

A member of the Team first needs to authorize it.

@damianricobelli damianricobelli changed the title feat(stepper): add component with docs feat(stepper): new stepper component May 8, 2023
@vercel
Copy link

vercel bot commented May 8, 2023

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
ui ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 17, 2024 7:14pm
1 Ignored Deployment
Name Status Preview Comments Updated (UTC)
next-template ⬜️ Ignored (Inspect) Visit Preview Apr 17, 2024 7:14pm

@jocarrd
Copy link

jocarrd commented May 9, 2023

love it 👀

@shadcn
Copy link
Collaborator

shadcn commented May 9, 2023

This looks incredible @damianricobelli I'll review.

@its-monotype
Copy link
Contributor

its-monotype commented May 9, 2023

Looks amazing 😍 Unfortunately I don't have time to review and research about this component. However, look what I recently found: https://saas-ui.dev/docs/components/navigation/stepper.
https://github.com/saas-js/saas-ui/tree/main/packages/saas-ui-core/src/stepper

This can serve as reference to improve or borrow ideas to simplify the implementation. From what I can suggest it is to rename Step to StepperStep and useSteps to useStepper to comply with the general API conventions of the components and it will be more unique name to prevent conflicts.

@damianricobelli
Copy link
Contributor Author

Looks amazing 😍 Unfortunately I don't have time to review and research about this component. However, look what I recently found: https://saas-ui.dev/docs/components/navigation/stepper. https://github.com/saas-js/saas-ui/tree/main/packages%2Fsaas-ui-stepper

This can serve as reference to improve or borrow ideas to simplify the implementation. From what I can suggest it is to rename Step to StepperStep and useSteps to useStepper to comply with the general API conventions of the components and it will be more unique name to prevent conflicts.

Thank you very much for your feedback! I'll be reviewing tomorrow what you just shared and your suggestions 🫶

@damianricobelli
Copy link
Contributor Author

@shadcn What do you think about this component? Do you think we should adjust anything so that it can be launched on prod?

@destino92
Copy link

Is this is still in progress?

@damianricobelli
Copy link
Contributor Author

damianricobelli commented Jun 20, 2023

Is this is still in progress?

@destino92 From my side the component is ready. Just need to know if @shadcn agrees to move forward and add it to the CLI that brings and details that you think are missing in terms of documentation.

@drewhoffer
Copy link

This looks good!

@dan5py
Copy link
Contributor

dan5py commented Jun 30, 2023

Hi @damianricobelli, this component looks very good. Could you please update it to the new version (different themes, registry, docs, etc.)?

@damianricobelli
Copy link
Contributor Author

@dan5py yes of course. Between today and Monday I will be making the necessary changes so that the component allows the last addition you mention.

@damianricobelli
Copy link
Contributor Author

@shadcn Could you check this? I've already updated the code with all the latest stuff in the main branch. There are already several people watching the release of this component 🤩 🚀

@damianricobelli
Copy link
Contributor Author

damianricobelli commented Jul 4, 2023

Hi @damianricobelli, this component looks very good. Could you please update it to the new version (different themes, registry, docs, etc.)?

Done @dan5py! 🥳

@ImanMahmoudinasab
Copy link

ImanMahmoudinasab commented Jan 24, 2025

Hey @damianricobelli and @resatyildiz, I think stepper component shouldn't get bind to routes internally. Instead, we should use a stepper in a controlled way and define routes for each step. When the routes change, pass the relevant step index or ID to the stepper. When the stepper goes to the next or previous step, update the route of the page.

@damianricobelli
Copy link
Contributor Author

@ImanMahmoudinasab yea, you're right. The component should not handle anything related to routing. Also, it is much simpler to handle query params instead of routes for something like a stepper.

@damianricobelli
Copy link
Contributor Author

Estimated date for review of the PR by the entire community and @shadcn -> tomorrow 👀

@resatyildiz
Copy link

@ImanMahmoudinasab actually I agree with you. Already it can bind to window history with query params. You can think this feature @damianricobelli

@damianricobelli
Copy link
Contributor Author

All documentation and component ready for review! @shadcn 👍

@JoelVenable
Copy link

JoelVenable commented Jan 24, 2025

Hey @damianricobelli and @resatyildiz, I think stepper component shouldn't get bind to routes internally. Instead, we should use a stepper in a controlled way and define routes for each step. When the routes change, pass the relevant step index or ID to the stepper. When the stepper goes to the next or previous step, update the route of the page.

Agreed. I'm having quite a bit of difficulty with step control with the underlying stepperize library. Either external control or alternatively an onStepChange callback affordance seem like intuitive uses, neither which seem to be part of the API (perhaps they are there, just not documented??).

My use case is a multi-step form where each step must be validated prior to allowing progression to later steps. I'm having a surprising amount of difficulty synchronizing this component with a zustand store.

Right now I've resorted to manually implementing the "canPrevious", "canNext" logic in the zustand store, with a subscription to call the goTo method. If there's something I'm missing, feedback is welcome.

import { CardContent, CardFooter, CardHeader } from '@repo/ui/components/card'
import {
  defineStepper,
  Stepper,
  StepperAction,
  StepperControls,
  StepperNavigation,
  StepperPanel,
  StepperStep,
  StepperTitle,
} from '@repo/ui/components/stepper'
import { SellPassStepOne } from './sell-pass-step-one'
import { SellPassStepThree } from './sell-pass-step-three'
import { SellPassStepTwo } from './sell-pass-step-two'
import { sellProductStore, useSellProductStore } from './sell-product.store'
import { useEffect } from 'react'

const stepper = defineStepper(
  {
    id: 'step1',
    title: 'Type',
    header: 'Sell a Pass or Enforcement?',
    Component: SellPassStepOne,
  },
  {
    id: 'step2',
    title: 'Customer',
    header: 'Please enter the customer details',
    Component: SellPassStepTwo,
  },
  {
    id: 'step3',
    title: 'Payment',
    header: 'Please enter the payment details',
    Component: SellPassStepThree,
  },
)

export const SellForm = () => {
  const s = stepper.useStepper()
  const store = useSellProductStore()

  const steps = s.all

  useEffect(() => {
    sellProductStore.subscribe((state) => {
      s.goTo(state.stepId)
    })
  }, [s])

  return (
    <Stepper instance={stepper}>
      <CardHeader>
        <StepperNavigation>
          {({ methods }) =>
            steps.map((step) => {
              return (
                <StepperStep
                  key={step.id}
                  of={step}
                  disabled={!store.enabledSteps.includes(step.id)}
                  onClick={() => store.setStep(step.id)}
                >
                  <StepperTitle>{step.title}</StepperTitle>
                </StepperStep>
              )
            })
          }
        </StepperNavigation>
      </CardHeader>
      <CardContent>
        {steps.map(({ Component, ...step }) => (
          <StepperPanel key={step.id} when={step}>
            <Component />
          </StepperPanel>
        ))}
      </CardContent>
      <CardFooter>
        <StepperControls className="flex justify-items-end w-full">
          <StepperAction action="prev" disabled={!store.canPrev}>
            Previous
          </StepperAction>
          <StepperAction action="next" disabled={!store.canNext}>
            Next
          </StepperAction>
          <StepperAction action="reset" disabled={!store.canReset}>
            Reset
          </StepperAction>
        </StepperControls>
      </CardFooter>
    </Stepper>
  )
}

@damianricobelli
Copy link
Contributor Author

damianricobelli commented Jan 25, 2025

@JoelVenable I have added in the PR an example of a form with react hook form. And I have updated a little the final logic of methods, where they are now obtained as part of the children of to maintain the typesafe API. Remember that the useStepper hook will work in these components if you use it within the Stepper component since it is a Provider.

@damianricobelli
Copy link
Contributor Author

@JoelVenable @resatyildiz @ImanMahmoudinasab I have been thinking about this and have released version 4.2.0 of @stepperize/react which adds functions beforeNext, afterNext, beforePrev and afterPrev which allow to execute next and prev but with logic before or after the action which can be async 👍 . I will add this in the PR this weekend! I hope you find it helpful.

Check the docs here --> https://stepperize.vercel.app/docs/react/api-references/hook#beforeafter-functions

@damianricobelli
Copy link
Contributor Author

damianricobelli commented Jan 27, 2025

Logic and updated examples to maintain two things:

  1. Composition
  2. Typesafe from the @stepperize/react
  3. Simplicity and flexibility
  4. Simple form validation

cc: @shadcn

@JoelVenable
Copy link

@JoelVenable @resatyildiz @ImanMahmoudinasab I have been thinking about this and have released version 4.2.0 of @stepperize/react which adds functions beforeNext, afterNext, beforePrev and afterPrev which allow to execute next and prev but with logic before or after the action which can be async 👍 . I will add this in the PR this weekend! I hope you find it helpful.

Check the docs here --> https://stepperize.vercel.app/docs/react/api-references/hook#beforeafter-functions

Appreciate the new callback hooks; that's a big improvement to the API. Personally I went a different direction ("dumb" components fully controlled by the zustand store) so I no longer have skin in the game, but I think affordances to conditionally disable steps entirely would make a lot of sense. Then both the StepperAction and StepperStep components could be disabled based on a single source of truth.

@damianricobelli
Copy link
Contributor Author

@JoelVenable Thanks for your comment! Please check the PR again as the examples and code have been simplified a bit. StepperAction is no longer necessary as it limits the DX a bit. Instead, the developer can be free to create their button logic. And to maintain the composition, we simply have a which has no functionality, just for composition purposes.

As for the idea of disabling steps, I think I would leave that detail in the hands of the developer and not the component

@ImanMahmoudinasab
Copy link

@damianricobelli Thank you for your amazing contribution. I have two questions?

  1. Does new stepper allow its states, e.g. current step, being controlled - passed by its parent?
  2. Can we add/remove steps without losing states?

@merodiro
Copy link

Is it possible to call defineStepper inside the component with dynamic data or does it have to be stable?

@damianricobelli
Copy link
Contributor Author

@ImanMahmoudinasab

  1. can you please show me an example?
  2. If you are referring to being able to remove steps and for it to be visible to the instance, no. That is not possible in runtime with TS. If you define 3 steps, there will be 3 valid steps unless you use your own logic to indicate that any of those steps should not be shown.

@damianricobelli
Copy link
Contributor Author

@merodiro will be stable since you cannot modify the type at runtime. If you need to modify it, you should add some state that stores metadata and can be modified.

I would like to be able to work on an idea of dynamic metadata without being typesafe in the future to solve this

@merodiro
Copy link

@damianricobelli I'm trying to change the metadata dynamically for example changing the title based on the locale or changing something in the schema based on data from the server. Can I change them dynamically or do I need to call defineStepper only once and keep its reference stable across renders?

@damianricobelli
Copy link
Contributor Author

@merodiro if you need to change texts according to locale and you are using a library for this, I recommend using the keys of the terms instead of the text in each of your steps

@damianricobelli
Copy link
Contributor Author

@stepperize/react v5 is out! Dynamic data with the new Metadata API 🔥

cc: @merodiro

@matheussatoshi
Copy link

matheussatoshi commented Jan 30, 2025

Ei@damianricobellie@resatyildiz, Acho que o componente stepper não deve ser vinculado a rotas internamente. Em vez disso, devemos usar um stepper de forma controlada e definir rotas para cada etapa. Quando as rotas mudarem, passe o índice ou ID da etapa relevante para o stepper. Quando o stepper for para a próxima etapa ou para a anterior, atualize a rota da página.

Concordo. Estou tendo bastante dificuldade com o controle de passo com a biblioteca stepperize subjacente. Tanto o controle externo quanto, alternativamente, um onStepChangeaffordance de retorno de chamada parecem usos intuitivos, nenhum dos quais parece fazer parte da API (talvez eles estejam lá, só não documentados??).

Meu caso de uso é um formulário multietapas em que cada etapa deve ser validada antes de permitir a progressão para etapas posteriores. Estou tendo uma quantidade surpreendente de dificuldade para sincronizar este componente com uma loja zustand.

No momento, recorri à implementação manual da lógica "canPrevious", "canNext" no zustand store, com uma assinatura para chamar o goTométodo. Se houver algo que eu esteja esquecendo, o feedback é bem-vindo.

import { CardContent, CardFooter, CardHeader } from '@repo/ui/components/card'
import {
  defineStepper,
  Stepper,
  StepperAction,
  StepperControls,
  StepperNavigation,
  StepperPanel,
  StepperStep,
  StepperTitle,
} from '@repo/ui/components/stepper'
import { SellPassStepOne } from './sell-pass-step-one'
import { SellPassStepThree } from './sell-pass-step-three'
import { SellPassStepTwo } from './sell-pass-step-two'
import { sellProductStore, useSellProductStore } from './sell-product.store'
import { useEffect } from 'react'

const stepper = defineStepper(
  {
    id: 'step1',
    title: 'Type',
    header: 'Sell a Pass or Enforcement?',
    Component: SellPassStepOne,
  },
  {
    id: 'step2',
    title: 'Customer',
    header: 'Please enter the customer details',
    Component: SellPassStepTwo,
  },
  {
    id: 'step3',
    title: 'Payment',
    header: 'Please enter the payment details',
    Component: SellPassStepThree,
  },
)

export const SellForm = () => {
  const s = stepper.useStepper()
  const store = useSellProductStore()

  const steps = s.all

  useEffect(() => {
    sellProductStore.subscribe((state) => {
      s.goTo(state.stepId)
    })
  }, [s])

  return (
    <Stepper instance={stepper}>
      <CardHeader>
        <StepperNavigation>
          {({ methods }) =>
            steps.map((step) => {
              return (
                <StepperStep
                  key={step.id}
                  of={step}
                  disabled={!store.enabledSteps.includes(step.id)}
                  onClick={() => store.setStep(step.id)}
                >
                  <StepperTitle>{step.title}</StepperTitle>
                </StepperStep>
              )
            })
          }
        </StepperNavigation>
      </CardHeader>
      <CardContent>
        {steps.map(({ Component, ...step }) => (
          <StepperPanel key={step.id} when={step}>
            <Component />
          </StepperPanel>
        ))}
      </CardContent>
      <CardFooter>
        <StepperControls className="flex justify-items-end w-full">
          <StepperAction action="prev" disabled={!store.canPrev}>
            Previous
          </StepperAction>
          <StepperAction action="next" disabled={!store.canNext}>
            Next
          </StepperAction>
          <StepperAction action="reset" disabled={!store.canReset}>
            Reset
          </StepperAction>
        </StepperControls>
      </CardFooter>
    </Stepper>
  )
}

I think this design can help you if you need it...

Captura de tela 2025-01-29 231605
Captura de tela 2025-01-29 231611
Captura de tela 2025-01-29 231536
Captura de tela 2025-01-29 231543

https://www.figma.com/design/EaDyK1l09bi7mxaSl5stv4/Untitled?node-id=0-1&p=f&t=dG2WqjbwIsXssRio-0

@shadcn
Copy link
Collaborator

shadcn commented Jan 30, 2025

@damianricobelli Thanks for your work on this. I'll review and make an official component once I'm done with the Tailwind v4 upgrade.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area: roadmap This looks great. We'll add it to the roadmap, review and merge. new component
Projects
None yet
Development

Successfully merging this pull request may close these issues.