diff --git a/.github/assets/demo-poster.png b/.github/assets/demo-poster.png new file mode 100644 index 0000000..6e7a92b Binary files /dev/null and b/.github/assets/demo-poster.png differ diff --git a/.github/assets/demo.gif b/.github/assets/demo.gif new file mode 100644 index 0000000..d5433d4 Binary files /dev/null and b/.github/assets/demo.gif differ diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..33ae480 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,62 @@ +name: Live demo + +# Deploys the showcase (demos/react, `--mode showcase`) to GitHub Pages: the README's live demo. +# The CI end-to-end tests run the showcase on every pull request, so a broken demo never gets here. +on: + push: + branches: + - 'main' + paths: + - 'src/**' + - 'demos/react/**' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/pages.yml' + workflow_dispatch: + +concurrency: + group: pages + cancel-in-progress: false + +permissions: + contents: read + pages: write + id-token: write + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .nvmrc + cache: npm + + # The root install provides the library's types; the showcase builds from its source. + - name: Install dependencies + run: | + npm ci + npm ci --prefix demos/react + + - name: Build the showcase + run: npm run build:showcase --prefix demos/react + + - name: Configure Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + - name: Upload the showcase + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: demos/react/dist-showcase + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.gitignore b/.gitignore index f6327ea..30f1d0d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules # Build output dist +dist-showcase # Turbo .turbo diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2d5729e..ca20f3f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -47,7 +47,7 @@ If you are about to write an `if` in a platform folder, ask whether it is a deci 2. If it needs a new platform fact or a new drawing primitive, add it to `PlatformAdapter` or `TourRenderer`. Both platforms now fail to compile until they implement it — that is the point. 3. Test the behaviour in the [shared behaviour suite](test/support/tour-behavior.suite.tsx). It runs against the web provider and the React Native provider (`tour-behavior.test.tsx` in `test/web` and `test/native`); a feature that works on only one of them fails there. 4. If the feature adds an export, add it to both [`src/react.ts`](src/react.ts) and [`src/react-native.ts`](src/react-native.ts). The [entry parity test](test/unit/entry-parity.test.ts) fails when one entry exports something the other does not, or when they export different implementations of a shared name. Platform-only exports must be declared there, with the reason. -5. Try it in both demos: `npm run demo:web`, `npm run demo:ios` / `npm run demo:android` (they run the source directly). +5. Try it in both demos: `npm run demo:web`, `npm run demo:ios` / `npm run demo:android` (they run the source directly). If it changes how the tour looks, record the README's preview again from the showcase (`npm run demo:record`). ## What is platform-only, and why @@ -73,13 +73,13 @@ Platform-only exports are listed in the entry parity test. All tests live in `test/`; the configuration files (`vitest.config.ts`, `playwright.config.ts`) are at the root. -| Folder | What | Run with | -| ---------------- | ----------------------------------------------------------------------- | -------------------- | -| `test/unit` | engine, geometry, platform logic, entry parity (Node), mirroring `src/` | `npm run test:unit` | -| `test/web` | the web provider in a real browser | `npm test` | -| `test/native` | the React Native provider, through react-native-web, in a real browser | `npm test` | -| `test/jsdom` | a whole tour in jsdom, as apps' own test suites run it | `npm test` | -| `test/support` | the shared behaviour suite and helpers (fake adapter, browser setup) | | -| `test/e2e` | the web demo on desktop and mobile browsers (Playwright) | `npm run test:e2e` | -| `test/maestro` | the React Native demo on simulators and devices (Maestro) | see the README | -| `smoke-node.mjs` | the packed package, imported in plain Node with a server render | `npm run smoke:node` | +| Folder | What | Run with | +| ---------------- | ------------------------------------------------------------------------- | -------------------- | +| `test/unit` | engine, geometry, platform logic, entry parity (Node), mirroring `src/` | `npm run test:unit` | +| `test/web` | the web provider in a real browser | `npm test` | +| `test/native` | the React Native provider, through react-native-web, in a real browser | `npm test` | +| `test/jsdom` | a whole tour in jsdom, as apps' own test suites run it | `npm test` | +| `test/support` | the shared behaviour suite and helpers (fake adapter, browser setup) | | +| `test/e2e` | the web demo and the showcase on desktop and mobile browsers (Playwright) | `npm run test:e2e` | +| `test/maestro` | the React Native demo on simulators and devices (Maestro) | see the README | +| `smoke-node.mjs` | the packed package, imported in plain Node with a server render | `npm run smoke:node` | diff --git a/CHANGELOG.md b/CHANGELOG.md index eb0b436..e021753 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ Releases are published automatically from `main`; each GitHub release lists its commits. This file describes the larger changes. +## 1.1.1 + +### Fixed + +- When a step's `onBeforeStep` navigated to another page or screen and then took a moment (loading data, for example), the previous step stayed on screen over the new page, its highlight around whatever was there now, until the spinner appeared. Once its target is gone, the previous step now goes at once; the screen stays dimmed and the spinner still appears only after a moment. On React Native, this applies to screens that are left (closed or popped): a screen opened on top keeps the previous one, and with it the target, in place. + ## 1.1.0 ### Added diff --git a/README.md b/README.md index d3217f0..15130ca 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,17 @@ Guided tours for React and React Native that never leave the screen. +

+ + + + An onboarding tour in a web app: it welcomes the user, highlights the search field and the New project button at the screen's edge, scrolls down to the activity feed, shows a spinner while the Team page loads, highlights Invite teammates there, and ends back on the Help button. + + +

+ +

Try the live demo →

+ Highlight any element, explain it in a tooltip, and walk users through your app — across pages and screens, into scroll containers and modals. The tour takes care of everything around it: it waits for targets to appear, scrolls them into view (clear of sticky headers and floating buttons), keeps the tooltip on screen on every device, animates smoothly, and is accessible out of the box. ```tsx @@ -575,7 +586,7 @@ The tour is rendered as a modal `` in the browser's top layer, so it is ## What the tour does for you - **Keeps everything in sight.** Before a step is shown, its target is scrolled into view — through every scroll container around it, including horizontal ones — with room for the tooltip. Elements that stay put while the page scrolls (sticky headers, cookie banners, floating buttons; any number, any shape) are detected and the target is scrolled clear of them, in both directions. Targets larger than the screen are aligned to their start. The tooltip is placed inside the visible screen minus safe areas and the on-screen keyboard, flips to another side when there is no room, and scrolls its own content if it is taller than the screen. When the window is resized or rotated, or your app or an animation moves the target, the tour follows. -- **Waits instead of guessing.** Targets that are not rendered yet (lazy pages, animations, data loading) are awaited, event-driven, for up to 5 seconds. When a step takes a moment to prepare (more than 300 ms), the tour shows that it is waiting: the highlight closes, the screen is dimmed and a spinner appears in the middle (announced to screen readers as `loadingAriaLabel`). Pressing the dimmed area meanwhile leaves the tour. +- **Waits instead of guessing.** Targets that are not rendered yet (lazy pages, animations, data loading) are awaited, event-driven, for up to 5 seconds. When a step takes a moment to prepare (more than 300 ms), the tour shows that it is waiting: the highlight closes, the screen is dimmed and a spinner appears in the middle (announced to screen readers as `loadingAriaLabel`). If the previous step's target goes away meanwhile (`onBeforeStep` navigated to another page, for example), that step does not stay over the new page: it goes at once. Pressing the dimmed area meanwhile leaves the tour. - **Animates.** The tour fades in, the highlight glides from target to target together with the scroll, and the tooltip enters from the side it is on. On React Native every animation runs on the UI thread, so it stays smooth while JavaScript is busy. With reduced motion turned on in the system, movement is replaced by short fades. - **Is accessible.** On the web the tour is a modal dialog (`aria-modal`, labelled by the step title, described by its content): focus moves into the tooltip and back to where it was when the tour ends, the rest of the page is inert, arrow keys work (but leave text fields, sliders, radio groups and other controls in a custom tooltip alone), Esc closes the tour, a long tooltip scrolls with the keyboard, and step changes are announced. Buttons have accessible names and large enough touch targets. In Windows high-contrast mode the highlight is drawn as an outline. On React Native, screen-reader focus moves to the tooltip, the app behind it is hidden from screen readers (in a `TourHost`, see [Modal screens](#modal-screens)), and the back button closes the tour. - **Costs nothing while idle.** Right after a step appears, the tour checks its target every frame until it has stayed put for a second (three at most), because animations can move it without any event (a React Native screen sliding in, for example). After that, while nothing moves, the tour does no work at all: no timers, no measuring, no re-rendering. Scrolling and resizing are followed once per frame, without re-rendering your app. `useTour()` only re-renders components when the tour's state changes. @@ -617,10 +628,13 @@ The demos use the library's source directly, so every change is live there: ```bash npm run demo:web # web demo: http://localhost:5173 — includes a /lab page with edge cases +npm run demo:showcase # the showcase: the live demo on GitHub Pages, deployed from main npm run demo:ios # React Native demo in Expo Go (run `npm install` in demos/react-native first) npm run demo:android ``` +The animated preview at the top of this README is recorded from the showcase: `npm run demo:record` writes `.github/assets/demo.gif` and its still poster (it needs ffmpeg and gifski: `brew install ffmpeg gifski`). Record it again when the showcase or the look of the tour changes. + All tests are in `test/` (see [ARCHITECTURE.md](./ARCHITECTURE.md#tests)). The React Native demo is tested with [Maestro](https://maestro.dev) flows: with the app open, run `maestro test test/maestro -e APP_ID=host.exp.Exponent`. To see the Android navigation bar setting in effect, build the app natively with `npx expo run:android`. ## License diff --git a/demos/react/package.json b/demos/react/package.json index a58445b..812ea7b 100644 --- a/demos/react/package.json +++ b/demos/react/package.json @@ -5,6 +5,9 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "showcase": "vite --mode showcase", + "build:showcase": "tsc -b && vite build --mode showcase", + "preview:showcase": "vite preview --mode showcase", "lint": "eslint .", "preview": "vite preview" }, diff --git a/demos/react/showcase/index.html b/demos/react/showcase/index.html new file mode 100644 index 0000000..0c08998 --- /dev/null +++ b/demos/react/showcase/index.html @@ -0,0 +1,27 @@ + + + + + + react-tour-kit · Live demo + + + + + + + + +
+ + + diff --git a/demos/react/src/showcase/App.tsx b/demos/react/src/showcase/App.tsx new file mode 100644 index 0000000..76e961c --- /dev/null +++ b/demos/react/src/showcase/App.tsx @@ -0,0 +1,127 @@ +import {type ReactNode, useEffect} from 'react'; +import {createHashRouter, NavLink, Outlet, RouterProvider, ScrollRestoration} from 'react-router-dom'; +import {TourProvider, type TourTheme, useTour} from '@robertlinde/react-tour-kit/react'; +import {Icon, type IconName} from './icons.tsx'; +import {DashboardPage, ProjectsPage, SettingsPage, TeamPage} from './pages.tsx'; +import {createShowcaseTour} from './tour.ts'; + +/** The tour in the app's own brand colour. */ +const theme: TourTheme = { + primaryColor: '#4f46e5', + overlayColor: 'rgba(15, 23, 42, 0.55)', +}; + +const navigation: ReadonlyArray<{readonly path: string; readonly label: string; readonly icon: IconName}> = [ + {path: '/', label: 'Dashboard', icon: 'dashboard'}, + {path: '/projects', label: 'Projects', icon: 'projects'}, + {path: '/team', label: 'Team', icon: 'team'}, + {path: '/settings', label: 'Settings', icon: 'settings'}, +]; + +function Layout(): ReactNode { + const {startTour} = useTour(); + + // Like a real onboarding: the tour starts by itself shortly after the app has loaded. + useEffect(() => { + const timer = setTimeout(() => { + startTour(tour, 'onboarding'); + }, 1000); + return () => { + clearTimeout(timer); + }; + }, [startTour]); + + const links = navigation.map((item) => ( + + + {item.label} + + )); + + return ( +
+ + +
+
+ + + + + + +
+ + + +
+ +
+
+ + +
+ ); +} + +const router = createHashRouter([ + { + element: , + children: [ + {path: '/', element: }, + {path: '/projects', element: }, + {path: '/team', element: }, + {path: '/settings', element: }, + ], + }, +]); + +const tour = createShowcaseTour((path) => { + if (router.state.location.pathname !== path) { + void router.navigate(path); + } +}); + +export function App(): ReactNode { + return ( + + + + ); +} diff --git a/demos/react/src/showcase/data.ts b/demos/react/src/showcase/data.ts new file mode 100644 index 0000000..ba00a39 --- /dev/null +++ b/demos/react/src/showcase/data.ts @@ -0,0 +1,85 @@ +import {useSyncExternalStore} from 'react'; + +/** Everything the showcase shows is made up; the team is "loaded" from a pretend server. */ + +export type Person = { + readonly name: string; + readonly initials: string; + readonly color: string; + readonly role: string; +}; + +export const people = { + maya: {name: 'Maya Chen', initials: 'MC', color: '#b45309', role: 'Product designer'}, + leo: {name: 'Leo Park', initials: 'LP', color: '#047857', role: 'Engineer'}, + ava: {name: 'Ava Rossi', initials: 'AR', color: '#be185d', role: 'Marketing lead'}, + sam: {name: 'Sam Okafor', initials: 'SO', color: '#0369a1', role: 'Engineering manager'}, + noah: {name: 'Noah Weber', initials: 'NW', color: '#6d28d9', role: 'Engineer'}, +} satisfies Record; + +export const stats = [ + {label: 'Active projects', value: '12', trend: '+2 this month'}, + {label: 'Tasks completed', value: '148', trend: '+18% vs last week'}, + {label: 'On-time delivery', value: '94%', trend: '+3 pts'}, +] as const; + +/** Tasks completed per day, Monday to Sunday. */ +export const weeklyTasks = [14, 22, 19, 29, 34, 17, 13] as const; + +export type Project = {readonly name: string; readonly progress: number; readonly due: string}; + +export const projects: readonly Project[] = [ + {name: 'Website redesign', progress: 72, due: 'Oct 30'}, + {name: 'Mobile app 2.0', progress: 45, due: 'Nov 14'}, + {name: 'Q4 launch campaign', progress: 90, due: 'Oct 22'}, + {name: 'Customer portal', progress: 18, due: 'Dec 5'}, +]; + +export const activity = [ + {person: people.maya, action: 'completed', subject: 'Homepage hero', project: 'Website redesign', time: '2m'}, + {person: people.leo, action: 'commented on', subject: 'Onboarding flow', project: 'Mobile app 2.0', time: '14m'}, + {person: people.ava, action: 'uploaded', subject: '3 files', project: 'Q4 launch campaign', time: '1h'}, + {person: people.sam, action: 'created', subject: 'Customer portal', project: 'Projects', time: '3h'}, +] as const; + +type TeamState = {readonly members: readonly Person[]; readonly isLoading: boolean}; + +/** How long the pretend server takes to answer. */ +const serverMs = 900; + +let team: TeamState = {members: [], isLoading: false}; +let pending: Promise | undefined; +const listeners = new Set<() => void>(); + +function update(next: TeamState): void { + team = next; + for (const listener of listeners) { + listener(); + } +} + +/** Loads the team again; resolves once it is there. Calls while a load is running share it. */ +export async function loadTeam(): Promise { + pending ??= (async () => { + update({...team, isLoading: true}); + await new Promise((resolve) => { + setTimeout(resolve, serverMs); + }); + update({members: Object.values(people), isLoading: false}); + pending = undefined; + })(); + return pending; +} + +export function useTeam(): TeamState { + return useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + () => team, + () => team, + ); +} diff --git a/demos/react/src/showcase/icons.tsx b/demos/react/src/showcase/icons.tsx new file mode 100644 index 0000000..72d94e5 --- /dev/null +++ b/demos/react/src/showcase/icons.tsx @@ -0,0 +1,77 @@ +import type {ReactNode} from 'react'; + +const paths = { + logo: , + dashboard: ( + <> + + + + + + ), + projects: ( + + ), + team: ( + <> + + + + + ), + settings: ( + <> + + + + + ), + search: ( + <> + + + + ), + bell: ( + <> + + + + ), + help: ( + <> + + + + + ), + plus: , + mail: ( + <> + + + + ), +} satisfies Record; + +export type IconName = keyof typeof paths; + +/** A small stroke icon; decorative, so hidden from screen readers. */ +export function Icon({name, size = 18}: {readonly name: IconName; readonly size?: number}): ReactNode { + return ( + + ); +} diff --git a/demos/react/src/showcase/main.tsx b/demos/react/src/showcase/main.tsx new file mode 100644 index 0000000..9d5f90b --- /dev/null +++ b/demos/react/src/showcase/main.tsx @@ -0,0 +1,13 @@ +import {StrictMode} from 'react'; +import {createRoot} from 'react-dom/client'; +import {App} from './App.tsx'; +import './showcase.css'; + +const rootElement = document.querySelector('#root'); +if (rootElement) { + createRoot(rootElement).render( + + + , + ); +} diff --git a/demos/react/src/showcase/pages.tsx b/demos/react/src/showcase/pages.tsx new file mode 100644 index 0000000..444969f --- /dev/null +++ b/demos/react/src/showcase/pages.tsx @@ -0,0 +1,205 @@ +import {type ReactNode, useEffect} from 'react'; +import {activity, loadTeam, type Person, projects, stats, useTeam, weeklyTasks} from './data.ts'; +import {Icon} from './icons.tsx'; + +function Avatar({person}: {readonly person: Person}): ReactNode { + return ( + + ); +} + +function PageHeader({ + title, + subtitle, + children, +}: { + readonly title: string; + readonly subtitle: string; + readonly children?: ReactNode; +}): ReactNode { + return ( +
+
+

{title}

+

{subtitle}

+
+ {children} +
+ ); +} + +function Progress({value, label}: {readonly value: number; readonly label: string}): ReactNode { + return ( + + + + ); +} + +const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +function TasksChart(): ReactNode { + const width = 420; + const height = 120; + const max = Math.max(...weeklyTasks) * 1.15; + const points = weeklyTasks.map((value, index) => { + const x = (index / (weeklyTasks.length - 1)) * width; + const y = height - (value / max) * height; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }); + + return ( +
+ + + + +
+ {days.map((day) => ( + {day} + ))} +
+
+ ); +} + +export function DashboardPage(): ReactNode { + return ( + <> + + +
+ {stats.map((stat) => ( +
+ {stat.label} + {stat.value} + {stat.trend} +
+ ))} +
+ +
+
+

Tasks completed

+ +
+
+

Projects

+
    + {projects.slice(0, 3).map((project) => ( +
  • + {project.name} + +
  • + ))} +
+
+
+ +
+

Recent activity

+
    + {activity.map((entry) => ( +
  • + + + {entry.person.name} {entry.action} {entry.subject} + · {entry.project} + + +
  • + ))} +
+
+ + ); +} + +export function TeamPage(): ReactNode { + const {members, isLoading} = useTeam(); + + useEffect(() => { + void loadTeam(); + }, []); + + return ( + <> + + + + +
+
    + {isLoading || members.length === 0 + ? [1, 2, 3, 4].map((row) => ( + + )) + : members.map((member) => ( +
  • + + + {member.name} + {member.role} + +
  • + ))} +
+
+ + ); +} + +export function ProjectsPage(): ReactNode { + return ( + <> + +
+ {projects.map((project) => ( +
+

{project.name}

+ + + {project.progress}% · due {project.due} + +
+ ))} +
+ + ); +} + +export function SettingsPage(): ReactNode { + return ( + <> + +
+ {['Email me about mentions', 'Weekly summary', 'Desktop notifications'].map((label, index) => ( + + ))} +
+ + ); +} diff --git a/demos/react/src/showcase/showcase.css b/demos/react/src/showcase/showcase.css new file mode 100644 index 0000000..a67b466 --- /dev/null +++ b/demos/react/src/showcase/showcase.css @@ -0,0 +1,527 @@ +/* Flat colours only (no shadows with blur, no gradients): the README GIF is recorded from this page. */ +:root { + --brand: #4f46e5; + --brand-strong: #4338ca; + --brand-soft: #eef2ff; + --bg: #f8fafc; + --surface: #ffffff; + --border: #e2e8f0; + --text: #0f172a; + --muted: #64748b; + --radius: 12px; + + color-scheme: light; + font-family: + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + sans-serif; + font-size: 14px; + line-height: 1.45; + color: var(--text); + background: var(--bg); + -webkit-font-smoothing: antialiased; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +h1, +h2, +p { + margin: 0; +} + +button, +input { + font: inherit; + color: inherit; +} + +a { + color: inherit; +} + +.muted { + color: var(--muted); +} + +/* ---------- Layout ---------- */ + +.shell { + display: grid; + grid-template-columns: 208px minmax(0, 1fr); + min-height: 100vh; +} + +.sidebar { + position: sticky; + top: 0; + display: flex; + flex-direction: column; + gap: 20px; + height: 100vh; + padding: 16px 12px; + background: var(--surface); + border-right: 1px solid var(--border); +} + +.sidebar nav { + display: flex; + flex-direction: column; + gap: 2px; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 8px; + font-size: 16px; + font-weight: 700; +} + +.brand-mark { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 8px; + color: #fff; + background: var(--brand); +} + +.brand-mark-compact { + display: none; + flex: none; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + color: var(--muted); + font-weight: 500; + text-decoration: none; +} + +.nav-item:hover { + color: var(--text); + background: var(--bg); +} + +.nav-item.active { + color: var(--brand-strong); + background: var(--brand-soft); +} + +.built-with { + margin-top: auto; + padding: 8px 10px; + color: var(--muted); + font-size: 12px; + text-decoration: none; +} + +.built-with:hover strong { + color: var(--brand); +} + +.main { + display: flex; + flex-direction: column; + min-width: 0; +} + +.topbar { + position: sticky; + top: 0; + z-index: 5; + display: flex; + align-items: center; + gap: 8px; + height: 60px; + padding: 0 20px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.tabs { + display: none; +} + +.content { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + max-width: 1040px; + padding: 24px; +} + +/* ---------- Controls ---------- */ + +.search { + display: flex; + flex: 0 1 300px; + align-items: center; + gap: 8px; + min-width: 0; + height: 36px; + margin-right: auto; + padding: 0 12px; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--muted); + background: var(--bg); +} + +.search:focus-within { + border-color: var(--brand); +} + +.search input { + flex: 1; + min-width: 0; + border: 0; + outline: 0; + background: none; +} + +.button, +.icon-button { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + gap: 6px; + height: 36px; + border: 0; + border-radius: 8px; + font-weight: 600; + cursor: pointer; +} + +.button { + padding: 0 12px; +} + +.icon-button { + width: 36px; + color: var(--muted); + background: none; +} + +.button-primary { + color: #fff; + background: var(--brand); +} + +.button-primary:hover { + background: var(--brand-strong); +} + +.button-ghost { + color: var(--muted); + background: none; +} + +.button-ghost:hover, +.icon-button:hover { + color: var(--text); + background: var(--bg); +} + +:is(.button, .icon-button, .nav-item):focus-visible { + outline: 2px solid var(--brand); + outline-offset: 2px; +} + +.avatar { + display: inline-grid; + flex: none; + place-items: center; + width: 32px; + height: 32px; + border-radius: 50%; + color: #fff; + background: var(--border); + font-size: 12px; + font-weight: 600; +} + +.avatar-me { + margin: 0 4px; + background: #0369a1; +} + +/* ---------- Content ---------- */ + +.page-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.page-header h1 { + font-size: 22px; + letter-spacing: -0.01em; +} + +.page-header p { + color: var(--muted); +} + +.card { + padding: 16px 18px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} + +.card h2 { + margin-bottom: 12px; + font-size: 14px; +} + +.stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; +} + +.stat { + display: flex; + flex-direction: column; + gap: 2px; +} + +.stat-label { + color: var(--muted); + font-size: 13px; +} + +.stat-value { + font-size: 24px; + letter-spacing: -0.02em; +} + +.stat-trend { + color: #047857; + font-size: 12px; + font-weight: 500; +} + +.split { + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + gap: 16px; +} + +.chart { + margin: 0; +} + +.chart svg { + display: block; + width: 100%; + height: 112px; +} + +.chart-area { + fill: var(--brand-soft); +} + +.chart-line { + fill: none; + stroke: var(--brand); + stroke-width: 2.5; + stroke-linejoin: round; +} + +.chart-days { + display: flex; + justify-content: space-between; + margin-top: 6px; + color: var(--muted); + font-size: 11px; +} + +.project-list { + display: flex; + flex-direction: column; + gap: 14px; + margin: 0; + padding: 0; + list-style: none; +} + +.project-list li { + display: flex; + flex-direction: column; + gap: 6px; +} + +.project-name { + font-weight: 500; +} + +.progress { + display: block; + height: 6px; + overflow: hidden; + border-radius: 3px; + background: var(--brand-soft); +} + +.progress span { + display: block; + height: 100%; + border-radius: 3px; + background: var(--brand); +} + +.activity, +.members { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; +} + +.activity li, +.members li { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; +} + +.activity li + li, +.members li + li { + border-top: 1px solid var(--border); +} + +.activity-text { + flex: 1; + min-width: 0; +} + +.members strong, +.member-role { + display: block; +} + +.member-role { + font-size: 13px; +} + +.skeleton-line { + width: 40%; + height: 12px; + border-radius: 6px; + background: var(--border); +} + +.project-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 16px; +} + +.project-card { + display: flex; + flex-direction: column; + gap: 10px; +} + +.project-card h2 { + margin: 0; +} + +.settings { + display: flex; + flex-direction: column; +} + +.setting { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 0; +} + +.setting + .setting { + border-top: 1px solid var(--border); +} + +.setting input { + width: 18px; + height: 18px; + accent-color: var(--brand); +} + +/* ---------- Phones ---------- */ + +@media (max-width: 760px) { + .shell { + grid-template-columns: minmax(0, 1fr); + } + + .sidebar, + .hide-compact, + .button-label { + display: none; + } + + .brand-mark-compact { + display: grid; + } + + .topbar { + gap: 6px; + padding: 0 12px; + } + + .search { + flex: 1 1 auto; + margin-right: 0; + } + + .topbar .button { + width: 36px; + padding: 0; + } + + .tabs { + display: flex; + gap: 4px; + overflow-x: auto; + padding: 8px 12px 0; + } + + .tabs .nav-item { + flex: none; + padding: 6px 10px; + } + + .content { + padding: 16px 12px 24px; + } + + .stats, + .split { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/demos/react/src/showcase/tour.ts b/demos/react/src/showcase/tour.ts new file mode 100644 index 0000000..26952ff --- /dev/null +++ b/demos/react/src/showcase/tour.ts @@ -0,0 +1,53 @@ +import type {TourStep} from '@robertlinde/react-tour-kit/react'; +import {loadTeam} from './data.ts'; + +/** + * The showcase's onboarding tour. Every step first opens the page it needs, so the tour also works + * when going back. The tour waits for each target to appear and scrolls it into view by itself. + */ +export function createShowcaseTour(open: (path: string) => void): TourStep[] { + const on = (path: string, step: TourStep): TourStep => ({ + ...step, + onBeforeStep() { + open(path); + }, + }); + + return [ + on('/', { + title: 'Welcome to Acme 👋', + content: 'Take a 20-second look around your new workspace.', + }), + on('/', { + target: '[data-tour="search"]', + title: 'Find anything', + content: 'Search projects, files and people from anywhere.', + }), + on('/', { + target: '[data-tour="new-project"]', + title: 'Start something new', + content: 'Spin up a project in seconds, templates included.', + }), + on('/', { + target: '[data-tour="activity"]', + title: 'Stay in the loop', + content: 'Every update from your team, in one feed.', + placement: 'top', + }), + { + target: '[data-tour="invite"]', + title: 'Bring your team', + content: 'Invite teammates. They get a tour like this one.', + // Loading takes a moment: meanwhile the tour dims the screen and shows a spinner. + async onBeforeStep() { + open('/team'); + await loadTeam(); + }, + }, + on('/', { + target: '[data-tour="help"]', + title: 'Help is always here', + content: 'Replay this tour any time from Help.', + }), + ]; +} diff --git a/demos/react/vite.config.ts b/demos/react/vite.config.ts index 98cc17f..819fa16 100644 --- a/demos/react/vite.config.ts +++ b/demos/react/vite.config.ts @@ -7,8 +7,17 @@ import {defineConfig} from 'vite'; const fromPackage = process.env.TOUR_KIT_FROM_PACKAGE === '1'; const repoRoot = fileURLToPath(new URL('../..', import.meta.url)); -export default defineConfig({ +// `--mode showcase` builds the showcase (the live demo on GitHub Pages) instead of the test-bench demo. +// It uses relative asset URLs and a hash router, so the same build works under any path. +export default defineConfig(({mode}) => ({ plugins: [react()], + ...(mode === 'showcase' + ? { + root: fileURLToPath(new URL('showcase', import.meta.url)), + base: './', + build: {outDir: fileURLToPath(new URL('dist-showcase', import.meta.url)), emptyOutDir: true}, + } + : {}), resolve: { alias: fromPackage ? [] @@ -24,4 +33,4 @@ export default defineConfig({ server: { fs: {allow: [repoRoot]}, }, -}); +})); diff --git a/package.json b/package.json index f6452b0..e44bd03 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", "build": "npm run clean && tsc -p tsconfig.build.json", "dev": "tsc -p tsconfig.build.json --watch", - "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.tooling.json", + "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.tooling.json && tsc -p scripts", "lint": "xo --ignore \"demos/**\"", "lint:fix": "xo --fix --ignore \"demos/**\"", "format": "prettier --write .", @@ -64,6 +64,8 @@ "check:package": "publint --strict && attw --pack . --profile esm-only", "smoke:node": "node test/smoke-node.mjs", "demo:web": "npm run dev --prefix demos/react", + "demo:showcase": "npm run showcase --prefix demos/react", + "demo:record": "node scripts/record-demo.ts", "demo:ios": "npm run ios --prefix demos/react-native", "demo:android": "npm run android --prefix demos/react-native", "prepare": "husky || true" diff --git a/playwright.config.ts b/playwright.config.ts index 420dddb..a168a48 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,7 +1,7 @@ import {defineConfig, devices} from '@playwright/test'; /** - * End-to-end tests (test/e2e) against the web demo (demos/react). + * End-to-end tests (test/e2e) against the web demo and the showcase (demos/react). * * Locally the demo runs the library's source. In CI, TOUR_KIT_FROM_PACKAGE=1 makes the demo use * the packed tarball instead, so the tests cover exactly what gets published. @@ -42,11 +42,21 @@ export default defineConfig({ use: {...devices['Desktop Chrome'], viewport: {width: 1280, height: 800}, reducedMotion: 'reduce'}, }, ], - webServer: { - command: 'npm run build && npm run preview -- --port 4173 --strictPort', - url: 'http://localhost:4173', - reuseExistingServer: !isCi, - timeout: 180_000, - cwd: './demos/react', - }, + webServer: [ + { + command: 'npm run build && npm run preview -- --port 4173 --strictPort', + url: 'http://localhost:4173', + reuseExistingServer: !isCi, + timeout: 180_000, + cwd: './demos/react', + }, + // The showcase (test/e2e/showcase.spec.ts). Type-checked by the build above: no second `tsc -b`. + { + command: 'npx vite build --mode showcase && npm run preview:showcase -- --port 4174 --strictPort', + url: 'http://localhost:4174', + reuseExistingServer: !isCi, + timeout: 180_000, + cwd: './demos/react', + }, + ], }); diff --git a/scripts/demo-harness.html b/scripts/demo-harness.html new file mode 100644 index 0000000..172de87 --- /dev/null +++ b/scripts/demo-harness.html @@ -0,0 +1,224 @@ + + + + + + react-tour-kit demo stage + + + +
+
+ + + + + + robertlinde.github.io/react-tour-kit + +
+ +
+ +
+ + + + + diff --git a/scripts/record-demo.ts b/scripts/record-demo.ts new file mode 100644 index 0000000..c37a5d5 --- /dev/null +++ b/scripts/record-demo.ts @@ -0,0 +1,388 @@ +/** + * Records the README's animated preview (.github/assets/demo.gif) and its still poster + * (.github/assets/demo-poster.png) from the showcase in demos/react. + * + * npm run demo:record + * + * Needs ffmpeg and gifski (`brew install ffmpeg gifski`) and the web demo's dependencies + * (`npm install --prefix demos/react`). Run it again whenever the showcase or the tour's look changes. + * + * How: the showcase plays in a browser window drawn by scripts/demo-harness.html, with a pointer that + * glides to Next and clicks. Chromium's screencast captures every painted frame with its timestamp; + * ffmpeg turns them into a constant 20 fps sequence; gifski encodes the GIF, as small as it takes to + * stay below GitHub's image proxy limit. + */ +import {Buffer} from 'node:buffer'; +import {spawn, spawnSync, type SpawnSyncOptions} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import {mkdir, mkdtemp, readdir, rm, stat, writeFile} from 'node:fs/promises'; +import {createServer} from 'node:net'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {setTimeout as sleep} from 'node:timers/promises'; +import {fileURLToPath, pathToFileURL} from 'node:url'; +import {type Browser, chromium, type FrameLocator} from '@playwright/test'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const demo = path.join(root, 'demos/react'); +const assets = path.join(root, '.github/assets'); +const harness = path.join(root, 'scripts/demo-harness.html'); + +/** The stage in CSS pixels, drawn at 1.5× (a 1440 px GIF), so text stays crisp on high-density screens. */ +const stageSize = {width: 960, height: 600}; +const scale = 1.5; +const fps = 20; +/** GitHub's image proxy has failed on GIFs of about 6 MB. */ +const maxBytes = 4.5 * 1024 * 1024; +/** How long each step is on screen, from settled until the click on Next. */ +const dwellMs = 2200; +const glideMs = 550; +/** How long the last frame stays; the GIF then loops to the first, which looks the same. */ +const outroMs = 400; +/** Where the pointer waits, in stage coordinates: over plain content, so nothing shows a hover state. */ +const rest = {x: 600, y: 470}; +/** The steps' titles (a dialog's name matches when it contains the title). */ +const titles = [ + 'Welcome to Acme', + 'Find anything', + 'Start something new', + 'Stay in the loop', + 'Bring your team', + 'Help is always here', +]; + +type Point = {readonly x: number; readonly y: number}; +type Rect = {readonly left: number; readonly top: number; readonly width: number; readonly height: number}; +type CapturedFrame = {readonly file: string; readonly time: number}; + +function run(command: string, args: string[], options: SpawnSyncOptions = {}): void { + const result = spawnSync(command, args, {stdio: 'inherit', ...options}); + if (result.status !== 0) { + throw new Error(`Failed: ${command} ${args.slice(0, 6).join(' ')}…`); + } +} + +function requireTools(): void { + const missing = ['ffmpeg', 'gifski'].filter((tool) => spawnSync(tool, ['-h'], {stdio: 'ignore'}).error); + if (missing.length > 0) { + throw new Error(`Missing ${missing.join(' and ')}. Install the recording tools with: brew install ffmpeg gifski`); + } + + if (!existsSync(path.join(demo, 'node_modules/.bin/vite'))) { + throw new Error('The web demo is not installed. Run: npm install --prefix demos/react'); + } +} + +async function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.on('error', reject); + server.listen(0, () => { + const address = server.address(); + server.close(() => { + if (typeof address === 'object' && address) { + resolve(address.port); + } else { + reject(new Error('No free port')); + } + }); + }); + }); +} + +async function waitForServer(url: string): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + try { + // eslint-disable-next-line no-await-in-loop + const response = await fetch(url); + if (response.ok) { + return; + } + } catch { + // Not listening yet. + } + + // eslint-disable-next-line no-await-in-loop + await sleep(200); + } + + throw new Error(`The showcase preview did not start at ${url}`); +} + +function rectOf(element: Element): Rect { + const {left, top, width, height} = element.getBoundingClientRect(); + return {left, top, width, height}; +} + +/** + * Opens the stage. Positions are in stage CSS pixels (the pointer's, and the showcase's own, offset by the + * iframe's origin); the mouse moves in page pixels, `scale` times as many. Playwright's `boundingBox()` + * and `click()` cannot be used inside the showcase: they do not account for the scale inside the iframe. + */ +async function openStage(browser: Browser, url: string, pointerAt?: Point) { + const page = await browser.newPage({viewport: {width: stageSize.width * scale, height: stageSize.height * scale}}); + const parameters = new URLSearchParams({scale: String(scale), src: url}); + if (pointerAt) { + parameters.set('x', String(pointerAt.x)); + parameters.set('y', String(pointerAt.y)); + } + + await page.goto(`${pathToFileURL(harness).href}?${parameters.toString()}`); + const app = page.frameLocator('iframe'); + await app.locator('[data-tour="search"]').waitFor(); + const frame = await page.locator('iframe').evaluate(rectOf); + const origin = {x: frame.left / scale, y: frame.top / scale}; + + // The pointer, drawn by the stage page (`stage` there). + const pointer = { + async move({x, y}: Point, duration: number) { + await page.evaluate(`stage.move(${x}, ${y}, ${duration})`); + }, + async press() { + await page.evaluate('stage.press()'); + }, + }; + const mouse = { + async move({x, y}: Point) { + await page.mouse.move(x * scale, y * scale); + }, + async click({x, y}: Point) { + await page.mouse.click(x * scale, y * scale); + }, + }; + + /** Where the pointer clicks `name`: a little right of and below its centre, like a hand would. */ + async function buttonPoint(name: string): Promise { + const box = await app.getByRole('button', {name, exact: true}).evaluate(rectOf); + return { + x: Math.round(origin.x + box.left + box.width * 0.55), + y: Math.round(origin.y + box.top + box.height * 0.6), + }; + } + + return {page, app, pointer, mouse, buttonPoint}; +} + +/** Waits until the step titled `title` is on screen and its entrance animation is over. */ +async function settled(app: FrameLocator, title: string): Promise { + await app.getByRole('dialog', {name: title}).waitFor({timeout: 10_000}); + await app.locator('[data-rtk-tooltip]:not([data-hidden]):not([data-entering])').waitFor({timeout: 10_000}); +} + +/** + * Plays the tour with the pointer while Chromium's screencast captures every painted frame. Returns the + * frames, and when continuous animations started (each click, and the pointer's way back at the end). + */ +async function record(browser: Browser, url: string, rawDirectory: string) { + const {page, app, pointer, mouse, buttonPoint} = await openStage(browser, url, rest); + await mouse.move(rest); + // The screencast starts with the last painted frame: make sure that one is complete. + await page.evaluate('new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))'); + + const frames: CapturedFrame[] = []; + const writes: Array> = []; + const cdp = await page.context().newCDPSession(page); + cdp.on('Page.screencastFrame', (event) => { + const file = path.join(rawDirectory, `${String(frames.length).padStart(5, '0')}.png`); + frames.push({file, time: (event.metadata.timestamp ?? Date.now() / 1000) * 1000}); + writes.push(writeFile(file, Buffer.from(event.data, 'base64'))); + void cdp.send('Page.screencastFrameAck', {sessionId: event.sessionId}); + }); + await cdp.send('Page.startScreencast', {format: 'png'}); + + // The tour starts by itself, a moment after the showcase has loaded. + const animations: number[] = []; + for (const [index, title] of titles.entries()) { + // eslint-disable-next-line no-await-in-loop + await settled(app, title); + const settledAt = Date.now(); + // eslint-disable-next-line no-await-in-loop + const target = await buttonPoint(index === titles.length - 1 ? 'Finish' : 'Next'); + // eslint-disable-next-line no-await-in-loop + await sleep(Math.max(0, settledAt + dwellMs - glideMs - Date.now())); + // eslint-disable-next-line no-await-in-loop + await pointer.move(target, glideMs); + // eslint-disable-next-line no-await-in-loop + await mouse.move(target); + // eslint-disable-next-line no-await-in-loop + await sleep(100); + animations.push(Date.now()); + // eslint-disable-next-line no-await-in-loop + await pointer.press(); + // eslint-disable-next-line no-await-in-loop + await mouse.click(target); + } + + await app.locator('[data-rtk-root]').waitFor({state: 'detached', timeout: 10_000}); + await sleep(250); + animations.push(Date.now()); + await pointer.move(rest, glideMs); + await mouse.move(rest); + await sleep(300); + await cdp.send('Page.stopScreencast'); + await Promise.all(writes); + await page.close(); + return {frames, animations}; +} + +/** A capture that missed frames; recording again usually fixes it. */ +class CaptureError extends Error { + override name = 'CaptureError'; +} + +/** Every animation must be captured smoothly: no gap of more than 3 frames (at 60 Hz) while it runs. */ +function checkPacing(frames: readonly CapturedFrame[], animations: readonly number[]): void { + for (const start of animations) { + const times = frames.map((frame) => frame.time).filter((time) => time >= start && time <= start + 500); + const worst = Math.max(0, ...times.slice(1).map((time, index) => time - (times[index] ?? time))); + if (worst > 50) { + throw new CaptureError(`the capture dropped frames (a ${Math.round(worst)} ms gap)`); + } + } +} + +/** Resamples the captured frames, each shown until the next one was painted, to a constant frame rate. */ +async function resample(frames: readonly CapturedFrame[], directory: string): Promise { + const last = frames.at(-1); + if (!last) { + throw new Error('Nothing was captured'); + } + + const lines = frames.flatMap((frame, index) => { + const next = frames[index + 1]; + const seconds = next ? (next.time - frame.time) / 1000 : outroMs / 1000; + return [`file '${frame.file}'`, `duration ${seconds.toFixed(4)}`]; + }); + // The concat demuxer ignores the duration of the last entry unless the file is listed once more. + lines.push(`file '${last.file}'`); + const list = path.join(directory, 'frames.txt'); + await writeFile(list, `${lines.join('\n')}\n`); + + const output = path.join(directory, 'gif'); + await mkdir(output); + const pattern = path.join(output, '%05d.png'); + run('ffmpeg', ['-v', 'error', '-f', 'concat', '-safe', '0', '-i', list, '-vf', `fps=${fps}`, pattern]); + // Numbered by ffmpeg, from 1. + const {length} = await readdir(output); + return Array.from({length}, (_, index) => path.join(output, `${String(index + 1).padStart(5, '0')}.png`)); +} + +/** The GIF loops: its last frame must look like its first. */ +function checkLoop(pngs: readonly string[]): void { + const [first, last] = [pngs[0], pngs.at(-1)]; + if (!first || !last) { + throw new Error('No frames to encode'); + } + + const result = spawnSync('ffmpeg', ['-i', first, '-i', last, '-lavfi', 'psnr', '-f', 'null', '-'], { + encoding: 'utf8', + }); + const psnr = result.stderr.split('average:')[1]?.split(' ')[0] ?? 'unknown'; + if (psnr !== 'inf' && !(Number(psnr) >= 45)) { + throw new CaptureError(`the last frame differs from the first (PSNR ${psnr} dB), so the loop would jump`); + } +} + +/** Encodes the GIF at the best quality that fits the size budget. */ +async function encode(pngs: readonly string[], output: string) { + // Below quality 100, gifski keeps stale pixels where frames barely differ: faint ghosts of earlier + // tooltips under the dimmed overlay. So the width goes down first. + for (const quality of [100, 90]) { + for (const width of [stageSize.width * scale, 1200, stageSize.width]) { + const settings = ['--quiet', '--fps', String(fps), '--quality', String(quality), '--width', String(width)]; + run('gifski', [...settings, '--output', output, ...pngs], {stdio: 'ignore'}); + // eslint-disable-next-line no-await-in-loop + const {size: bytes} = await stat(output); + if (bytes <= maxBytes) { + return {width, quality, bytes}; + } + } + } + + throw new Error('The GIF does not fit 4.5 MB even at the lowest settings: shorten the tour or the dwell time.'); +} + +/** A sharp still of the edge step, for visitors who prefer reduced motion and for link previews. */ +async function poster(browser: Browser, url: string, output: string): Promise { + const {page, app, mouse, buttonPoint} = await openStage(browser, url); + for (const [index, title] of titles.slice(0, 3).entries()) { + if (index > 0) { + // eslint-disable-next-line no-await-in-loop + await mouse.click(await buttonPoint('Next')); + } + + // eslint-disable-next-line no-await-in-loop + await settled(app, title); + } + + // No hover state on the button that was clicked last. + await mouse.move(rest); + await sleep(400); + await page.screenshot({path: output}); + await page.close(); +} + +/** Records until a capture passes the checks (a busy machine can make the screencast miss frames). */ +async function recordFrames(browser: Browser, url: string, work: string): Promise { + const attempts = 3; + for (let attempt = 1; ; attempt++) { + const directory = path.join(work, `attempt-${attempt}`); + const raw = path.join(directory, 'raw'); + // eslint-disable-next-line no-await-in-loop + await mkdir(raw, {recursive: true}); + console.log('Recording the showcase tour…'); + try { + // eslint-disable-next-line no-await-in-loop + const {frames, animations} = await record(browser, url, raw); + checkPacing(frames, animations); + // eslint-disable-next-line no-await-in-loop + const pngs = await resample(frames, directory); + checkLoop(pngs); + return pngs; + } catch (error) { + if (!(error instanceof CaptureError) || attempt === attempts) { + throw error; + } + + console.log(`The recording is not usable: ${error.message}. Recording again…`); + } + } +} + +async function main(): Promise { + requireTools(); + run('npm', ['run', 'build:showcase'], {cwd: demo}); + + const port = await freePort(); + const url = `http://localhost:${port}/`; + const preview = spawn( + path.join(demo, 'node_modules/.bin/vite'), + ['preview', '--mode', 'showcase', '--port', String(port), '--strictPort'], + {cwd: demo, stdio: 'ignore'}, + ); + const work = await mkdtemp(path.join(tmpdir(), 'react-tour-kit-demo-')); + const browser = await chromium.launch(); + try { + await waitForServer(url); + await mkdir(assets, {recursive: true}); + + const pngs = await recordFrames(browser, url, work); + + console.log(`Encoding ${pngs.length} frames (${(pngs.length / fps).toFixed(1)} s)…`); + const gif = path.join(assets, 'demo.gif'); + const {width, quality, bytes} = await encode(pngs, gif); + console.log(`${path.relative(root, gif)}: ${(bytes / 1024 / 1024).toFixed(2)} MB, ${width} px, quality ${quality}`); + + const still = path.join(assets, 'demo-poster.png'); + await poster(browser, url, still); + const {size} = await stat(still); + console.log(`${path.relative(root, still)}: ${(size / 1024).toFixed(0)} kB`); + } finally { + await browser.close(); + preview.kill(); + await rm(work, {recursive: true, force: true}); + } +} + +await main(); diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..ef1a3e3 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../tsconfig.json", + "compilerOptions": { + "target": "ES2024", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": ["node"] + }, + "include": ["."] +} diff --git a/src/shared/engine/tour-engine.ts b/src/shared/engine/tour-engine.ts index 42489e4..981e529 100644 --- a/src/shared/engine/tour-engine.ts +++ b/src/shared/engine/tour-engine.ts @@ -96,6 +96,11 @@ export type TourViewState = { readonly tourKey: number; readonly steps: readonly TourStep[]; readonly shown: ShownStep | undefined; + /** + * While the next step is prepared: the shown step's target has gone (the app moved on, for example + * `onBeforeStep` navigated), so the shown step must not stay on screen. + */ + readonly isShownTargetGone: boolean; readonly measure: MeasureRequest | undefined; }; @@ -140,6 +145,8 @@ type State = { readonly currentStep: number; readonly pendingStepIndex: number | undefined; readonly shown: ShownStep | undefined; + /** See {@link TourViewState.isShownTargetGone}. */ + readonly isShownTargetGone: boolean; readonly measure: MeasureRequest | undefined; /** Where the target being revealed currently is (only meaningful while revealing). */ readonly revealHighlight: Box | undefined; @@ -228,6 +235,7 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine currentStep: 0, pendingStepIndex: undefined, shown: undefined, + isShownTargetGone: false, measure: undefined, revealHighlight: undefined, }; @@ -239,6 +247,7 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine tourKey: 0, steps: emptySteps, shown: undefined, + isShownTargetGone: false, measure: undefined, }; @@ -248,6 +257,8 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine let transition: AbortController | undefined; /** Observation of the step on screen. */ let stepScope: AbortController | undefined; + /** While the next step is prepared: watching whether the step on screen's target goes away. */ + let goneScope: AbortController | undefined; let shownDetails: ShownDetails | undefined; let measureSequence = 0; /** A scroll that brings the shown target back into view is running. */ @@ -290,6 +301,7 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine tourKey: state.session?.key ?? previous.tourKey, steps: state.session?.steps ?? emptySteps, shown: state.shown, + isShownTargetGone: state.isShownTargetGone, measure: state.measure, }; const isUnchanged = @@ -299,6 +311,7 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine next.tourKey === previous.tourKey && next.steps === previous.steps && next.shown === previous.shown && + next.isShownTargetGone === previous.isShownTargetGone && next.measure === previous.measure; return isUnchanged ? previous : next; } @@ -573,9 +586,43 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine keepInViewScope = undefined; const controller = new AbortController(); transition = controller; + watchShownTarget().catch(reportError); return controller; } + /** + * While the next step is prepared, the step on screen stays (a quick step change shows nothing in + * between) and follows its target. Once that target is gone, though, the app has moved on (for + * example `onBeforeStep` navigated), and the step must not stay over what is there now. + */ + async function watchShownTarget(): Promise { + goneScope?.abort(); + const {shown} = state; + const target = shown?.step.target; + if (!shown || target === undefined || state.isShownTargetGone) { + return; + } + + const scope = new AbortController(); + goneScope = scope; + let last = shownDetails?.measurement; + while (!scope.signal.aborted) { + // eslint-disable-next-line no-await-in-loop -- one change at a time + const current = await options.adapter.waitForMove(target, last, scope.signal); + if (scope.signal.aborted || state.shown !== shown) { + return; + } + + if (!current) { + setState({isShownTargetGone: true}); + return; + } + + last = current; + void remeasure(scope.signal); + } + } + /** * Prepares the steps from `requested` on, in the direction of travel, until one can be shown. * Steps whose target cannot be found or brought into view are skipped. @@ -744,6 +791,8 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine transition = undefined; } + goneScope?.abort(); + goneScope = undefined; shownDetails = {measurement, size}; lastTooltipSize = size; const highlight = measurement ? highlightOf(measurement) : undefined; @@ -752,6 +801,7 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine currentStep: index, pendingStepIndex: undefined, measure: undefined, + isShownTargetGone: false, shown: { transitionId: id, stepIndex: index, @@ -952,6 +1002,8 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine transition = undefined; stepScope?.abort(); stepScope = undefined; + goneScope?.abort(); + goneScope = undefined; shownDetails = undefined; for (const resolve of sizeWaiters.values()) { resolve(lastTooltipSize ?? fallbackTooltipSize); @@ -966,6 +1018,7 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine currentStep: 0, pendingStepIndex: undefined, shown: undefined, + isShownTargetGone: false, measure: undefined, }); safeInvoke(() => session.onTourEnd?.(session.tourId, info)); @@ -1015,6 +1068,7 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine currentStep: 0, pendingStepIndex: 0, shown: undefined, + isShownTargetGone: false, measure: undefined, }); void prepare(0, 1); @@ -1083,6 +1137,8 @@ export function createTourEngine(initialOptions: TourEngineOptions): TourEngine transition = undefined; stepScope?.abort(); stepScope = undefined; + goneScope?.abort(); + goneScope = undefined; } function resume(): void { diff --git a/src/shared/renderer/tour-overlay.tsx b/src/shared/renderer/tour-overlay.tsx index 0f9daf2..dc4079d 100644 --- a/src/shared/renderer/tour-overlay.tsx +++ b/src/shared/renderer/tour-overlay.tsx @@ -94,9 +94,7 @@ function OverlayContent({ const totalSteps = view.steps.length; // The first step can take a while (navigation in onBeforeStep): dim the screen after a moment. const isWaitingDimmed = useDelayedFlag(!shown && !isClosing, motion.waitingDelayMs); - // Preparing a step takes a while: the highlight closes and a spinner shows that the tour is waiting. - const isPreparing = phase === 'preparing' && !isClosing; - const isWaiting = useDelayedFlag(isPreparing, motion.waitingDelayMs); + const {isPreparing, isWaiting, isShownGone} = usePreparation(view, isClosing); // Once on screen, the overlay stays until the tour ends (also when another tour takes over). const [hasBeenVisible, setHasBeenVisible] = useState(false); const isShowingSomething = shown !== undefined || isWaitingDimmed; @@ -110,7 +108,7 @@ function OverlayContent({ useEffect(() => { visibleSince.current = isVisible ? Date.now() : undefined; }, [isVisible]); - const spotlight = useSpotlight(view, isVisible, isReducedMotion, isWaiting); + const spotlight = useSpotlight(view, isVisible, isReducedMotion, isWaiting || isShownGone); const announcement = useAnnouncement(view, i18n, isClosing, isWaiting); // Fade out, then unmount. @@ -201,7 +199,7 @@ function OverlayContent({ shown={shown} totalSteps={totalSteps} isBusy={isPreparing} - isHidden={phase === 'revealing' || isWaiting} + isHidden={phase === 'revealing' || isWaiting || isShownGone} isDelayed={spotlight.isMorphing} isReducedMotion={isReducedMotion} titleId={titleId} @@ -336,6 +334,21 @@ function MeasuredTooltip({engine, renderer, Tooltip, request, totalSteps}: Measu ); } +/** + * While the next step is prepared. Preparing it takes a while (`isWaiting`): the highlight closes and + * a spinner shows that the tour is waiting. The app moved on before the next step is ready (the step + * on screen's target is gone, `isShownGone`): the step goes at once rather than stay over what is + * there now, and the spinner still waits for its delay. + */ +function usePreparation( + view: TourViewState, + isClosing: boolean, +): {readonly isPreparing: boolean; readonly isWaiting: boolean; readonly isShownGone: boolean} { + const isPreparing = view.phase === 'preparing' && !isClosing; + const isWaiting = useDelayedFlag(isPreparing, motion.waitingDelayMs); + return {isPreparing, isWaiting, isShownGone: isPreparing && view.isShownTargetGone}; +} + /** True once `isWaiting` has been true for `delayMs`, as long as it stays true. */ function useDelayedFlag(isWaiting: boolean, delayMs: number): boolean { const [isDue, setIsDue] = useState(false); diff --git a/test/e2e/in-sight.ts b/test/e2e/in-sight.ts index 02ac8f9..1b7103b 100644 --- a/test/e2e/in-sight.ts +++ b/test/e2e/in-sight.ts @@ -15,132 +15,135 @@ export type SightReport = { /** * Checks the "never out of sight" guarantee in the page, once per animation frame for `durationMs`. - * Returns the reports of every frame. + * `target` selects the step's target (the lab's by default). Returns the reports of every frame. */ -export async function sampleSight(page: Page, durationMs: number): Promise { - return page.evaluate(async (duration) => { - const padding = 12; - const epsilon = 0.75; - const isTourNode = (node: Element): boolean => node.closest('[data-rtk-root]') !== null; - - type View = {left: number; top: number; right: number; bottom: number}; - - function visibleViewport(): View { - const visual = globalThis.visualViewport; - const width = Math.min(visual?.width ?? Infinity, document.documentElement.clientWidth); - const height = Math.min(visual?.height ?? Infinity, document.documentElement.clientHeight); - const left = visual?.offsetLeft ?? 0; - const top = visual?.offsetTop ?? 0; - return {left, top, right: left + width, bottom: top + height}; - } - - const edges = (r: DOMRect): number[] => [r.left, r.top, r.right, r.bottom].map((value) => Math.round(value)); - - // Covered = a fixed or sticky element (header, floating button, banner) overlapping the target. - // Computed geometrically: hit-testing cannot see the page while the tour's modal dialog is open. - function isUnobscured(target: Element, t: DOMRect, view: View): boolean { - const area = view.right * view.bottom; - return [...document.body.querySelectorAll('*')] - .filter((element) => !isTourNode(element) && !element.contains(target) && !target.contains(element)) - .filter((element) => ['fixed', 'sticky'].includes(getComputedStyle(element).position)) - .map((element) => element.getBoundingClientRect()) - .filter((r) => r.width > 0 && r.height > 0 && r.width * r.height <= area / 2) - .every( - (r) => - Math.min(r.right, t.right) - Math.max(r.left, t.left) <= 1 || - Math.min(r.bottom, t.bottom) - Math.max(r.top, t.top) <= 1, - ); - } +export async function sampleSight(page: Page, durationMs: number, target = '#lab-target'): Promise { + return page.evaluate( + async ({duration, targetSelector}) => { + const padding = 12; + const epsilon = 0.75; + const isTourNode = (node: Element): boolean => node.closest('[data-rtk-root]') !== null; + + type View = {left: number; top: number; right: number; bottom: number}; + + function visibleViewport(): View { + const visual = globalThis.visualViewport; + const width = Math.min(visual?.width ?? Infinity, document.documentElement.clientWidth); + const height = Math.min(visual?.height ?? Infinity, document.documentElement.clientHeight); + const left = visual?.offsetLeft ?? 0; + const top = visual?.offsetTop ?? 0; + return {left, top, right: left + width, bottom: top + height}; + } - function inspectTarget(target: Element, view: View, box: DOMRect) { - const t = target.getBoundingClientRect(); - const visibleWidth = Math.min(t.right, view.right) - Math.max(t.left, view.left); - const visibleHeight = Math.min(t.bottom, view.bottom) - Math.max(t.top, view.top); - const fits = t.width <= view.right - view.left && t.height <= view.bottom - view.top; - const spotlight = document.querySelector('[data-rtk-spotlight]')?.getBoundingClientRect(); - return { - targetVisible: visibleWidth > 0 && visibleHeight > 0, - targetFullyVisibleIfFits: - !fits || - (t.left >= view.left - epsilon && - t.top >= view.top - epsilon && - t.right <= view.right + epsilon && - t.bottom <= view.bottom + epsilon), - targetUnobscured: isUnobscured(target, t, view), - spotlightMatchesTarget: - !spotlight || - !fits || - (Math.abs(spotlight.left - (t.left - 8)) < 1.5 && - Math.abs(spotlight.top - (t.top - 8)) < 1.5 && - Math.abs(spotlight.width - (t.width + 16)) < 1.5), - overlapsTarget: box.left < t.right && box.right > t.left && box.top < t.bottom && box.bottom > t.top, - }; - } + const edges = (r: DOMRect): number[] => [r.left, r.top, r.right, r.bottom].map((value) => Math.round(value)); + + // Covered = a fixed or sticky element (header, floating button, banner) overlapping the target. + // Computed geometrically: hit-testing cannot see the page while the tour's modal dialog is open. + function isUnobscured(target: Element, t: DOMRect, view: View): boolean { + const area = view.right * view.bottom; + return [...document.body.querySelectorAll('*')] + .filter((element) => !isTourNode(element) && !element.contains(target) && !target.contains(element)) + .filter((element) => ['fixed', 'sticky'].includes(getComputedStyle(element).position)) + .map((element) => element.getBoundingClientRect()) + .filter((r) => r.width > 0 && r.height > 0 && r.width * r.height <= area / 2) + .every( + (r) => + Math.min(r.right, t.right) - Math.max(r.left, t.left) <= 1 || + Math.min(r.bottom, t.bottom) - Math.max(r.top, t.top) <= 1, + ); + } - const noTarget = { - targetVisible: true, - targetFullyVisibleIfFits: true, - targetUnobscured: true, - spotlightMatchesTarget: true, - overlapsTarget: false, - }; - - function report(): SightReport | undefined { - const tooltip = document.querySelector('[data-rtk-tooltip]'); - const target = document.querySelector('#lab-target'); - if (!tooltip || tooltip.dataset.hidden !== undefined) { - return undefined; + function inspectTarget(target: Element, view: View, box: DOMRect) { + const t = target.getBoundingClientRect(); + const visibleWidth = Math.min(t.right, view.right) - Math.max(t.left, view.left); + const visibleHeight = Math.min(t.bottom, view.bottom) - Math.max(t.top, view.top); + const fits = t.width <= view.right - view.left && t.height <= view.bottom - view.top; + const spotlight = document.querySelector('[data-rtk-spotlight]')?.getBoundingClientRect(); + return { + targetVisible: visibleWidth > 0 && visibleHeight > 0, + targetFullyVisibleIfFits: + !fits || + (t.left >= view.left - epsilon && + t.top >= view.top - epsilon && + t.right <= view.right + epsilon && + t.bottom <= view.bottom + epsilon), + targetUnobscured: isUnobscured(target, t, view), + spotlightMatchesTarget: + !spotlight || + !fits || + (Math.abs(spotlight.left - (t.left - 8)) < 1.5 && + Math.abs(spotlight.top - (t.top - 8)) < 1.5 && + Math.abs(spotlight.width - (t.width + 16)) < 1.5), + overlapsTarget: box.left < t.right && box.right > t.left && box.top < t.bottom && box.bottom > t.top, + }; } - const view = visibleViewport(); - const box = tooltip.getBoundingClientRect(); - const [topmost] = document.elementsFromPoint(box.left + box.width / 2, box.top + Math.min(box.height / 2, 20)); - return { - tooltipInViewport: - box.left >= view.left - epsilon && - box.top >= view.top - epsilon && - box.right <= view.right + epsilon && - box.bottom <= view.bottom + epsilon, - tooltipInSafeArea: - box.left >= view.left + padding - epsilon && - box.top >= view.top + padding - epsilon && - box.right <= view.right - padding + epsilon && - box.bottom <= view.bottom - padding + epsilon, - entering: tooltip.dataset.entering !== undefined, - tooltipUnobscured: topmost ? tooltip.contains(topmost) : false, - ...(target ? inspectTarget(target, view, box) : noTarget), - detail: JSON.stringify({ - view, - tooltip: edges(box), - target: target ? edges(target.getBoundingClientRect()) : null, - }), + const noTarget = { + targetVisible: true, + targetFullyVisibleIfFits: true, + targetUnobscured: true, + spotlightMatchesTarget: true, + overlapsTarget: false, }; - } - // Sample what was actually painted: a task queued from a frame callback runs after that frame's - // rendering, whereas reading inside the callback could run before other frame callbacks update the page. - const reports: SightReport[] = []; - const start = performance.now(); - await new Promise((resolve) => { - const tick = (): void => { - setTimeout(() => { - const current = report(); - if (current) { - reports.push(current); - } - - if (performance.now() - start < duration) { - requestAnimationFrame(tick); - } else { - resolve(); - } - }, 0); - }; + function report(): SightReport | undefined { + const tooltip = document.querySelector('[data-rtk-tooltip]'); + const target = document.querySelector(targetSelector); + if (!tooltip || tooltip.dataset.hidden !== undefined) { + return undefined; + } + + const view = visibleViewport(); + const box = tooltip.getBoundingClientRect(); + const [topmost] = document.elementsFromPoint(box.left + box.width / 2, box.top + Math.min(box.height / 2, 20)); + return { + tooltipInViewport: + box.left >= view.left - epsilon && + box.top >= view.top - epsilon && + box.right <= view.right + epsilon && + box.bottom <= view.bottom + epsilon, + tooltipInSafeArea: + box.left >= view.left + padding - epsilon && + box.top >= view.top + padding - epsilon && + box.right <= view.right - padding + epsilon && + box.bottom <= view.bottom - padding + epsilon, + entering: tooltip.dataset.entering !== undefined, + tooltipUnobscured: topmost ? tooltip.contains(topmost) : false, + ...(target ? inspectTarget(target, view, box) : noTarget), + detail: JSON.stringify({ + view, + tooltip: edges(box), + target: target ? edges(target.getBoundingClientRect()) : null, + }), + }; + } - requestAnimationFrame(tick); - }); - return reports; - }, durationMs); + // Sample what was actually painted: a task queued from a frame callback runs after that frame's + // rendering, whereas reading inside the callback could run before other frame callbacks update the page. + const reports: SightReport[] = []; + const start = performance.now(); + await new Promise((resolve) => { + const tick = (): void => { + setTimeout(() => { + const current = report(); + if (current) { + reports.push(current); + } + + if (performance.now() - start < duration) { + requestAnimationFrame(tick); + } else { + resolve(); + } + }, 0); + }; + + requestAnimationFrame(tick); + }); + return reports; + }, + {duration: durationMs, targetSelector: target}, + ); } /** Asserts the guarantee on every sampled frame. */ diff --git a/test/e2e/showcase.spec.ts b/test/e2e/showcase.spec.ts new file mode 100644 index 0000000..bc343e9 --- /dev/null +++ b/test/e2e/showcase.spec.ts @@ -0,0 +1,86 @@ +import {AxeBuilder} from '@axe-core/playwright'; +import {expect, type Page, test, type TestInfo} from '@playwright/test'; +import {expectInSight, sampleSight} from './in-sight.js'; + +/** + * The showcase: the live demo on GitHub Pages and the README's animated preview. Its onboarding + * tour starts by itself, crosses pages, waits for data, and must stay in sight on every device. + */ + +test.use({baseURL: 'http://localhost:4174'}); + +/** The showcase runs on one desktop and two phone projects. */ +function skipOtherProjects(testInfo: TestInfo): void { + test.skip(!['chromium-desktop', 'chromium-phone', 'webkit-phone'].includes(testInfo.project.name), 'showcase'); +} + +const tooltip = (page: Page) => page.locator('[data-rtk-tooltip]:not([data-hidden])'); + +async function expectStep(page: Page, title: string | RegExp, target?: string): Promise { + await expect(page.getByRole('dialog', {name: title})).toBeVisible(); + await expect(tooltip(page)).not.toHaveAttribute('data-entering'); + if (target) { + expectInSight(await sampleSight(page, 400, target)); + } +} + +async function next(page: Page): Promise { + await page.getByRole('button', {name: 'Next'}).click(); +} + +test('the onboarding tour starts by itself and runs across pages to the end', async ({page}, testInfo) => { + skipOtherProjects(testInfo); + await page.goto('/'); + await expectStep(page, /^Welcome to Acme/u); + + await next(page); + await expectStep(page, 'Find anything', '[data-tour="search"]'); + + await next(page); + await expectStep(page, 'Start something new', '[data-tour="new-project"]'); + + // Below the fold on smaller screens: scrolled into view. + await next(page); + await expectStep(page, 'Stay in the loop', '[data-tour="activity"]'); + + // Another page whose data takes a moment: the tour shows a spinner meanwhile. + await next(page); + await expect(page.getByRole('progressbar', {name: 'Loading…'})).toBeVisible(); + await expectStep(page, 'Bring your team', '[data-tour="invite"]'); + await expect(page).toHaveURL(/#\/team$/u); + + await next(page); + await expectStep(page, 'Help is always here', '[data-tour="help"]'); + await expect(page).toHaveURL(/#\/$/u); + + await page.getByRole('button', {name: 'Finish'}).click(); + await expect(page.locator('[data-rtk-root]')).toHaveCount(0); + // Where it started, so the README's preview loops seamlessly. + expect(await page.evaluate(() => window.scrollY)).toBe(0); +}); + +test('Help replays the tour', async ({page}, testInfo) => { + skipOtherProjects(testInfo); + await page.goto('/'); + await expectStep(page, /^Welcome to Acme/u); + await page.getByRole('button', {name: 'Close tour'}).click(); + await expect(page.locator('[data-rtk-root]')).toHaveCount(0); + + await page.getByRole('button', {name: 'Help'}).click(); + await expectStep(page, /^Welcome to Acme/u); +}); + +test('the showcase and its tour are accessible', async ({page}, testInfo) => { + skipOtherProjects(testInfo); + await page.goto('/'); + await expectStep(page, /^Welcome to Acme/u); + await next(page); + await expectStep(page, 'Find anything'); + const tour = await new AxeBuilder({page}).include('[data-rtk-root]').analyze(); + expect(tour.violations).toEqual([]); + + await page.getByRole('button', {name: 'Close tour'}).click(); + await expect(page.locator('[data-rtk-root]')).toHaveCount(0); + const app = await new AxeBuilder({page}).analyze(); + expect(app.violations).toEqual([]); +}); diff --git a/test/support/tour-behavior.suite.tsx b/test/support/tour-behavior.suite.tsx index a6cc6f4..8d8927a 100644 --- a/test/support/tour-behavior.suite.tsx +++ b/test/support/tour-behavior.suite.tsx @@ -243,9 +243,10 @@ export function describeTourBehavior(harness: TourBehaviorHarness): void { await harness.waitForStep('First step'); const spinner = page.getByRole('progressbar', {name: 'Loading…'}); await page.getByRole('button', {name: 'Next'}).click(); - // A quick step change shows nothing. + // A quick step change shows nothing: the step on screen stays meanwhile. await sleep(motion.waitingDelayMs / 2); expect(spinner.query()).toBeNull(); + expect(harness.tooltip()).not.toBeNull(); await expect.element(spinner).toBeVisible(); expect(harness.tooltip()).toBeNull(); gate.resolve(); @@ -253,6 +254,58 @@ export function describeTourBehavior(harness: TourBehaviorHarness): void { expect(spinner.query()).toBeNull(); }); + it('hides the step on screen at once when its target goes away while the next step is prepared', async () => { + let removeFirst: (() => void) | undefined; + function Leaving(): ReactNode { + const [isThere, setIsThere] = useState(true); + useEffect(() => { + removeFirst = () => { + setIsThere(false); + }; + }, []); + return isThere ? : null; + } + + await harness.render( + <> + + + + + + , + ); + const gate = deferred(); + startedTour().startTour( + [ + steps[0]!, + { + ...steps[1]!, + // The app moves on (navigates away from the first step's page), then loads for a while. + async onBeforeStep() { + removeFirst?.(); + await gate.promise; + }, + }, + ], + 'intro', + ); + await harness.waitForStep('First step'); + const spinner = page.getByRole('progressbar', {name: 'Loading…'}); + await page.getByRole('button', {name: 'Next'}).click(); + // Long before the spinner is due, the step is no longer over what is there now. + await vi.waitFor( + () => { + expect(harness.tooltip()).toBeNull(); + }, + {timeout: motion.waitingDelayMs / 2, interval: 10}, + ); + expect(spinner.query()).toBeNull(); + await expect.element(spinner).toBeVisible(); + gate.resolve(); + await harness.waitForStep('Second step'); + }); + it('can be left while it waits for a step, with a press on the dimmed area', async () => { const onTourEnd = vi.fn<(id: string | undefined, info: TourEndInfo) => void>(); await renderScreen({onTourEnd}); diff --git a/xo.config.ts b/xo.config.ts index 92e335f..217b7e5 100644 --- a/xo.config.ts +++ b/xo.config.ts @@ -128,7 +128,7 @@ const xoConfig: FlatXoConfig = [ }, // Node scripts and tool configuration (same formatting as the source: Prettier with spaces) { - files: ['test/**/*.mjs', 'vitest.config.ts', 'playwright.config.ts'], + files: ['test/**/*.mjs', 'vitest.config.ts', 'playwright.config.ts', 'scripts/**/*.ts'], prettier: true, space: true, semicolon: true,