From 1ce8343a9e283d2d5355a87636c1d118ad36821e Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Fri, 29 May 2026 11:59:53 -0700 Subject: [PATCH] feat: Guided Tours Navigation Dots Track Progress --- src/providers/TourProvider/TourPopover.tsx | 132 +++++++++++++++++++- src/providers/TourProvider/TourProvider.tsx | 2 + 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/src/providers/TourProvider/TourPopover.tsx b/src/providers/TourProvider/TourPopover.tsx index 468421e59..7a7f3a85a 100644 --- a/src/providers/TourProvider/TourPopover.tsx +++ b/src/providers/TourProvider/TourPopover.tsx @@ -1,10 +1,13 @@ import { type ProviderProps, useTour } from "@reactour/tour"; import { useNavigate } from "@tanstack/react-router"; -import { useEffect } from "react"; +import type { FC, PropsWithChildren, ReactNode } from "react"; +import { useEffect, useRef } from "react"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { BlockStack } from "@/components/ui/layout"; +import { cn } from "@/lib/utils"; import { APP_ROUTES } from "@/routes/router"; import { tracking } from "@/utils/tracking"; @@ -170,6 +173,133 @@ function clampPopoverElement(el: HTMLElement): void { } } +type ComponentsProp = NonNullable; +type NavigationProps = React.ComponentProps< + NonNullable +>; + +type NavButtonProps = { + onClick?: () => void; + kind?: "next" | "prev"; + hideArrow?: boolean; +}; + +export function TourNavigation(props: NavigationProps) { + const { + setCurrentStep, + currentStep, + steps, + nextButton, + prevButton, + setIsOpen, + hideButtons, + hideDots, + disableAll, + rtl, + } = props; + + const stepsLength = steps.length; + + const visitedMaxRef = useRef(currentStep); + if (currentStep > visitedMaxRef.current) { + visitedMaxRef.current = currentStep; + } + const visited = visitedMaxRef.current; + + const NavButton: FC> = ({ + onClick, + kind = "next", + hideArrow, + children, + }) => { + const isDisabled = disableAll + ? true + : kind === "next" + ? stepsLength - 1 === currentStep + : currentStep === 0; + const handleClick = () => { + if (disableAll) return; + if (onClick) onClick(); + else if (kind === "next") + setCurrentStep(Math.min(currentStep + 1, stepsLength - 1)); + else setCurrentStep(Math.max(currentStep - 1, 0)); + }; + const inverted = rtl ? kind === "prev" : kind === "next"; + return ( + + ); + }; + + const btnCtx = { + Button: NavButton, + setCurrentStep, + currentStep, + stepsLength, + setIsOpen, + steps, + }; + + const renderPrev: ReactNode = !hideButtons ? ( + typeof prevButton === "function" ? ( + prevButton(btnCtx) + ) : ( + + ) + ) : null; + + const renderNext: ReactNode = !hideButtons ? ( + typeof nextButton === "function" ? ( + nextButton(btnCtx) + ) : ( + + ) + ) : null; + + return ( +
+ {renderPrev} + {!hideDots ? ( +
+ {Array.from({ length: stepsLength }, (_, i) => i).map((index) => { + const isCurrent = index === currentStep; + const isVisited = index <= visited && !isCurrent; + return ( + + ); + })} +
+ ) : null} + {renderNext} +
+ ); +} + // Reactour has no viewport-padding setting and lets the popover snap flush to // edges, which clips our step-number badge. We observe its inline transform // and clamp it to stay POPOVER_VIEWPORT_MARGIN inside the viewport. diff --git a/src/providers/TourProvider/TourProvider.tsx b/src/providers/TourProvider/TourProvider.tsx index b4e07130e..5c5a5f75b 100644 --- a/src/providers/TourProvider/TourProvider.tsx +++ b/src/providers/TourProvider/TourProvider.tsx @@ -6,6 +6,7 @@ import { POPOVER_STYLES, PopoverClampBridge, renderNextButton, + TourNavigation, } from "./TourPopover"; export function TourProvider({ children }: { children: ReactNode }) { @@ -13,6 +14,7 @@ export function TourProvider({ children }: { children: ReactNode }) {