WelcomeDialog
Alert dialog that proposes a guide once the records are hydrated: Start, or Resume / Start over for an in-progress one, and Skip.
Installation
Terminal
pnpm dlx shadcn@latest add https://ui-registry.com/r/guide/welcome-dialogSource
The file you get once installed. It is yours: edit the markup, the labels and the styles.
welcome-dialog.tsx
"use client";
import type { GuideState } from "@ui-registries/guide";import { useGuide } from "@ui-registries/guide/react";import { type ReactNode, useCallback, useRef, useState } from "react";import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,} from "@/components/ui/alert-dialog";import { Button } from "@/components/ui/button";
/** What the dialog offers: a first start, or resuming an in-progress record. */type Offer = "start" | "resume" | null;
export interface WelcomeDialogLabels { /** Resume variant: starts over from the first step. */ restart: ReactNode; resume: ReactNode; skip: ReactNode; start: ReactNode;}
const DEFAULT_LABELS: WelcomeDialogLabels = { start: "Start the tour", resume: "Resume", restart: "Start over", skip: "Skip",};
export interface WelcomeDialogProps { className?: string; description: ReactNode; /** Gate it (role, feature flag…). Default `true`. */ enabled?: boolean; /** Id of the guide to propose. */ guideId: string; /** Button labels; English by default. */ labels?: Partial<WelcomeDialogLabels>; /** Resume variant; default: `description`. */ resumeDescription?: ReactNode; /** Resume variant; default: `title`. */ resumeTitle?: ReactNode; title: ReactNode;}
/** * Auto-start example for a `manual` guide. Once the records are hydrated, it * opens when the guide has no record (Start) or an in-progress one (Resume / * Start over). Skip records the guide as dismissed; Escape only closes the * dialog for this visit. The run starts once the dialog has finished closing, * so the dialog's focus restore does not fight the tour frame's focus. */export function WelcomeDialog({ className, description, enabled = true, guideId, labels, resumeDescription, resumeTitle, title,}: WelcomeDialogProps) { const text = { ...DEFAULT_LABELS, ...labels }; const selectOffer = useCallback( (state: GuideState): Offer | "pending" => { if (!state.hydrated) { return "pending"; } if (state.runs.some((run) => run.guide.id === guideId)) { return null; } const record = state.records[guideId] ?? null; if (!record) { return "start"; } return record.status === "in-progress" ? "resume" : null; }, [guideId] ); const { state: offer, start, end } = useGuide(selectOffer);
// Decided once, when the records are known: a run ending later with an // in-progress record (missing target, error) does not reopen the dialog. const [decided, setDecided] = useState<Offer | undefined>(undefined); const [closed, setClosed] = useState(false); if (decided === undefined && enabled && offer !== "pending") { setDecided(offer); }
// `offer === null`: the guide started or got a record elsewhere (another // tab, a `visible` trigger) while the dialog was open. const open = enabled && !closed && (decided ?? null) !== null && offer !== null; const resuming = decided === "resume";
const startFrom = useRef<"resume" | "start" | null>(null); const begin = (from: "resume" | "start") => { startFrom.current = from; setClosed(true); }; const onOpenChangeComplete = (isOpen: boolean) => { const from = startFrom.current; if (isOpen || from === null) { return; } startFrom.current = null; start(guideId, { from }); }; const skip = () => { setClosed(true); end(guideId, "dismissed"); };
return ( <AlertDialog onOpenChange={(next) => { if (!next) { setClosed(true); } }} onOpenChangeComplete={onOpenChangeComplete} open={open} > <AlertDialogContent className={className}> <AlertDialogHeader> <AlertDialogTitle> {resuming ? (resumeTitle ?? title) : title} </AlertDialogTitle> <AlertDialogDescription> {resuming ? (resumeDescription ?? description) : description} </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel onClick={skip}>{text.skip}</AlertDialogCancel> {resuming ? ( <Button onClick={() => begin("start")} variant="outline"> {text.restart} </Button> ) : null} <AlertDialogAction onClick={() => begin(resuming ? "resume" : "start")} > {resuming ? text.resume : text.start} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> );}