Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/assets/demo-poster.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/assets/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
62 changes: 62 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ node_modules

# Build output
dist
dist-showcase

# Turbo
.turbo
Expand Down
22 changes: 11 additions & 11 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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` |
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

Guided tours for React and React Native that never leave the screen.

<p align="center">
<a href="https://robertlinde.github.io/react-tour-kit/">
<picture>
<source media="(prefers-reduced-motion: reduce)" srcset="https://raw.githubusercontent.com/robertlinde/react-tour-kit/main/.github/assets/demo-poster.png" />
<img src="https://raw.githubusercontent.com/robertlinde/react-tour-kit/main/.github/assets/demo.gif" width="100%" alt="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." />
</picture>
</a>
</p>

<p align="center"><a href="https://robertlinde.github.io/react-tour-kit/"><strong>Try the live demo →</strong></a></p>

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
Expand Down Expand Up @@ -575,7 +586,7 @@ The tour is rendered as a modal `<dialog>` 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), <kbd>Esc</kbd> 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.
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions demos/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
27 changes: 27 additions & 0 deletions demos/react/showcase/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>react-tour-kit · Live demo</title>
<meta
name="description"
content="A guided product tour built with react-tour-kit: smooth, accessible, and it never leaves the screen."
/>
<meta property="og:title" content="react-tour-kit · Live demo" />
<meta property="og:description" content="Guided tours for React and React Native that never leave the screen." />
<meta
property="og:image"
content="https://raw.githubusercontent.com/robertlinde/react-tour-kit/main/.github/assets/demo-poster.png"
/>
<meta name="twitter:card" content="summary_large_image" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3Cpath d='M16 7l8 9-8 9-8-9z' fill='%23fff'/%3E%3C/svg%3E"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="../src/showcase/main.tsx"></script>
</body>
</html>
127 changes: 127 additions & 0 deletions demos/react/src/showcase/App.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<NavLink key={item.path} to={item.path} end className="nav-item">
<Icon name={item.icon} />
{item.label}
</NavLink>
));

return (
<div className="shell">
<aside className="sidebar">
<span className="brand">
<span className="brand-mark">
<Icon name="logo" size={16} />
</span>
Acme
</span>
<nav aria-label="Main">{links}</nav>
<a className="built-with" href="https://github.com/robertlinde/react-tour-kit">
Built with <strong>react-tour-kit</strong>
</a>
</aside>

<div className="main">
<header className="topbar">
<span className="brand-mark brand-mark-compact" aria-hidden="true">
<Icon name="logo" size={16} />
</span>
<label className="search" data-tour="search">
<Icon name="search" size={16} />
<input type="search" placeholder="Search Acme" aria-label="Search" />
</label>
<button
type="button"
className="button button-ghost"
data-tour="help"
aria-label="Help"
onClick={() => {
startTour(tour, 'onboarding');
}}
>
<Icon name="help" />
<span className="button-label">Help</span>
</button>
<button type="button" className="icon-button hide-compact" aria-label="Notifications">
<Icon name="bell" />
</button>
<span className="avatar avatar-me hide-compact" aria-hidden="true">
SO
</span>
<button type="button" className="button button-primary" data-tour="new-project" aria-label="New project">
<Icon name="plus" size={16} />
<span className="button-label">New project</span>
</button>
</header>

<nav className="tabs" aria-label="Main">
{links}
</nav>

<main className="content">
<Outlet />
</main>
</div>

<ScrollRestoration />
</div>
);
}

const router = createHashRouter([
{
element: <Layout />,
children: [
{path: '/', element: <DashboardPage />},
{path: '/projects', element: <ProjectsPage />},
{path: '/team', element: <TeamPage />},
{path: '/settings', element: <SettingsPage />},
],
},
]);

const tour = createShowcaseTour((path) => {
if (router.state.location.pathname !== path) {
void router.navigate(path);
}
});

export function App(): ReactNode {
return (
<TourProvider theme={theme}>
<RouterProvider router={router} />
</TourProvider>
);
}
Loading
Loading