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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

Releases are published automatically from `main`; each GitHub release lists its commits. This file describes the larger changes.

## 1.1.0

### Added

- A waiting state: when a step takes a moment to prepare (`onBeforeStep` loading data or navigating, a target that is still rendering), the highlight closes, the screen is dimmed and a spinner appears in the middle, on the web and React Native, with built-in and custom tooltips alike. Before, the previous step stayed unchanged and Next seemed to do nothing until the next one appeared. Pressing the dimmed area meanwhile leaves the tour.
- `i18n.loadingAriaLabel` (default `'Loading…'`): what screen readers announce while the tour waits, and the spinner's accessible name.

## 1.0.3

### Fixed
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,7 @@ The defaults meet WCAG AA contrast. A dark theme only needs a dark background an
stepCounterTemplate: '{current} von {total}',
closeAriaLabel: 'Tour schließen',
dotAriaLabelTemplate: 'Zu Schritt {step}',
loadingAriaLabel: 'Wird geladen…',
}}
>
```
Expand All @@ -501,6 +502,7 @@ The defaults meet WCAG AA contrast. A dark theme only needs a dark background an
| `stepCounterTemplate` | `'{current} of {total}'` |
| `closeAriaLabel` | `'Close tour'` |
| `dotAriaLabelTemplate` | `'Go to step {step}'` |
| `loadingAriaLabel` | `'Loading…'` |

## Custom tooltip

Expand Down Expand Up @@ -573,7 +575,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.
- **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.
- **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
79 changes: 78 additions & 1 deletion demos/react-native/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {type ComponentRef, createContext, createRef, type ReactNode, use, useState} from 'react';
import {type ComponentRef, createContext, createRef, type ReactNode, use, useState, useSyncExternalStore} from 'react';
import {StatusBar} from 'expo-status-bar';
import {FlatList, Pressable, ScrollView, StyleSheet, Switch, Text, View} from 'react-native';
import {SafeAreaProvider, useSafeAreaInsets} from 'react-native-safe-area-context';
Expand Down Expand Up @@ -49,6 +49,53 @@ function on(screen: keyof Screens, step: TourStep): TourStep {
};
}

type Order = {readonly id: number; readonly table: string; readonly items: string};
type OrdersState = {readonly orders: readonly Order[]; readonly isLoading: boolean};

const sampleOrders: readonly Order[] = [
{id: 1, table: 'Table 4', items: '2 × Pasta, 1 × Salad'},
{id: 2, table: 'Table 7', items: '3 × Pizza'},
{id: 3, table: 'Terrace 2', items: '1 × Soup, 2 × Lemonade'},
];

/** How long the demo's "server" takes to answer. */
const ordersServerMs = 1500;

/** Recent orders, as if from a server: refreshing them takes a moment. */
let ordersState: OrdersState = {orders: sampleOrders, isLoading: false};
const ordersListeners = new Set<() => void>();

function setOrdersState(next: OrdersState): void {
ordersState = next;
for (const listener of ordersListeners) {
listener();
}
}

function subscribeToOrders(listener: () => void): () => void {
ordersListeners.add(listener);
return () => {
ordersListeners.delete(listener);
};
}

/** Loads the orders again; resolves once they are there. */
async function refreshOrders(): Promise<void> {
setOrdersState({...ordersState, isLoading: true});
await new Promise((resolve) => {
setTimeout(resolve, ordersServerMs);
});
setOrdersState({orders: sampleOrders, isLoading: false});
}

function useOrders(): OrdersState {
return useSyncExternalStore(
subscribeToOrders,
() => ordersState,
() => ordersState,
);
}

const demoTour: TourStep[] = [
on('Home', {
target: 'header',
Expand All @@ -66,6 +113,15 @@ const demoTour: TourStep[] = [
content: 'The tooltip picks the side with room and never leaves the safe area.',
placement: 'top',
}),
{
target: 'orders',
title: 'Waits for your data',
content: 'onBeforeStep refreshed these orders first. While that took a moment, the tour showed a spinner.',
async onBeforeStep() {
show('Home');
await refreshOrders();
},
},
on('Home', {
target: 'integration-last',
title: 'Horizontal lists',
Expand Down Expand Up @@ -242,6 +298,21 @@ function Integrations(): ReactNode {
);
}

function RecentOrders(): ReactNode {
const ref = useTourTarget('orders');
const {orders, isLoading} = useOrders();
return (
<View ref={ref} style={styles.card}>
<Text style={styles.cardTitle}>Recent orders{isLoading ? ' · updating…' : ''}</Text>
{orders.map((order) => (
<Text key={order.id} style={styles.cardText}>
<Text style={styles.orderTable}>{order.table}</Text> {order.items}
</Text>
))}
</View>
);
}

function HomeScreen(): ReactNode {
const {startTour, isActive, currentStep, steps} = useTour();
// A ref the app already has: pass it in.
Expand Down Expand Up @@ -292,6 +363,8 @@ function HomeScreen(): ReactNode {
description="Screen-reader focus moves to the tooltip, the back button closes the tour."
/>

<RecentOrders />

<Text style={styles.sectionTitle}>Integrations</Text>
</View>
<Integrations />
Expand Down Expand Up @@ -480,6 +553,10 @@ const styles = StyleSheet.create({
textAlign: 'center',
lineHeight: 20,
},
orderTable: {
fontWeight: '600',
color: '#f9fafb',
},
integrations: {
gap: 12,
paddingHorizontal: 24,
Expand Down
49 changes: 48 additions & 1 deletion demos/react/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,39 @@
border-radius: 12px;
}

.orders-section {
max-width: 480px;
margin: 0 auto 3rem;
padding: 1.5rem 2rem;
background: #f8f9fa;
border-radius: 12px;
}

.orders-section h2 {
margin: 0 0 1rem;
font-size: 1.25rem;
}

.orders {
margin: 0;
padding: 0;
list-style: none;
}

.orders li {
padding: 0.5rem 0;
color: #666;
}

.orders li + li {
border-top: 1px solid #e2e8f0;
}

.orders strong {
margin-right: 0.5rem;
color: #333;
}

.install-section h2 {
margin: 0 0 1rem;
font-size: 1.5rem;
Expand Down Expand Up @@ -334,10 +367,24 @@
color: #a0aec0;
}

.install-section {
.install-section,
.orders-section {
background: #2d3748;
}

.orders-section h2,
.orders strong {
color: #f7fafc;
}

.orders li {
color: #a0aec0;
}

.orders li + li {
border-top-color: #4a5568;
}

.install-section h2 {
color: #f7fafc;
}
Expand Down
13 changes: 13 additions & 0 deletions demos/react/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {type ReactNode, useEffect, useState} from 'react';
import {BrowserRouter, Link, Route, Routes, useNavigate} from 'react-router-dom';
import {TourProvider, type TourTheme, type TourTooltipProps, useTour} from '@robertlinde/react-tour-kit/react';
import {Lab} from './lab.tsx';
import {useOrders} from './orders.ts';
import {createDemoTour} from './tour.tsx';
import './App.css';

Expand Down Expand Up @@ -116,6 +117,7 @@ function Navigation({
function HomePage(): ReactNode {
const {startTour, isActive, currentStep, steps} = useTour();
const navigate = useNavigate();
const {orders, isLoading} = useOrders();

return (
<div className="page">
Expand Down Expand Up @@ -146,6 +148,17 @@ function HomePage(): ReactNode {
</div>
</section>

<section className="orders-section" data-tour="orders">
<h2>Recent orders{isLoading ? ' · updating…' : ''}</h2>
<ul className="orders">
{orders.map((order) => (
<li key={order.id}>
<strong>{order.table}</strong> {order.items}
</li>
))}
</ul>
</section>

<section className="cta-section">
<button
type="button"
Expand Down
49 changes: 49 additions & 0 deletions demos/react/src/orders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {useSyncExternalStore} from 'react';

/** Recent orders, as if from a server: refreshing them takes a moment. */
export type Order = {readonly id: number; readonly table: string; readonly items: string};

type OrdersState = {readonly orders: readonly Order[]; readonly isLoading: boolean};

const sampleOrders: readonly Order[] = [
{id: 1, table: 'Table 4', items: '2 × Pasta, 1 × Salad'},
{id: 2, table: 'Table 7', items: '3 × Pizza'},
{id: 3, table: 'Terrace 2', items: '1 × Soup, 2 × Lemonade'},
];

/** How long the demo's "server" takes to answer. */
const serverMs = 1500;

let state: OrdersState = {orders: sampleOrders, isLoading: false};
const listeners = new Set<() => void>();

function update(next: OrdersState): void {
state = next;
for (const listener of listeners) {
listener();
}
}

function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}

/** Loads the orders again; resolves once they are there. */
export async function refreshOrders(): Promise<void> {
update({...state, isLoading: true});
await new Promise((resolve) => {
setTimeout(resolve, serverMs);
});
update({orders: sampleOrders, isLoading: false});
}

export function useOrders(): OrdersState {
return useSyncExternalStore(
subscribe,
() => state,
() => state,
);
}
10 changes: 10 additions & 0 deletions demos/react/src/tour.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {TourStep} from '@robertlinde/react-tour-kit/react';
import {refreshOrders} from './orders.ts';

/**
* A tour across two pages. Every step first opens the page it needs, so the tour also works when
Expand Down Expand Up @@ -36,6 +37,15 @@ export function createDemoTour(navigate: (path: string) => void): TourStep[] {
content: 'Asked for the right side; flips automatically when there is no room.',
placement: 'right',
}),
{
target: '[data-tour="orders"]',
title: 'Waits for your data',
content: 'onBeforeStep refreshed these orders first. While that took a moment, the tour showed a spinner.',
async onBeforeStep() {
open('/');
await refreshOrders();
},
},
on('/', {
target: '[data-tour="install"]',
title: 'Below the fold',
Expand Down
36 changes: 35 additions & 1 deletion src/react-native-platform/layer/native-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from 'react';
import {
AccessibilityInfo,
ActivityIndicator,
Animated,
BackHandler,
Easing,
Expand Down Expand Up @@ -46,6 +47,7 @@ import type {
TextPartProps,
TooltipFrameProps,
TourRenderer,
WaitingProps,
} from '../../shared/renderer/renderer.type.js';

const emptySize: Size = {width: 0, height: 0};
Expand Down Expand Up @@ -512,6 +514,36 @@ function TooltipFrame({children, layout, entrance, isBusy, isHidden, onSize, onI
);
}

/** A spinner in the middle of the layer, faded in on the UI thread; presses go through to the dimmed area. */
function Waiting({label, theme}: WaitingProps): ReactNode {
const [opacity] = useState(() => new Animated.Value(0));
useEffect(() => {
const animation = Animated.timing(opacity, {
toValue: 1,
duration: motion.fadeInMs,
easing: standardEasing,
useNativeDriver,
});
animation.start();
return (): void => {
animation.stop();
};
}, [opacity]);

return (
<View pointerEvents="none" style={[StyleSheet.absoluteFill, styles.waitingLayer]}>
<Animated.View
accessible
accessibilityRole="progressbar"
accessibilityLabel={label}
style={[styles.card, styles.waitingCard, {backgroundColor: theme.tooltipBackground, opacity}]}
>
<ActivityIndicator color={theme.primaryColor} />
</Animated.View>
</View>
);
}

function MeasureFrame({children, maxWidth, maxHeight, onSize}: MeasureFrameProps): ReactNode {
return (
<View
Expand Down Expand Up @@ -728,7 +760,7 @@ const card: CardParts = {
};

/** How React Native draws the tour. */
export const nativeRenderer: TourRenderer = {Layer, Backdrop, Spotlight, TooltipFrame, MeasureFrame, card};
export const nativeRenderer: TourRenderer = {Layer, Backdrop, Spotlight, TooltipFrame, MeasureFrame, Waiting, card};

const styles = StyleSheet.create({
piece: {position: 'absolute', top: 0, left: 0},
Expand All @@ -737,6 +769,8 @@ const styles = StyleSheet.create({
verticalBar: {width: ringWidth, height: barLength},
hidden: {opacity: 0},
measure: {position: 'absolute', top: 0, left: 0, opacity: 0},
waitingLayer: {alignItems: 'center', justifyContent: 'center'},
waitingCard: {width: 56, height: 56, alignItems: 'center', justifyContent: 'center'},
card: {
borderRadius: 14,
...Platform.select({
Expand Down
Loading
Loading