diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f53eb..eb0b436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 8952685..d3217f0 100644 --- a/README.md +++ b/README.md @@ -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…', }} > ``` @@ -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 @@ -573,7 +575,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. +- **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), 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. diff --git a/demos/react-native/App.tsx b/demos/react-native/App.tsx index da4daa7..78f787b 100644 --- a/demos/react-native/App.tsx +++ b/demos/react-native/App.tsx @@ -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'; @@ -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 { + 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', @@ -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', @@ -242,6 +298,21 @@ function Integrations(): ReactNode { ); } +function RecentOrders(): ReactNode { + const ref = useTourTarget('orders'); + const {orders, isLoading} = useOrders(); + return ( + + Recent orders{isLoading ? ' · updating…' : ''} + {orders.map((order) => ( + + {order.table} {order.items} + + ))} + + ); +} + function HomeScreen(): ReactNode { const {startTour, isActive, currentStep, steps} = useTour(); // A ref the app already has: pass it in. @@ -292,6 +363,8 @@ function HomeScreen(): ReactNode { description="Screen-reader focus moves to the tooltip, the back button closes the tour." /> + + Integrations @@ -480,6 +553,10 @@ const styles = StyleSheet.create({ textAlign: 'center', lineHeight: 20, }, + orderTable: { + fontWeight: '600', + color: '#f9fafb', + }, integrations: { gap: 12, paddingHorizontal: 24, diff --git a/demos/react/src/App.css b/demos/react/src/App.css index a114b89..a5193a8 100644 --- a/demos/react/src/App.css +++ b/demos/react/src/App.css @@ -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; @@ -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; } diff --git a/demos/react/src/App.tsx b/demos/react/src/App.tsx index 9601c99..fa4590f 100644 --- a/demos/react/src/App.tsx +++ b/demos/react/src/App.tsx @@ -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'; @@ -116,6 +117,7 @@ function Navigation({ function HomePage(): ReactNode { const {startTour, isActive, currentStep, steps} = useTour(); const navigate = useNavigate(); + const {orders, isLoading} = useOrders(); return (
@@ -146,6 +148,17 @@ function HomePage(): ReactNode {
+
+

Recent orders{isLoading ? ' · updating…' : ''}

+
    + {orders.map((order) => ( +
  • + {order.table} {order.items} +
  • + ))} +
+
+