From aad5a7875478a8539503d87852dd35057ca7fc73 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/TourContent.tsx | 2 +- .../TourProvider/TourNavigation.test.tsx | 232 ++++++++++++++ src/providers/TourProvider/TourNavigation.tsx | 299 ++++++++++++++++++ src/providers/TourProvider/TourPopover.tsx | 71 ++--- .../TourProvider/TourProgressContext.test.tsx | 40 +++ .../TourProvider/TourProgressContext.tsx | 56 ++++ src/providers/TourProvider/TourProvider.tsx | 45 +-- .../TourProvider/tourActionLabels.ts | 20 ++ 8 files changed, 706 insertions(+), 59 deletions(-) create mode 100644 src/providers/TourProvider/TourNavigation.test.tsx create mode 100644 src/providers/TourProvider/TourNavigation.tsx create mode 100644 src/providers/TourProvider/TourProgressContext.test.tsx create mode 100644 src/providers/TourProvider/TourProgressContext.tsx create mode 100644 src/providers/TourProvider/tourActionLabels.ts diff --git a/src/providers/TourProvider/TourContent.tsx b/src/providers/TourProvider/TourContent.tsx index bcf8f23c6..a6a543efe 100644 --- a/src/providers/TourProvider/TourContent.tsx +++ b/src/providers/TourProvider/TourContent.tsx @@ -2,7 +2,7 @@ import { Fragment, type ReactNode } from "react"; const INLINE_TOKEN = /(\*\*[^*]+\*\*|_[^_]+_|`[^`]+`)/g; -function renderInline(text: string): ReactNode[] { +export function renderInline(text: string): ReactNode[] { return text.split(INLINE_TOKEN).map((part, index) => { if (part.startsWith("**") && part.endsWith("**")) { return {part.slice(2, -2)}; diff --git a/src/providers/TourProvider/TourNavigation.test.tsx b/src/providers/TourProvider/TourNavigation.test.tsx new file mode 100644 index 000000000..143f8e4cc --- /dev/null +++ b/src/providers/TourProvider/TourNavigation.test.tsx @@ -0,0 +1,232 @@ +import type { StepType } from "@reactour/tour"; +import { act, render, screen } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; +import { useEffect } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + renderNextButton, + TourAutoAdvance, + TourStepChecklist, +} from "./TourNavigation"; +import { TourProgressProvider, useTourProgress } from "./TourProgressContext"; + +type TourState = { + isOpen: boolean; + currentStep: number; + steps: unknown[]; + setCurrentStep: ReturnType; +}; + +let tourState: TourState; + +vi.mock("@reactour/tour", () => ({ + useTour: () => tourState, +})); + +function makeStep(overrides: Record): StepType { + return { + selector: "body", + content: "step", + ...overrides, + } as unknown as StepType; +} + +function MarkComplete({ step }: { step: number }) { + const { markStepComplete } = useTourProgress(); + useEffect(() => { + markStepComplete(step); + }, [step, markStepComplete]); + return null; +} + +type NavButtonProps = PropsWithChildren<{ + onClick?: () => void; + kind?: "next" | "prev"; + hideArrow?: boolean; + disabled?: boolean; +}>; + +function FakeButton({ onClick, disabled, children }: NavButtonProps) { + return ( + + ); +} + +function renderNext(opts: { + steps: StepType[]; + currentStep: number; + complete?: number; +}) { + const props = { + Button: FakeButton, + setCurrentStep: vi.fn(), + stepsLength: opts.steps.length, + currentStep: opts.currentStep, + setIsOpen: vi.fn(), + steps: opts.steps, + }; + return render( + + {opts.complete !== undefined && } + {renderNextButton(props as Parameters[0])} + , + ); +} + +describe("TourStepChecklist", () => { + it("renders a labeled row for an interactive step", () => { + render( + + + , + ); + expect( + screen.getByText("Complete the highlighted action to continue"), + ).toBeInTheDocument(); + }); + + it("renders nothing for a non-interactive step", () => { + const { container } = render( + + + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("strikes through the label once the step is complete", () => { + render( + + + + , + ); + const label = screen.getByText( + "Complete the highlighted action to continue", + ); + expect(label.className).toContain("line-through"); + }); +}); + +describe("renderNextButton gating", () => { + it("disables Next on an interactive step that is not complete", () => { + renderNext({ + steps: [makeStep({ interaction: "select-task" }), makeStep({})], + currentStep: 0, + }); + expect(screen.getByTestId("next")).toBeDisabled(); + }); + + it("enables Next once the interactive step is complete", () => { + renderNext({ + steps: [makeStep({ interaction: "select-task" }), makeStep({})], + currentStep: 0, + complete: 0, + }); + expect(screen.getByTestId("next")).toBeEnabled(); + }); + + it("enables Next on a non-interactive step", () => { + renderNext({ + steps: [makeStep({}), makeStep({})], + currentStep: 0, + }); + expect(screen.getByTestId("next")).toBeEnabled(); + }); + + it("hides the Next button on the last step", () => { + const { container } = renderNext({ + steps: [makeStep({}), makeStep({ interaction: "select-task" })], + currentStep: 1, + }); + expect(container.querySelector("[aria-hidden]")).not.toBeNull(); + }); +}); + +const progress: { markComplete: (step: number) => void } = { + markComplete: () => undefined, +}; +function CaptureProgress() { + const { markStepComplete } = useTourProgress(); + useEffect(() => { + progress.markComplete = markStepComplete; + }, [markStepComplete]); + return null; +} + +function renderController(currentStep: number, stepCount = 3) { + tourState = { + isOpen: true, + currentStep, + steps: Array.from({ length: stepCount }, () => ({})), + setCurrentStep: vi.fn(), + }; + const tree = ( + + + + + ); + const result = render(tree); + const navigateTo = (step: number) => { + tourState = { ...tourState, currentStep: step }; + result.rerender(tree); + }; + return { navigateTo }; +} + +describe("TourAutoAdvance", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("auto-advances after a fresh completion, once the delay elapses", () => { + renderController(0); + act(() => progress.markComplete(0)); + + act(() => vi.advanceTimersByTime(500)); + expect(tourState.setCurrentStep).not.toHaveBeenCalled(); + + act(() => vi.advanceTimersByTime(400)); + expect(tourState.setCurrentStep).toHaveBeenCalledTimes(1); + }); + + it("does not auto-advance a step that has not been completed", () => { + renderController(0); + act(() => vi.advanceTimersByTime(2000)); + expect(tourState.setCurrentStep).not.toHaveBeenCalled(); + }); + + it("does not auto-advance the last step", () => { + renderController(2, 3); + act(() => progress.markComplete(2)); + act(() => vi.advanceTimersByTime(2000)); + expect(tourState.setCurrentStep).not.toHaveBeenCalled(); + }); + + it("does not auto-advance a step already complete on arrival (revisit)", () => { + const { navigateTo } = renderController(0); + + act(() => progress.markComplete(0)); + act(() => vi.advanceTimersByTime(900)); + expect(tourState.setCurrentStep).toHaveBeenCalledTimes(1); + tourState.setCurrentStep.mockClear(); + + // Navigate forward, then back to step 0 (which is already complete). + act(() => navigateTo(1)); + act(() => navigateTo(0)); + act(() => vi.advanceTimersByTime(2000)); + expect(tourState.setCurrentStep).not.toHaveBeenCalled(); + }); +}); diff --git a/src/providers/TourProvider/TourNavigation.tsx b/src/providers/TourProvider/TourNavigation.tsx new file mode 100644 index 000000000..f8a319f28 --- /dev/null +++ b/src/providers/TourProvider/TourNavigation.tsx @@ -0,0 +1,299 @@ +import { type ProviderProps, type StepType, useTour } from "@reactour/tour"; +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, InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; + +import { tourActionLabel } from "./tourActionLabels"; +import { renderInline } from "./TourContent"; +import { useTourProgress } from "./TourProgressContext"; + +const tourVisitedMax = { value: 0 }; +function recordTourVisited(step: number): void { + if (step > tourVisitedMax.value) tourVisitedMax.value = step; +} +function getTourVisitedMax(): number { + return tourVisitedMax.value; +} +export function resetTourVisited(): void { + tourVisitedMax.value = 0; +} + +type NextButtonProps = Parameters>[0]; + +function GatedNextButton(props: NextButtonProps) { + const { currentStep, stepsLength, setCurrentStep, steps } = props; + const { isStepComplete } = useTourProgress(); + + // Runtime Button is always TourNavigation's NavButton (which accepts + // `disabled`); reactour's narrower type omits it. + const Button = props.Button as FC>; + + const hiddenPlaceholder = ( + + + ); +} + +export function renderNextButton(props: NextButtonProps) { + return ; +} + +type ComponentsProp = NonNullable; +type NavigationProps = React.ComponentProps< + NonNullable +>; + +type NavButtonProps = { + onClick?: () => void; + kind?: "next" | "prev"; + hideArrow?: boolean; + disabled?: boolean; +}; + +type ChecklistStep = StepType & { + interaction?: string; + targetTaskName?: string; + targetWindowName?: string; + targetFolderName?: string; + targetSearchTerm?: string; + targetArgumentName?: string; + targetMinCount?: number; +}; + +export function TourStepChecklist({ + step, + stepIndex, +}: { + step: StepType | undefined; + stepIndex: number; +}) { + const { isStepComplete } = useTourProgress(); + const interactiveStep = step as ChecklistStep | undefined; + if (!interactiveStep?.interaction) return null; + + const complete = isStepComplete(stepIndex); + return ( + + + + {renderInline(tourActionLabel(interactiveStep))} + + + ); +} + +export function TourNavigation(props: NavigationProps) { + const { + setCurrentStep, + currentStep, + steps, + nextButton, + prevButton, + setIsOpen, + hideButtons, + hideDots, + disableAll, + rtl, + } = props; + + const stepsLength = steps.length; + + recordTourVisited(currentStep); + const visited = getTourVisitedMax(); + + const NavButton: FC> = ({ + onClick, + kind = "next", + hideArrow, + disabled, + children, + }) => { + const baseDisabled = + kind === "next" ? stepsLength - 1 === currentStep : currentStep === 0; + const isDisabled = disableAll ? true : baseDisabled || !!disabled; + const handleClick = () => { + if (isDisabled) 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} +
+
+ ); +} + +const AUTO_ADVANCE_DELAY_MS = 800; + +export function TourAutoAdvance() { + const { isOpen, currentStep, steps, setCurrentStep } = useTour(); + const { completedSteps, isStepComplete } = useTourProgress(); + + const armedStepRef = useRef(null); + const visitedStepRef = useRef(null); + + useEffect(() => { + if (!isOpen) { + visitedStepRef.current = null; + armedStepRef.current = null; + return undefined; + } + + // On arrival at a step, auto-advance only if it isn't already complete. + if (visitedStepRef.current !== currentStep) { + visitedStepRef.current = currentStep; + armedStepRef.current = isStepComplete(currentStep) ? null : currentStep; + } + + const isLastStep = currentStep >= steps.length - 1; + if ( + armedStepRef.current !== currentStep || + !isStepComplete(currentStep) || + isLastStep + ) { + return undefined; + } + + const timer = setTimeout(() => { + setCurrentStep((s: number) => Math.min(s + 1, steps.length - 1)); + }, AUTO_ADVANCE_DELAY_MS); + return () => clearTimeout(timer); + }, [ + isOpen, + currentStep, + completedSteps, + isStepComplete, + steps, + setCurrentStep, + ]); + + return null; +} diff --git a/src/providers/TourProvider/TourPopover.tsx b/src/providers/TourProvider/TourPopover.tsx index c20c0f598..284d918a2 100644 --- a/src/providers/TourProvider/TourPopover.tsx +++ b/src/providers/TourProvider/TourPopover.tsx @@ -1,4 +1,4 @@ -import { type ProviderProps, useTour } from "@reactour/tour"; +import { useTour } from "@reactour/tour"; import { useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; @@ -8,8 +8,15 @@ import { BlockStack } from "@/components/ui/layout"; import { APP_ROUTES } from "@/routes/router"; import { tracking } from "@/utils/tracking"; +import { resetTourVisited } from "./TourNavigation"; +import { useTourProgress } from "./TourProgressContext"; + // Matches the step-number badge's ≈13px outside offset plus a small margin. const POPOVER_VIEWPORT_MARGIN = 16; +// Popover padding (40) + prev icon button (~32) + "Next" text button (~60) + buffer. +const POPOVER_NAV_BASE_WIDTH = 140; +const POPOVER_DOT_WIDTH = 16; +const POPOVER_DEFAULT_MAX_WIDTH = 420; export const POPOVER_STYLES = { popover: (base: object) => ({ @@ -17,7 +24,7 @@ export const POPOVER_STYLES = { borderRadius: "0.75rem", padding: "1.25rem", boxShadow: "0 10px 30px rgba(0,0,0,0.12)", - maxWidth: "420px", + maxWidth: `${POPOVER_DEFAULT_MAX_WIDTH}px`, }), maskWrapper: (base: object) => ({ ...base, @@ -88,8 +95,6 @@ export function computeDefaultPopoverPosition( return "bottom"; } -type NextButtonProps = Parameters>[0]; - export function TourCompletionActions() { const navigate = useNavigate(); const { setIsOpen } = useTour(); @@ -114,40 +119,6 @@ export function TourCompletionActions() { ); } -export function renderNextButton(props: NextButtonProps) { - const { Button, currentStep, stepsLength, setCurrentStep, steps } = props; - - const hiddenPlaceholder = ( - -