import * as React from 'react'; import { Badge } from '@/components/badge'; import { Button } from '@/components/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/dialog'; import { Progress } from '@/components/progress'; import { ChevronLeftIcon, ChevronRightIcon } from '@/icons'; import { useMessages } from '~/i18n'; import { SLIDES } from './tour-slides'; /** * The intro tour: six slides that open by themselves on a first visit and can * be reopened from the header afterwards. * * It exists because everything this site can do was, until now, discoverable * only by clicking around — forty rail links and an overview page, with the * playground, the copy buttons, the variant grids and the Figma parity report * all one level in. `/introduction` documents the kit in prose, but you have to * know to go there. * * Whether it is on screen is `use-tour.ts`; which slide says what is * `tour-slides.tsx`. This file is only the frame around them, and it is all * kit components, per the rule at the top of `toolbar.tsx` — including the * `Progress` bar, which is a component the tour is describing while it uses it. */ export function Tour({ open, onClose }: { open: boolean; onClose: () => void }) { const m = useMessages(); const [index, setIndex] = React.useState(0); const total = SLIDES.length; const slide = SLIDES[index]; const first = index === 0; const last = index === total - 1; const go = React.useCallback((next: number) => { setIndex(Math.min(SLIDES.length - 1, Math.max(0, next))); }, []); /* * Rewind on the way out, not on the way in. * * Reopening from the header has to start at the first slide — someone * pressing that button wants the intro, not the slide they stopped on three * weeks ago — and the obvious spelling is an effect watching `open`. That is * a setState inside an effect, which `pnpm lint` rejects and is right to: * closing is already an event, so the reset belongs there and costs no extra * render. * * Every route out of the dialog goes through here — Skip, the final button, * a link on the last slide, the X, Escape and a click on the overlay — which * is the reason it is one function rather than `onClose` passed around. */ const close = React.useCallback(() => { setIndex(0); onClose(); }, [onClose]); return ( (next ? undefined : close())}> { if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return; const target = e.target as HTMLElement | null; if (target?.closest('input, textarea, [contenteditable], pre')) return; e.preventDefault(); go(e.key === 'ArrowRight' ? index + 1 : index - 1); }} /* The last slide offers four links into the site. Following one while the dialog stays open would leave the page hidden behind it. One delegated handler rather than a prop threaded into every link — the same move `Rail` makes to close the drawer on navigation. */ onClick={(e) => { if ((e.target as HTMLElement).closest('a')) close(); }} > {/* `pr-8` keeps the heading clear of the close button, which Radix positions absolutely at `top-4 right-4` inside this padding. */}

{m.tour.title}

{slide.title(m)} {slide.body(m)}
{/* What a screen reader hears when the slide changes. The live region is this one line rather than the demo below it: focus stays on Next across a slide change, so nothing would otherwise be announced — but wrapping the demo itself would read out a grid of fifteen buttons every time somebody pressed a key. */} {m.tour.step(index + 1, total)}: {slide.title(m)} {/* A floor, not a fixed height: the slides differ by a couple of rows and without it the dialog jumps under the cursor between Next presses. The cap is for short viewports, where the demo scrolls rather than pushing the footer off screen. */}
{m.tour.step(index + 1, total)}
{/* `sm:mr-auto` puts the way out on the far left, away from the pair that moves you forward. Gone on the last slide, where the primary button already closes the dialog. */} {last ? null : ( )} {/* Disabled rather than hidden on the first slide: a control that appears after the first press moves the primary button sideways exactly when someone is aiming at it. */}
); }