Skip to content

Commit b9acf61

Browse files
committed
feat: Guided Tours Navigation Dots Track Progress
1 parent e97b036 commit b9acf61

2 files changed

Lines changed: 133 additions & 1 deletion

File tree

‎src/providers/TourProvider/TourPopover.tsx‎

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { type ProviderProps, useTour } from "@reactour/tour";
22
import { useNavigate } from "@tanstack/react-router";
3-
import { useEffect } from "react";
3+
import type { FC, PropsWithChildren, ReactNode } from "react";
4+
import { useEffect, useRef } from "react";
45

6+
import { Badge } from "@/components/ui/badge";
57
import { Button } from "@/components/ui/button";
68
import { Icon } from "@/components/ui/icon";
79
import { BlockStack } from "@/components/ui/layout";
10+
import { cn } from "@/lib/utils";
811
import { APP_ROUTES } from "@/routes/router";
912
import { tracking } from "@/utils/tracking";
1013

@@ -170,6 +173,133 @@ function clampPopoverElement(el: HTMLElement): void {
170173
}
171174
}
172175

176+
type ComponentsProp = NonNullable<ProviderProps["components"]>;
177+
type NavigationProps = React.ComponentProps<
178+
NonNullable<ComponentsProp["Navigation"]>
179+
>;
180+
181+
type NavButtonProps = {
182+
onClick?: () => void;
183+
kind?: "next" | "prev";
184+
hideArrow?: boolean;
185+
};
186+
187+
export function TourNavigation(props: NavigationProps) {
188+
const {
189+
setCurrentStep,
190+
currentStep,
191+
steps,
192+
nextButton,
193+
prevButton,
194+
setIsOpen,
195+
hideButtons,
196+
hideDots,
197+
disableAll,
198+
rtl,
199+
} = props;
200+
201+
const stepsLength = steps.length;
202+
203+
const visitedMaxRef = useRef(currentStep);
204+
if (currentStep > visitedMaxRef.current) {
205+
visitedMaxRef.current = currentStep;
206+
}
207+
const visited = visitedMaxRef.current;
208+
209+
const NavButton: FC<PropsWithChildren<NavButtonProps>> = ({
210+
onClick,
211+
kind = "next",
212+
hideArrow,
213+
children,
214+
}) => {
215+
const isDisabled = disableAll
216+
? true
217+
: kind === "next"
218+
? stepsLength - 1 === currentStep
219+
: currentStep === 0;
220+
const handleClick = () => {
221+
if (disableAll) return;
222+
if (onClick) onClick();
223+
else if (kind === "next")
224+
setCurrentStep(Math.min(currentStep + 1, stepsLength - 1));
225+
else setCurrentStep(Math.max(currentStep - 1, 0));
226+
};
227+
const inverted = rtl ? kind === "prev" : kind === "next";
228+
return (
229+
<Button
230+
variant="ghost"
231+
size="icon"
232+
onClick={handleClick}
233+
disabled={isDisabled}
234+
aria-label={`Go to ${kind} step`}
235+
>
236+
{!hideArrow ? (
237+
<Icon name={inverted ? "ChevronRight" : "ChevronLeft"} />
238+
) : null}
239+
{children}
240+
</Button>
241+
);
242+
};
243+
244+
const btnCtx = {
245+
Button: NavButton,
246+
setCurrentStep,
247+
currentStep,
248+
stepsLength,
249+
setIsOpen,
250+
steps,
251+
};
252+
253+
const renderPrev: ReactNode = !hideButtons ? (
254+
typeof prevButton === "function" ? (
255+
prevButton(btnCtx)
256+
) : (
257+
<NavButton kind="prev" />
258+
)
259+
) : null;
260+
261+
const renderNext: ReactNode = !hideButtons ? (
262+
typeof nextButton === "function" ? (
263+
nextButton(btnCtx)
264+
) : (
265+
<NavButton kind="next" />
266+
)
267+
) : null;
268+
269+
return (
270+
<div
271+
dir={rtl ? "rtl" : "ltr"}
272+
className="flex flex-row items-center justify-between mt-6"
273+
>
274+
{renderPrev}
275+
{!hideDots ? (
276+
<div
277+
aria-hidden
278+
className="flex flex-row items-center justify-between flex-wrap"
279+
>
280+
{Array.from({ length: stepsLength }, (_, i) => i).map((index) => {
281+
const isCurrent = index === currentStep;
282+
const isVisited = index <= visited && !isCurrent;
283+
return (
284+
<Badge
285+
key={`tour-dot-${index}`}
286+
variant="dot"
287+
className={cn(
288+
"m-1 transition-all",
289+
isCurrent && "text-blue-500 scale-125",
290+
isVisited && "text-blue-300",
291+
!isCurrent && !isVisited && "text-zinc-300",
292+
)}
293+
/>
294+
);
295+
})}
296+
</div>
297+
) : null}
298+
{renderNext}
299+
</div>
300+
);
301+
}
302+
173303
// Reactour has no viewport-padding setting and lets the popover snap flush to
174304
// edges, which clips our step-number badge. We observe its inline transform
175305
// and clamp it to stay POPOVER_VIEWPORT_MARGIN inside the viewport.

‎src/providers/TourProvider/TourProvider.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import {
66
POPOVER_STYLES,
77
PopoverClampBridge,
88
renderNextButton,
9+
TourNavigation,
910
} from "./TourPopover";
1011

1112
export function TourProvider({ children }: { children: ReactNode }) {
1213
return (
1314
<ReactourProvider
1415
steps={[]}
1516
styles={POPOVER_STYLES}
17+
components={{ Navigation: TourNavigation }}
1618
scrollSmooth
1719
showBadge
1820
showCloseButton={false}

0 commit comments

Comments
 (0)