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 109 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! 🥳

@damianricobelli
Copy link
Contributor Author

Hello 👋 for those who are impatient for the release of this component: I understand you and I am too, but I am also working hard on a library with many improvements for this component. If you really can't wait and want to use this component, feel free to use the code from this PR or the one from the repository at this link: https://shadcn-stepper.vercel.app/

I am very close to finish the library, I just ask you not to keep commenting with messages like: "any news?" since I am actively working on the library I have mentioned. @shadcn is already aware of this and I'm sure he is also waiting for the release of the library.

If you want to contribute value in your comments, please do it with problems you find in the code as it helps me to understand all the improvements I have to take into account in the library 🙌

@damianricobelli
Copy link
Contributor Author

Heads up: I just finished version 1.0 of the library! Now I have to document everything and after publishing it in npm I will be adding the library to this PR. This will take a few more days, patience, the best is yet to come! 🙌

@Dazedi
Copy link

Dazedi commented Aug 8, 2024

Looking forward to using the official version of this someday.

Enjoying this so far, but I had an issue of forms clearing on orientation change. Basically if you use a smaller device and input the form, you'd have to re-input everything if you were to turn it sideways.

I managed to deal this issue by using react-reverse-portal to render the step content to wherever it needs to be rendered, but is there some official / better way to deal with this issue?

Maybe the 1.0 fixes the orientation issue? 😃

@warisareshi
Copy link

warisareshi commented Aug 10, 2024

@damianricobelli When will this be official?

@damianricobelli
Copy link
Contributor Author

@warisareshi this week I think

@lucasdu4rte
Copy link

I'm waiting for this 🙏

@damianricobelli
Copy link
Contributor Author

I want to announce to everyone that the first version of the library is here! It is called @stepperize/react. You can find the docs (examples are still in progress) here --> https://stepperize.vercel.app

@shadcn I will try to finish all the examples of the library and then I will add the components to this PR

@alipiry
Copy link

alipiry commented Aug 16, 2024

I want to announce to everyone that the first version of the library is here! It is called @stepperize/react. You can find the docs (examples are still in progress) here --> https://stepperize.vercel.app

@shadcn I will try to finish all the examples of the library and then I will add the components to this PR

You rock bro!!

@damianricobelli
Copy link
Contributor Author

New docs and improved API! The v1.0 is here!

More examples coming soon..

https://stepperize.vercel.app/

@rafaeljigau
Copy link

Did something happen with the top of the stepper or am I doing something wrong? It used to show some progress as you navigate through your steps, but now it's just not there?

image

@ImGeorgiy
Copy link

👀

@damianricobelli
Copy link
Contributor Author

@damianricobelli

u tell me what is the size of the device? The problem with forcing horizontal use on small screens is that we don't have space for many buttons + separators for the different steps. That's why it is forced to use the vertical format in mobile screens

Just regular iPhone size device. In my opinion instead of separators it should display it just differently instead of taking that much space from the left that could be used for inputs. Or just make horizontal steps overflow. I made a 3 step checkout process using this component but I'm considering using just routes similar to how you select plan on netflix because I don't like how little space it leaves on mobile for user input form. It's a great component maybe it's better for other use cases I don't know.

image

Just a reminder that I will shortly be posting an example on @stepperize/react that addresses this case 🤝

@Woofer21
Copy link

I want to announce to everyone that the first version of the library is here! It is called @stepperize/react. You can find the docs (examples are still in progress) here --> https://stepperize.vercel.app

@shadcn I will try to finish all the examples of the library and then I will add the components to this PR

Hi, if you don't mind me asking, what does the progress look like on this at the moment?

@damianricobelli
Copy link
Contributor Author

@Woofer21 I am trying to finish the library docs and examples and then I will refactor this PR with the library.

@ayushchauhan840
Copy link

@damianricobelli Thanks for the stepper, can you please bring back this site https://shadcn-stepper.vercel.app/ I'm in the middle of integrating stepper from one of the examples given. as the examples in the library are still in progress. Thanks in advance. https://stepperize.vercel.app/

@alamenai
Copy link

alamenai commented Sep 5, 2024

Any updates on that?

@Victor-Abidoye
Copy link

@damianricobelli well done. Any update on when this PR might be approved?

@SanderCokart
Copy link

SanderCokart commented Sep 9, 2024

Ok look, I want this component to get released as well but maybe some improvements can be made, over the past year I have worked on this for work and want to contribute the source to this endeavour, maybe you can get some takeaways from it.
The structure is hell but the animations are clean so here it is:

stepper-types.ts

import type { HTMLAttributes, ReactNode } from 'react';
import type { STEPPER_TRANSLATION_KEYS } from './useStepper';
import type { Options } from 'nuqs';

interface StepperProps extends HTMLAttributes<HTMLDivElement> {
  queryKey?: string;
  /**
   * The steps of the stepper.
   * @default []
   */
  steps: Step[];

  /** A function to translate the keys of the stepper. */
  t?: (key: STEPPER_TRANSLATION_KEYS) => string;
}

interface Step {
  /** The component of the step. */
  component: ReactNode;
  /** The description of the step. */
  description?: string;
  /** The title of the step. */
  title: string;
  /** Whether the step should be skipped. */
  skip?: boolean;
}

type StepperDirection = 1 | -1 | 0;

/** the first number is the active step, the second number is the direction and starts with 0 */
type StepTuple = [number, StepperDirection, skipped?: boolean];

export type { StepperProps, Step, StepTuple, StepperDirection };

stepper.tsx

import { animationFade, animationSlideInOut } from '#animations/index.ts';
import { Alert, AlertDescription, AlertTitle } from '#components/ui/alert.tsx';
import { cn } from '#lib/utils.ts';
import { AnimatePresence, motion } from 'framer-motion';
import { useQueryState } from 'nuqs';
import { GiCheckMark } from 'react-icons/gi';
import { LuFastForward } from 'react-icons/lu';

import type { ButtonHTMLAttributes, CSSProperties } from 'react';
import type { StepperProps, StepTuple } from './stepper-types';

import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import { StepperContextProvider, useStepper } from './useStepper';

const BaseLine = ({ width }: { width: CSSProperties['width'] }) => (
  <div className="bg-muted-foreground absolute left-1/2 top-1/2 h-[4px] -translate-y-1/2" style={{ width }} />
);

const ActiveLine = (props: { width: CSSProperties['width'] }) => (
  <motion.div
    animate={{ width: props.width }}
    className="bg-primary/50 absolute left-1/2 top-1/2 h-[4px] -translate-y-1/2"
  />
);

const CompletedLine = (props: { width: CSSProperties['width'] }) => (
  <motion.div
    animate={{ width: props.width }}
    className="bg-primary absolute left-1/2 top-1/2 h-[4px] -translate-y-1/2"
  />
);

/**
 * A stepper component that allows you to create a step-by-step process for example a form.
 */
const Stepper = ({ steps, className, children, t, queryKey = 'stepper', ...restOfProps }: StepperProps) => {
  const [activeStepTuple, setActiveStepTuple] = useQueryState(queryKey, {
    parse: (query: string) => query.split(',').map(Number) as StepTuple,
    serialize: value => value.join(','),
    history: 'push',
    defaultValue: [0, 0] as StepTuple,
  });

  const activeStep = activeStepTuple[0];
  const direction = activeStepTuple[1];

  const goToNextStep = () => {
    // Check if the current active step is not the last step
    if (activeStep < steps.length - 1) {
      let nextStep = activeStep + 1;

      // Skip steps that are marked to be skipped
      while (nextStep < steps.length && steps[nextStep]?.skip) {
        nextStep += 1;
      }

      // If the next step is valid, update the active step tuple
      if (nextStep < steps.length) {
        setActiveStepTuple([nextStep, 1]);
      }
    }
  };

  const goToPreviousStep = (step?: number) => {
    // Check if a specific step is provided and valid
    if (step !== undefined && step >= 0 && step < activeStep) {
      // Skip any steps that are marked to be skipped
      while (step >= 0 && steps[step]?.skip) {
        step -= 1;
      }
      // If a valid previous step is found, update the active step tuple
      if (step >= 0) {
        setActiveStepTuple([step, -1]);
      }
    } else if (activeStep > 0) {
      // If no specific step is provided, move to the previous step
      let previousStep = activeStep - 1;
      // Skip any steps that are marked to be skipped
      while (previousStep >= 0 && steps[previousStep]?.skip) {
        previousStep -= 1;
      }
      // If a valid previous step is found, update the active step tuple
      if (previousStep >= 0) {
        setActiveStepTuple([previousStep, -1]);
      }
    }
  };

  return (
    <StepperContextProvider
      value={{
        activeStepTuple,
        goToNextStep,
        goToPreviousStep,
        setActiveStepTuple,
        t,
      }}>
      <div className={cn('flex flex-col gap-8', className)} {...restOfProps}>
        <Alert className="overflow-hidden">
          <AnimatePresence custom={direction} initial={false} mode="wait">
            <motion.div key={steps[activeStep]!.title} custom={direction} {...animationSlideInOut}>
              <AlertTitle className="text-balance text-center text-2xl">{steps[activeStep]!.title}</AlertTitle>
              <AlertDescription className="text-balance text-center">{steps[activeStep]!.description}</AlertDescription>
            </motion.div>
          </AnimatePresence>
        </Alert>

        <div className="flex justify-between">
          {steps.map((_, index) => {
            const isCompleted = index < activeStep;

            return (
              <div
                key={index}
                className={cn('group relative flex grow justify-center', [
                  index === activeStep && 'active',
                  isCompleted && 'completed',
                ])}>
                {index === 0 && (
                  <>
                    <BaseLine width={`${100 * (steps.length - 1)}%`} />
                    <ActiveLine width={`${100 * activeStep}%`} />
                    <CompletedLine width={`${100 * (activeStep <= 0 ? 0 : activeStep - 1)}%`} />
                  </>
                )}

                <StepButton
                  skipped={steps[index]?.skip}
                  disabled={index >= activeStep}
                  delayed={index === activeStep}
                  onClick={() => {
                    goToPreviousStep(index);
                  }}>
                  {index + 1}
                </StepButton>
              </div>
            );
          })}
        </div>
      </div>

      <div>
        <AnimatePresence custom={direction} initial={false} mode="wait">
          <motion.div key={activeStep} custom={direction} {...animationFade}>
            <div className="py-0">{steps[activeStep]?.component}</div>
          </motion.div>
        </AnimatePresence>
        {children}
      </div>
    </StepperContextProvider>
  );
};

interface StepButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  delayed?: boolean;
  skipped?: boolean;
}

const StepButton = ({ children, skipped, delayed, className, ...restOfProps }: StepButtonProps) => {
  const { t } = useStepper();

  if (skipped)
    return (
      <TooltipProvider>
        <Tooltip>
          <TooltipTrigger asChild>
            <button
              {...restOfProps}
              className={cn(
                'bg-primary z-10 grid h-[40px] w-[40px] cursor-not-allowed place-items-center rounded-full',
                className,
              )}>
              <LuFastForward className="fill-primary-foreground stroke-primary-foreground h-6 w-6" />
            </button>
          </TooltipTrigger>
          <TooltipContent>{t?.('skip') || 'Deze stap is overgeslagen.'}</TooltipContent>
        </Tooltip>
      </TooltipProvider>
    );

  return (
    <button
      {...restOfProps}
      className={cn(
        delayed && 'delay-500',
        'bg-muted relative z-10 grid h-[40px] w-[40px] place-items-center rounded-full transition-all',
        'text-muted-foreground font-bold',
        'group/button',
        'ring-primary ring-offset-background/90 group-[.active]:bg-primary group-[.active]:text-primary-foreground ring-offset-4 group-[.active]:ring-2',
        'group-[.completed]:bg-primary group-[.completed]:text-primary-foreground',
        className,
      )}
      type="button">
      <GiCheckMark
        className={cn(
          'absolute grid place-items-center opacity-0 transition-opacity',
          'group-[.completed]:opacity-100 group-has-[button:hover,button:focus]:opacity-0',
        )}
      />
      <span
        className={cn(
          'transition-opacity',
          'group-[.completed]:opacity-0 group-has-[button:hover,button:focus]:opacity-100',
        )}>
        {children}
      </span>
    </button>
  );
};

export { Stepper };

useStepper.ts

'use client';

import { createContext, useContext } from 'react';

import type { StepTuple } from './stepper-types';

export type STEPPER_TRANSLATION_KEYS = 'skip';

interface StepperContextType {
  goToNextStep: () => void;
  goToPreviousStep: (step?: number) => void;
  setActiveStepTuple: (value: StepTuple) => void;
  activeStepTuple: StepTuple;
  t?: (key: STEPPER_TRANSLATION_KEYS) => string;
}

const StepperContext = createContext<StepperContextType | null>(null);

export const useStepper = () => {
  const context = useContext(StepperContext);

  if (!context) {
    throw new Error('useStepper must be used within a Stepper');
  }

  return context;
};

export const StepperContextProvider = StepperContext.Provider;

Usage

'use client';

import { Stepper } from '@repo/ui/stepper';
import { div } from 'framer-motion/m';
import { useTranslations } from 'next-intl';
import { useQueryState } from 'nuqs';
import { useShallow } from 'zustand/react/shallow';

import { useEffect, useMemo } from 'react';

import type { OrderType } from '@/types/models/order';
import type { StepperProps, StepTuple } from '@repo/ui/stepper-types';

import { ReturnStep4RequestLabel } from '@/app/[slug]/return/components/return-steps/return-step-4-request-label';
import { useOrganisation } from '@/providers/organisation-provider';
import { useReturnFormStore } from '@/stores/return-form-store';

import { ReturnStep1SelectOrderItemsForm } from './return-steps/return-step-1-select-order-items-form';
import { ReturnStep2SelectReasonsForm } from './return-steps/return-step-2-select-reasons-form';

export const ReturnStepper = ({ order }: { order: OrderType }) => {
  const {
    organisation: {
      return_settings: { reasons },
    },
  } = useOrganisation();

  const t = useTranslations('ReturnStepper');
  const [setOrder, setBillingCustomer] = useReturnFormStore(
    useShallow(state => [state.setOrder, state.setBillingCustomer]),
  );

  useEffect(() => {
    if (!useReturnFormStore.persist.hasHydrated()) useReturnFormStore.persist.rehydrate();
    setOrder(order);
    setBillingCustomer(order.destination);
  }, [order]);

  const steps: StepperProps['steps'] = useMemo(
    () => [
      {
        title: t('step1.title'),
        description: t('step1.description'),
        component: (
          <div className="container max-w-screen-md">
            <ReturnStep1SelectOrderItemsForm order={order} />
          </div>
        ),
        skip: !order?.order_items?.length,
      },
      {
        title: t('step2.title'),
        description: t('step2.description'),
        component: (
          <div className="container max-w-screen-lg">
            <ReturnStep2SelectReasonsForm reasons={reasons} />
          </div>
        ),
      },
      // {
      //   title: t('step3.title'),
      //   description: t('step3.description'),
      //
      //   component: <ReturnStep3SelectLocation order={order} />,
      // },
      {
        title: t('step3.title'),
        description: t('step3.description'),
        component: (
          <div className="container max-w-screen-lg">
            <ReturnStep4RequestLabel />
          </div>
        ),
      },
    ],
    [],
  );

  return <Stepper className="container mb-8 max-w-screen-md" steps={steps} t={t} />;
};

@damianricobelli
Copy link
Contributor Author

Just a small update to put the community at ease: I'm working on this, but a bit slower than it could be as I'm having very busy weeks with my work. I hope to have everything this week for both the ‘@stepperize/react’ library and shadcn 🤝

@damianricobelli
Copy link
Contributor Author

Ok look, I want this component to get released as well but maybe some improvements can be made, over the past year I have worked on this for work and want to contribute the source to this endeavour, maybe you can get some takeaways from it. The structure is hell but the animations are clean so here it is:

stepper-types.ts

import type { HTMLAttributes, ReactNode } from 'react';
import type { STEPPER_TRANSLATION_KEYS } from './useStepper';
import type { Options } from 'nuqs';

interface StepperProps extends HTMLAttributes<HTMLDivElement> {
  queryKey?: string;
  /**
   * The steps of the stepper.
   * @default []
   */
  steps: Step[];

  /** A function to translate the keys of the stepper. */
  t?: (key: STEPPER_TRANSLATION_KEYS) => string;
}

interface Step {
  /** The component of the step. */
  component: ReactNode;
  /** The description of the step. */
  description?: string;
  /** The title of the step. */
  title: string;
  /** Whether the step should be skipped. */
  skip?: boolean;
}

type StepperDirection = 1 | -1 | 0;

/** the first number is the active step, the second number is the direction and starts with 0 */
type StepTuple = [number, StepperDirection, skipped?: boolean];

export type { StepperProps, Step, StepTuple, StepperDirection };

stepper.tsx

import { animationFade, animationSlideInOut } from '#animations/index.ts';
import { Alert, AlertDescription, AlertTitle } from '#components/ui/alert.tsx';
import { cn } from '#lib/utils.ts';
import { AnimatePresence, motion } from 'framer-motion';
import { useQueryState } from 'nuqs';
import { GiCheckMark } from 'react-icons/gi';
import { LuFastForward } from 'react-icons/lu';

import type { ButtonHTMLAttributes, CSSProperties } from 'react';
import type { StepperProps, StepTuple } from './stepper-types';

import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import { StepperContextProvider, useStepper } from './useStepper';

const BaseLine = ({ width }: { width: CSSProperties['width'] }) => (
  <div className="bg-muted-foreground absolute left-1/2 top-1/2 h-[4px] -translate-y-1/2" style={{ width }} />
);

const ActiveLine = (props: { width: CSSProperties['width'] }) => (
  <motion.div
    animate={{ width: props.width }}
    className="bg-primary/50 absolute left-1/2 top-1/2 h-[4px] -translate-y-1/2"
  />
);

const CompletedLine = (props: { width: CSSProperties['width'] }) => (
  <motion.div
    animate={{ width: props.width }}
    className="bg-primary absolute left-1/2 top-1/2 h-[4px] -translate-y-1/2"
  />
);

/**
 * A stepper component that allows you to create a step-by-step process for example a form.
 */
const Stepper = ({ steps, className, children, t, queryKey = 'stepper', ...restOfProps }: StepperProps) => {
  const [activeStepTuple, setActiveStepTuple] = useQueryState(queryKey, {
    parse: (query: string) => query.split(',').map(Number) as StepTuple,
    serialize: value => value.join(','),
    history: 'push',
    defaultValue: [0, 0] as StepTuple,
  });

  const activeStep = activeStepTuple[0];
  const direction = activeStepTuple[1];

  const goToNextStep = () => {
    // Check if the current active step is not the last step
    if (activeStep < steps.length - 1) {
      let nextStep = activeStep + 1;

      // Skip steps that are marked to be skipped
      while (nextStep < steps.length && steps[nextStep]?.skip) {
        nextStep += 1;
      }

      // If the next step is valid, update the active step tuple
      if (nextStep < steps.length) {
        setActiveStepTuple([nextStep, 1]);
      }
    }
  };

  const goToPreviousStep = (step?: number) => {
    // Check if a specific step is provided and valid
    if (step !== undefined && step >= 0 && step < activeStep) {
      // Skip any steps that are marked to be skipped
      while (step >= 0 && steps[step]?.skip) {
        step -= 1;
      }
      // If a valid previous step is found, update the active step tuple
      if (step >= 0) {
        setActiveStepTuple([step, -1]);
      }
    } else if (activeStep > 0) {
      // If no specific step is provided, move to the previous step
      let previousStep = activeStep - 1;
      // Skip any steps that are marked to be skipped
      while (previousStep >= 0 && steps[previousStep]?.skip) {
        previousStep -= 1;
      }
      // If a valid previous step is found, update the active step tuple
      if (previousStep >= 0) {
        setActiveStepTuple([previousStep, -1]);
      }
    }
  };

  return (
    <StepperContextProvider
      value={{
        activeStepTuple,
        goToNextStep,
        goToPreviousStep,
        setActiveStepTuple,
        t,
      }}>
      <div className={cn('flex flex-col gap-8', className)} {...restOfProps}>
        <Alert className="overflow-hidden">
          <AnimatePresence custom={direction} initial={false} mode="wait">
            <motion.div key={steps[activeStep]!.title} custom={direction} {...animationSlideInOut}>
              <AlertTitle className="text-balance text-center text-2xl">{steps[activeStep]!.title}</AlertTitle>
              <AlertDescription className="text-balance text-center">{steps[activeStep]!.description}</AlertDescription>
            </motion.div>
          </AnimatePresence>
        </Alert>

        <div className="flex justify-between">
          {steps.map((_, index) => {
            const isCompleted = index < activeStep;

            return (
              <div
                key={index}
                className={cn('group relative flex grow justify-center', [
                  index === activeStep && 'active',
                  isCompleted && 'completed',
                ])}>
                {index === 0 && (
                  <>
                    <BaseLine width={`${100 * (steps.length - 1)}%`} />
                    <ActiveLine width={`${100 * activeStep}%`} />
                    <CompletedLine width={`${100 * (activeStep <= 0 ? 0 : activeStep - 1)}%`} />
                  </>
                )}

                <StepButton
                  skipped={steps[index]?.skip}
                  disabled={index >= activeStep}
                  delayed={index === activeStep}
                  onClick={() => {
                    goToPreviousStep(index);
                  }}>
                  {index + 1}
                </StepButton>
              </div>
            );
          })}
        </div>
      </div>

      <div>
        <AnimatePresence custom={direction} initial={false} mode="wait">
          <motion.div key={activeStep} custom={direction} {...animationFade}>
            <div className="py-0">{steps[activeStep]?.component}</div>
          </motion.div>
        </AnimatePresence>
        {children}
      </div>
    </StepperContextProvider>
  );
};

interface StepButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  delayed?: boolean;
  skipped?: boolean;
}

const StepButton = ({ children, skipped, delayed, className, ...restOfProps }: StepButtonProps) => {
  const { t } = useStepper();

  if (skipped)
    return (
      <TooltipProvider>
        <Tooltip>
          <TooltipTrigger asChild>
            <button
              {...restOfProps}
              className={cn(
                'bg-primary z-10 grid h-[40px] w-[40px] cursor-not-allowed place-items-center rounded-full',
                className,
              )}>
              <LuFastForward className="fill-primary-foreground stroke-primary-foreground h-6 w-6" />
            </button>
          </TooltipTrigger>
          <TooltipContent>{t?.('skip') || 'Deze stap is overgeslagen.'}</TooltipContent>
        </Tooltip>
      </TooltipProvider>
    );

  return (
    <button
      {...restOfProps}
      className={cn(
        delayed && 'delay-500',
        'bg-muted relative z-10 grid h-[40px] w-[40px] place-items-center rounded-full transition-all',
        'text-muted-foreground font-bold',
        'group/button',
        'ring-primary ring-offset-background/90 group-[.active]:bg-primary group-[.active]:text-primary-foreground ring-offset-4 group-[.active]:ring-2',
        'group-[.completed]:bg-primary group-[.completed]:text-primary-foreground',
        className,
      )}
      type="button">
      <GiCheckMark
        className={cn(
          'absolute grid place-items-center opacity-0 transition-opacity',
          'group-[.completed]:opacity-100 group-has-[button:hover,button:focus]:opacity-0',
        )}
      />
      <span
        className={cn(
          'transition-opacity',
          'group-[.completed]:opacity-0 group-has-[button:hover,button:focus]:opacity-100',
        )}>
        {children}
      </span>
    </button>
  );
};

export { Stepper };

useStepper.ts

'use client';

import { createContext, useContext } from 'react';

import type { StepTuple } from './stepper-types';

export type STEPPER_TRANSLATION_KEYS = 'skip';

interface StepperContextType {
  goToNextStep: () => void;
  goToPreviousStep: (step?: number) => void;
  setActiveStepTuple: (value: StepTuple) => void;
  activeStepTuple: StepTuple;
  t?: (key: STEPPER_TRANSLATION_KEYS) => string;
}

const StepperContext = createContext<StepperContextType | null>(null);

export const useStepper = () => {
  const context = useContext(StepperContext);

  if (!context) {
    throw new Error('useStepper must be used within a Stepper');
  }

  return context;
};

export const StepperContextProvider = StepperContext.Provider;

Usage

'use client';

import { Stepper } from '@repo/ui/stepper';
import { div } from 'framer-motion/m';
import { useTranslations } from 'next-intl';
import { useQueryState } from 'nuqs';
import { useShallow } from 'zustand/react/shallow';

import { useEffect, useMemo } from 'react';

import type { OrderType } from '@/types/models/order';
import type { StepperProps, StepTuple } from '@repo/ui/stepper-types';

import { ReturnStep4RequestLabel } from '@/app/[slug]/return/components/return-steps/return-step-4-request-label';
import { useOrganisation } from '@/providers/organisation-provider';
import { useReturnFormStore } from '@/stores/return-form-store';

import { ReturnStep1SelectOrderItemsForm } from './return-steps/return-step-1-select-order-items-form';
import { ReturnStep2SelectReasonsForm } from './return-steps/return-step-2-select-reasons-form';

export const ReturnStepper = ({ order }: { order: OrderType }) => {
  const {
    organisation: {
      return_settings: { reasons },
    },
  } = useOrganisation();

  const t = useTranslations('ReturnStepper');
  const [setOrder, setBillingCustomer] = useReturnFormStore(
    useShallow(state => [state.setOrder, state.setBillingCustomer]),
  );

  useEffect(() => {
    if (!useReturnFormStore.persist.hasHydrated()) useReturnFormStore.persist.rehydrate();
    setOrder(order);
    setBillingCustomer(order.destination);
  }, [order]);

  const steps: StepperProps['steps'] = useMemo(
    () => [
      {
        title: t('step1.title'),
        description: t('step1.description'),
        component: (
          <div className="container max-w-screen-md">
            <ReturnStep1SelectOrderItemsForm order={order} />
          </div>
        ),
        skip: !order?.order_items?.length,
      },
      {
        title: t('step2.title'),
        description: t('step2.description'),
        component: (
          <div className="container max-w-screen-lg">
            <ReturnStep2SelectReasonsForm reasons={reasons} />
          </div>
        ),
      },
      // {
      //   title: t('step3.title'),
      //   description: t('step3.description'),
      //
      //   component: <ReturnStep3SelectLocation order={order} />,
      // },
      {
        title: t('step3.title'),
        description: t('step3.description'),
        component: (
          <div className="container max-w-screen-lg">
            <ReturnStep4RequestLabel />
          </div>
        ),
      },
    ],
    [],
  );

  return <Stepper className="container mb-8 max-w-screen-md" steps={steps} t={t} />;
};

@SanderCokart I want to reassure you that I will be refactoring this PR using my new library @stepperize/react. In the next few days there will be a big update of just documentation to make everything even clearer and more examples, but I think with that documentation you can achieve some great things.

@damianricobelli
Copy link
Contributor Author

Update --> New docs and improvements in the stepperize API! --> https://stepperize.vercel.app/

Next step (finally!) --> using the library in this PR to create the primitives for Shadcn. Again I apologize for my delay, I've had a busy few weeks and couldn't dedicate the time I needed to all of this

@SanderCokart
Copy link

Screenshot_2024-09-30-08-52-15-38_df198e732186825c8df26e3c5a10d7cd.jpg

@gianniskotsas
Copy link

Thanks Damian, really looking forward to testing this!

@damianricobelli
Copy link
Contributor Author

damianricobelli commented Sep 30, 2024

@SanderCokart This is a compatibility issue mentioned in the Stackblitz docs. I could add some flag indicating that maybe

@damianricobelli
Copy link
Contributor Author

Building the API for Shadcn UI using @stepperize/react... 🧠

@yah-23
Copy link

yah-23 commented Oct 18, 2024

@damianricobelli Any idea when it will be available?

@damianricobelli
Copy link
Contributor Author

@yah-23 not yet, I'm trying to find some free time in my week to be able to continue with the API. A lot of workload unfortunately. But I'm working on it

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.