|
1 | 1 | import { type ProviderProps, useTour } from "@reactour/tour"; |
2 | 2 | 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"; |
4 | 5 |
|
| 6 | +import { Badge } from "@/components/ui/badge"; |
5 | 7 | import { Button } from "@/components/ui/button"; |
6 | 8 | import { Icon } from "@/components/ui/icon"; |
7 | 9 | import { BlockStack } from "@/components/ui/layout"; |
| 10 | +import { cn } from "@/lib/utils"; |
8 | 11 | import { APP_ROUTES } from "@/routes/router"; |
9 | 12 | import { tracking } from "@/utils/tracking"; |
10 | 13 |
|
@@ -170,6 +173,133 @@ function clampPopoverElement(el: HTMLElement): void { |
170 | 173 | } |
171 | 174 | } |
172 | 175 |
|
| 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 | + |
173 | 303 | // Reactour has no viewport-padding setting and lets the popover snap flush to |
174 | 304 | // edges, which clips our step-number badge. We observe its inline transform |
175 | 305 | // and clamp it to stay POPOVER_VIEWPORT_MARGIN inside the viewport. |
|
0 commit comments