@tapestry-ui/tour 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +118 -2
- package/dist/tour.d.ts +123 -0
- package/dist/tour.js +278 -0
- package/package.json +56 -4
- package/styles.css +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brandon Minton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,119 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tapestry-ui/tour
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A product tour for Preact: **spotlight the thing, explain it, move on.**
|
|
4
|
+
WCAG 2.1 AA, headless behavior, skinned entirely through CSS.
|
|
5
|
+
|
|
6
|
+
Part of [Tapestry UI](https://github.com/ryurage/brandonminton).
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm i @tapestry-ui/tour
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { Tour } from '@tapestry-ui/tour';
|
|
14
|
+
import '@tapestry-ui/tour/styles.css'; // structural only — picks one colour, the dim
|
|
15
|
+
|
|
16
|
+
<Tour
|
|
17
|
+
open={touring}
|
|
18
|
+
onClose={() => setTouring(false)}
|
|
19
|
+
label="Dashboard tour"
|
|
20
|
+
steps={[
|
|
21
|
+
{ target: '.fuse', title: 'The countdown', body: 'Days until sell-ready.' },
|
|
22
|
+
{ target: '.property-z', title: 'Every property', body: 'A decade, month by month.' },
|
|
23
|
+
]}
|
|
24
|
+
/>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Atomic parts
|
|
28
|
+
|
|
29
|
+
Split on Brad Frost's lines, like the accordion:
|
|
30
|
+
|
|
31
|
+
| | | |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| `Spotlight` | **atom** | dims the page, cuts a hole around one element |
|
|
34
|
+
| `Coachmark` | *molecule* | a spotlight plus the bubble that explains it — composed inside `Tour` |
|
|
35
|
+
| `Tour` | **organism** | the sequence, with the controls and the keyboard |
|
|
36
|
+
|
|
37
|
+
`Spotlight` and the pure helpers `placeBubble` / `rectOf` are exported, so you can
|
|
38
|
+
build a different sequence on the same foundation.
|
|
39
|
+
|
|
40
|
+
## The two contracts
|
|
41
|
+
|
|
42
|
+
**The component owns the SEQUENCE; the consumer owns the PAGE.** A tour cannot
|
|
43
|
+
know that step four points at something inside a closed drawer. So a step may
|
|
44
|
+
carry `onEnter` — sync or async — that you use to put the page in the right
|
|
45
|
+
state. The tour then waits for the target to appear (up to 1.2s) before pointing
|
|
46
|
+
at it.
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
{ target: '#epic-editor', body: 'Epics live here.', onEnter: () => openDrawer('epics') }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**The container contract, and its exception.** Tapestry UI components never own
|
|
53
|
+
scrolling — but a tour must, because a step whose target is below the fold points
|
|
54
|
+
at nothing. It scrolls **the target**, never a container of yours, and only when
|
|
55
|
+
the target is actually off screen. A tour that jumps the page on every step is a
|
|
56
|
+
tour nobody finishes.
|
|
57
|
+
|
|
58
|
+
## A step whose target never arrives still speaks
|
|
59
|
+
|
|
60
|
+
It is not skipped and it does not throw. The bubble centres itself, the spotlight
|
|
61
|
+
stays dark, and `onMissingTarget` tells you — because a renamed class should cost
|
|
62
|
+
you one highlight, not the whole tour.
|
|
63
|
+
|
|
64
|
+
## Props
|
|
65
|
+
|
|
66
|
+
| Prop | Type | Notes |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `steps` | `TourStep[]` | `{ target?, title?, body, placement?, padding?, onEnter? }` |
|
|
69
|
+
| `open` | `boolean` | |
|
|
70
|
+
| `onClose` | `(reason) => void` | `'finished'` \| `'skipped'` \| `'escaped'` — they mean different things |
|
|
71
|
+
| `index` / `onIndex` | `number` / `(i) => void` | controlled; the tour reports intent and never drifts |
|
|
72
|
+
| `label` | `string` | names the tour when a step has no title |
|
|
73
|
+
| `nextLabel` `backLabel` `skipLabel` `doneLabel` | `string` | |
|
|
74
|
+
| `showProgress` | `boolean` | "3 of 7" (default true) |
|
|
75
|
+
| `blocking` | `boolean` | block the page outside the spotlight (default true) |
|
|
76
|
+
| `onMissingTarget` | `(step, index) => void` | |
|
|
77
|
+
| `class` / `bubbleClass` / `id` | `string` | skin hooks |
|
|
78
|
+
|
|
79
|
+
## Keyboard
|
|
80
|
+
|
|
81
|
+
| Key | Does |
|
|
82
|
+
|---|---|
|
|
83
|
+
| <kbd>→</kbd> | next step, or finish on the last |
|
|
84
|
+
| <kbd>←</kbd> | previous step |
|
|
85
|
+
| <kbd>Esc</kbd> | leave, reported as `'escaped'` |
|
|
86
|
+
| <kbd>Tab</kbd> | cycles within the bubble while blocking |
|
|
87
|
+
|
|
88
|
+
Focus moves to the bubble on every step, so a keyboard and a screen reader follow
|
|
89
|
+
the tour instead of being left behind in the document. The bubble is a dialog
|
|
90
|
+
that does **not** claim `aria-modal` — the page behind it is the subject of the
|
|
91
|
+
tour, not scenery to be hidden — and the step count is announced from a polite
|
|
92
|
+
live region.
|
|
93
|
+
|
|
94
|
+
## Styling
|
|
95
|
+
|
|
96
|
+
| Property | Default |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `--tui-tour-dim` | `rgb(0 0 0 / 0.6)` |
|
|
99
|
+
| `--tui-tour-hole-radius` | `6px` |
|
|
100
|
+
| `--tui-tour-bubble-w` | `min(22rem, calc(100vw - 24px))` |
|
|
101
|
+
| `--tui-tour-bubble-pad` | `1rem` |
|
|
102
|
+
| `--tui-tour-gap` | `0.5em` |
|
|
103
|
+
| `--tui-tour-focus` | `2px solid currentColor` |
|
|
104
|
+
| `--tui-tour-z` / `--tui-tour-bubble-z` | `60` / `61` |
|
|
105
|
+
| `--tui-tour-move` | the hole's transition; honours `prefers-reduced-motion` |
|
|
106
|
+
|
|
107
|
+
Parts: `root` (`data-placement`), `spotlight` (`data-blocking`), `band`, `hole`,
|
|
108
|
+
`bubble`, `title`, `body`, `controls`, `progress`, `skip`, `back`, `next`,
|
|
109
|
+
`status`.
|
|
110
|
+
|
|
111
|
+
**How the hole works, in case you want to change it:** the dim is a `box-shadow`
|
|
112
|
+
on the hole itself rather than a fill over the page, which is what lets the hole
|
|
113
|
+
have rounded corners. The four `band` elements around it are what actually catch
|
|
114
|
+
clicks — so the highlighted element stays usable while the rest of the page does
|
|
115
|
+
not. **Never fork the package to restyle it.**
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT © Brandon Minton
|
package/dist/tour.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { type ComponentChildren, type JSX, type Ref } from 'preact';
|
|
2
|
+
/** The two sentences this component assembles. Functions, so a language whose
|
|
3
|
+
* "3 of 7" reads differently can simply say so. */
|
|
4
|
+
export interface TourStrings {
|
|
5
|
+
/** The visible progress line. */
|
|
6
|
+
progress: (step: number, total: number) => string;
|
|
7
|
+
/** What the live region announces on each step. */
|
|
8
|
+
announce: (step: number, total: number) => string;
|
|
9
|
+
}
|
|
10
|
+
export declare const EN_TOUR: TourStrings;
|
|
11
|
+
export type Placement = 'top' | 'bottom' | 'left' | 'right' | 'auto';
|
|
12
|
+
/** Where a mark actually landed; 'center' means there was nothing to point at. */
|
|
13
|
+
export type PlacedAt = Exclude<Placement, 'auto'> | 'center';
|
|
14
|
+
export type TourEnd = 'finished' | 'skipped' | 'escaped';
|
|
15
|
+
export interface TourStep {
|
|
16
|
+
/** Stable id; defaults to the step's position. */
|
|
17
|
+
id?: string;
|
|
18
|
+
/** CSS selector for the element to point at. Omit for a step about the page as a whole. */
|
|
19
|
+
target?: string;
|
|
20
|
+
title?: ComponentChildren;
|
|
21
|
+
body: ComponentChildren;
|
|
22
|
+
/** Where the bubble sits relative to the target (default: 'auto'). */
|
|
23
|
+
placement?: Placement;
|
|
24
|
+
/** Spotlight padding for this step only. */
|
|
25
|
+
padding?: number;
|
|
26
|
+
/** Corner radius of the hole, in px — a pill-shaped button wants a pill-shaped hole. */
|
|
27
|
+
radius?: number;
|
|
28
|
+
/** Put the page in the state this step needs — open the drawer, switch the tab.
|
|
29
|
+
* May be async; the tour waits for the target to appear either way. */
|
|
30
|
+
onEnter?: () => void | Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export interface TourProps {
|
|
33
|
+
steps: TourStep[];
|
|
34
|
+
open: boolean;
|
|
35
|
+
/** Why the tour ended: the last step was passed, skip was pressed, or Escape. */
|
|
36
|
+
onClose: (reason: TourEnd) => void;
|
|
37
|
+
/** Controlled step index. Omit to let the tour keep its own. */
|
|
38
|
+
index?: number;
|
|
39
|
+
onIndex?: (index: number) => void;
|
|
40
|
+
/** Accessible name for the tour as a whole. */
|
|
41
|
+
label?: string;
|
|
42
|
+
nextLabel?: string;
|
|
43
|
+
backLabel?: string;
|
|
44
|
+
skipLabel?: string;
|
|
45
|
+
doneLabel?: string;
|
|
46
|
+
/** Show "3 of 7" (default true). */
|
|
47
|
+
showProgress?: boolean;
|
|
48
|
+
/** Replace the sentences this component builds. Defaults are English. */
|
|
49
|
+
strings?: Partial<TourStrings>;
|
|
50
|
+
/** Stop the page being clicked outside the spotlight (default true). */
|
|
51
|
+
blocking?: boolean;
|
|
52
|
+
/** Called when a step's target never turned up; the step still shows, centred. */
|
|
53
|
+
onMissingTarget?: (step: TourStep, index: number) => void;
|
|
54
|
+
/** skin hooks — tapestry-ui components style through CSS, never props */
|
|
55
|
+
class?: string;
|
|
56
|
+
bubbleClass?: string;
|
|
57
|
+
id?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface Rect {
|
|
60
|
+
top: number;
|
|
61
|
+
left: number;
|
|
62
|
+
width: number;
|
|
63
|
+
height: number;
|
|
64
|
+
}
|
|
65
|
+
/** Viewport-space box of an element, or null when it isn't there (or has no box). */
|
|
66
|
+
export declare function rectOf(element: Element | null): Rect | null;
|
|
67
|
+
/**
|
|
68
|
+
* Where the bubble goes. 'auto' prefers below, then above, then the sides —
|
|
69
|
+
* whichever first has ROOM_NEEDED. Everything is viewport space, so the caller
|
|
70
|
+
* can position with `fixed` and never think about scroll offsets.
|
|
71
|
+
*/
|
|
72
|
+
export declare function placeBubble(hole: Rect | null, bubble: {
|
|
73
|
+
width: number;
|
|
74
|
+
height: number;
|
|
75
|
+
}, view: {
|
|
76
|
+
width: number;
|
|
77
|
+
height: number;
|
|
78
|
+
}, wanted?: Placement): {
|
|
79
|
+
top: number;
|
|
80
|
+
left: number;
|
|
81
|
+
placement: PlacedAt;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* The atom: the page goes dim, one rectangle doesn't.
|
|
85
|
+
*
|
|
86
|
+
* The dimming is a box-shadow on the hole itself rather than a filled overlay,
|
|
87
|
+
* so the hole can have rounded corners. The four bands around it are what
|
|
88
|
+
* actually catch clicks — which is why the hole stays clickable, and why
|
|
89
|
+
* `blocking` can be honoured without making the highlighted thing unusable.
|
|
90
|
+
*/
|
|
91
|
+
export declare function Spotlight({ hole, blocking, radius }: {
|
|
92
|
+
hole: Rect | null;
|
|
93
|
+
blocking?: boolean;
|
|
94
|
+
radius?: number;
|
|
95
|
+
}): JSX.Element;
|
|
96
|
+
export interface CoachmarkProps {
|
|
97
|
+
/** The rectangle to light up, in viewport space. Null dims everything and centres the bubble. */
|
|
98
|
+
hole: Rect | null;
|
|
99
|
+
/** What the bubble says — and, in a tour, the controls underneath it. */
|
|
100
|
+
children: ComponentChildren;
|
|
101
|
+
placement?: Placement;
|
|
102
|
+
blocking?: boolean;
|
|
103
|
+
/** Corner radius of the hole, in px. */
|
|
104
|
+
radius?: number;
|
|
105
|
+
/** Accessible name, when the content has no heading to point at. */
|
|
106
|
+
label?: string;
|
|
107
|
+
/** Id of the heading inside `children` that names this mark. */
|
|
108
|
+
labelledBy?: string;
|
|
109
|
+
/** Handed out so a sequence can move focus here on every step. */
|
|
110
|
+
bubbleRef?: Ref<HTMLDivElement>;
|
|
111
|
+
class?: string;
|
|
112
|
+
bubbleClass?: string;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The molecule: a spotlight, and the bubble anchored to it.
|
|
116
|
+
*
|
|
117
|
+
* It measures its own bubble and places itself, so the thing that owns the
|
|
118
|
+
* sequence only has to say which rectangle matters. The bubble is a dialog that
|
|
119
|
+
* does NOT claim aria-modal — the page behind it is the subject of the
|
|
120
|
+
* explanation, not scenery to be hidden from a screen reader.
|
|
121
|
+
*/
|
|
122
|
+
export declare function Coachmark(props: CoachmarkProps): JSX.Element;
|
|
123
|
+
export declare function Tour(props: TourProps): JSX.Element | null;
|
package/dist/tour.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
3
|
+
/** Breathing room, in px, between the target's edge and the spotlight's edge. */
|
|
4
|
+
const DEFAULT_SPOTLIGHT_PAD = 6;
|
|
5
|
+
/** Gap, in px, between the spotlight and the bubble. */
|
|
6
|
+
const BUBBLE_GAP = 12;
|
|
7
|
+
/** Keep the bubble at least this far from the viewport edge. */
|
|
8
|
+
const VIEWPORT_MARGIN = 12;
|
|
9
|
+
/** A step needs this much room on a side before the bubble will go there. */
|
|
10
|
+
const ROOM_NEEDED = 160;
|
|
11
|
+
/** How long to wait for a step's target to appear after onEnter, in ms. */
|
|
12
|
+
const TARGET_WAIT_MS = 1200;
|
|
13
|
+
/** How often to look for it while waiting, in ms. */
|
|
14
|
+
const TARGET_POLL_MS = 50;
|
|
15
|
+
export const EN_TOUR = {
|
|
16
|
+
progress: (step, total) => `${step} of ${total}`,
|
|
17
|
+
announce: (step, total) => `Step ${step} of ${total}`,
|
|
18
|
+
};
|
|
19
|
+
const cx = (...parts) => parts.filter(Boolean).join(' ');
|
|
20
|
+
const grow = (rect, by) => ({
|
|
21
|
+
top: rect.top - by,
|
|
22
|
+
left: rect.left - by,
|
|
23
|
+
width: rect.width + by * 2,
|
|
24
|
+
height: rect.height + by * 2,
|
|
25
|
+
});
|
|
26
|
+
/** Viewport-space box of an element, or null when it isn't there (or has no box). */
|
|
27
|
+
export function rectOf(element) {
|
|
28
|
+
const box = element?.getBoundingClientRect?.();
|
|
29
|
+
if (!box)
|
|
30
|
+
return null;
|
|
31
|
+
if (box.width === 0 && box.height === 0)
|
|
32
|
+
return null;
|
|
33
|
+
return { top: box.top, left: box.left, width: box.width, height: box.height };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Where the bubble goes. 'auto' prefers below, then above, then the sides —
|
|
37
|
+
* whichever first has ROOM_NEEDED. Everything is viewport space, so the caller
|
|
38
|
+
* can position with `fixed` and never think about scroll offsets.
|
|
39
|
+
*/
|
|
40
|
+
export function placeBubble(hole, bubble, view, wanted = 'auto') {
|
|
41
|
+
if (!hole) {
|
|
42
|
+
return {
|
|
43
|
+
top: Math.max(VIEWPORT_MARGIN, (view.height - bubble.height) / 2),
|
|
44
|
+
left: Math.max(VIEWPORT_MARGIN, (view.width - bubble.width) / 2),
|
|
45
|
+
placement: 'center',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const room = {
|
|
49
|
+
bottom: view.height - (hole.top + hole.height),
|
|
50
|
+
top: hole.top,
|
|
51
|
+
right: view.width - (hole.left + hole.width),
|
|
52
|
+
left: hole.left,
|
|
53
|
+
};
|
|
54
|
+
const order = wanted === 'auto' ? ['bottom', 'top', 'right', 'left'] : [wanted];
|
|
55
|
+
const needed = { bottom: bubble.height, top: bubble.height, right: bubble.width, left: bubble.width };
|
|
56
|
+
const fits = order.find(side => room[side] >= Math.min(needed[side], ROOM_NEEDED) + BUBBLE_GAP);
|
|
57
|
+
// nothing fits: take the roomiest side rather than drawing off-screen
|
|
58
|
+
const side = fits ?? ['bottom', 'top', 'right', 'left']
|
|
59
|
+
.reduce((best, one) => (room[one] > room[best] ? one : best), 'bottom');
|
|
60
|
+
const centreX = hole.left + hole.width / 2 - bubble.width / 2;
|
|
61
|
+
const centreY = hole.top + hole.height / 2 - bubble.height / 2;
|
|
62
|
+
const raw = {
|
|
63
|
+
bottom: { top: hole.top + hole.height + BUBBLE_GAP, left: centreX },
|
|
64
|
+
top: { top: hole.top - bubble.height - BUBBLE_GAP, left: centreX },
|
|
65
|
+
right: { top: centreY, left: hole.left + hole.width + BUBBLE_GAP },
|
|
66
|
+
left: { top: centreY, left: hole.left - bubble.width - BUBBLE_GAP },
|
|
67
|
+
}[side];
|
|
68
|
+
const clamp = (value, size, limit) => Math.max(VIEWPORT_MARGIN, Math.min(value, limit - size - VIEWPORT_MARGIN));
|
|
69
|
+
return {
|
|
70
|
+
top: clamp(raw.top, bubble.height, view.height),
|
|
71
|
+
left: clamp(raw.left, bubble.width, view.width),
|
|
72
|
+
placement: side,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The atom: the page goes dim, one rectangle doesn't.
|
|
77
|
+
*
|
|
78
|
+
* The dimming is a box-shadow on the hole itself rather than a filled overlay,
|
|
79
|
+
* so the hole can have rounded corners. The four bands around it are what
|
|
80
|
+
* actually catch clicks — which is why the hole stays clickable, and why
|
|
81
|
+
* `blocking` can be honoured without making the highlighted thing unusable.
|
|
82
|
+
*/
|
|
83
|
+
export function Spotlight({ hole, blocking = true, radius }) {
|
|
84
|
+
const ring = hole && (_jsx("div", { class: "tui-tour__hole", "data-part": "hole", style: {
|
|
85
|
+
top: `${hole.top}px`,
|
|
86
|
+
left: `${hole.left}px`,
|
|
87
|
+
width: `${hole.width}px`,
|
|
88
|
+
height: `${hole.height}px`,
|
|
89
|
+
...(radius === undefined ? {} : { borderRadius: `${radius}px` }),
|
|
90
|
+
} }));
|
|
91
|
+
if (!blocking)
|
|
92
|
+
return _jsx("div", { class: "tui-tour__spotlight", "data-part": "spotlight", "data-blocking": "false", children: ring });
|
|
93
|
+
// four bands, so the page is unclickable everywhere except the hole
|
|
94
|
+
const bands = hole
|
|
95
|
+
? [
|
|
96
|
+
{ top: 0, left: 0, width: '100%', height: `${Math.max(0, hole.top)}px` },
|
|
97
|
+
{ top: `${hole.top + hole.height}px`, left: 0, width: '100%', bottom: 0 },
|
|
98
|
+
{ top: `${hole.top}px`, left: 0, width: `${Math.max(0, hole.left)}px`, height: `${hole.height}px` },
|
|
99
|
+
{ top: `${hole.top}px`, left: `${hole.left + hole.width}px`, right: 0, height: `${hole.height}px` },
|
|
100
|
+
]
|
|
101
|
+
: [{ top: 0, left: 0, right: 0, bottom: 0 }];
|
|
102
|
+
return (_jsxs("div", { class: "tui-tour__spotlight", "data-part": "spotlight", "data-blocking": "true", children: [bands.map((band, index) => (_jsx("div", { class: "tui-tour__band", "data-part": "band", style: band }, index))), ring] }));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The molecule: a spotlight, and the bubble anchored to it.
|
|
106
|
+
*
|
|
107
|
+
* It measures its own bubble and places itself, so the thing that owns the
|
|
108
|
+
* sequence only has to say which rectangle matters. The bubble is a dialog that
|
|
109
|
+
* does NOT claim aria-modal — the page behind it is the subject of the
|
|
110
|
+
* explanation, not scenery to be hidden from a screen reader.
|
|
111
|
+
*/
|
|
112
|
+
export function Coachmark(props) {
|
|
113
|
+
const { hole, blocking = true, placement = 'auto' } = props;
|
|
114
|
+
const own = useRef(null);
|
|
115
|
+
const bubble = (props.bubbleRef ?? own);
|
|
116
|
+
const [at, setAt] = useState({ top: 0, left: 0, placement: 'center' });
|
|
117
|
+
useLayoutEffect(() => {
|
|
118
|
+
const place = () => {
|
|
119
|
+
const box = bubble.current?.getBoundingClientRect?.();
|
|
120
|
+
setAt(placeBubble(hole, { width: box?.width || 320, height: box?.height || 160 }, { width: window.innerWidth, height: window.innerHeight }, placement));
|
|
121
|
+
};
|
|
122
|
+
place();
|
|
123
|
+
window.addEventListener('resize', place);
|
|
124
|
+
// capture, so a scroll inside any container repositions the mark too
|
|
125
|
+
window.addEventListener('scroll', place, true);
|
|
126
|
+
return () => {
|
|
127
|
+
window.removeEventListener('resize', place);
|
|
128
|
+
window.removeEventListener('scroll', place, true);
|
|
129
|
+
};
|
|
130
|
+
}, [hole?.top, hole?.left, hole?.width, hole?.height, placement]);
|
|
131
|
+
return (_jsxs("div", { class: cx('tui-tour', props.class), "data-part": "root", "data-placement": at.placement, children: [_jsx(Spotlight, { hole: hole, blocking: blocking, radius: props.radius }), _jsx("div", { ref: bubble, class: cx('tui-tour__bubble', props.bubbleClass), "data-part": "bubble", role: "dialog", "aria-modal": "false", "aria-label": props.labelledBy ? undefined : props.label, "aria-labelledby": props.labelledBy, tabIndex: -1, style: { top: `${at.top}px`, left: `${at.left}px` }, children: props.children })] }));
|
|
132
|
+
}
|
|
133
|
+
export function Tour(props) {
|
|
134
|
+
const autoId = useId();
|
|
135
|
+
const base = props.id ?? `tui-tour-${autoId}`;
|
|
136
|
+
const { steps, open, blocking = true, showProgress = true, nextLabel = 'Next', backLabel = 'Back', skipLabel = 'Skip', doneLabel = 'Done', } = props;
|
|
137
|
+
const words = { ...EN_TOUR, ...props.strings };
|
|
138
|
+
const controlled = props.index !== undefined;
|
|
139
|
+
const [ownIndex, setOwnIndex] = useState(0);
|
|
140
|
+
const rawIndex = controlled ? props.index : ownIndex;
|
|
141
|
+
const index = Math.min(Math.max(rawIndex, 0), Math.max(steps.length - 1, 0));
|
|
142
|
+
const step = steps[index];
|
|
143
|
+
const [hole, setHole] = useState(null);
|
|
144
|
+
const bubbleRef = useRef(null);
|
|
145
|
+
/** Bumped whenever the measured target should be re-read. */
|
|
146
|
+
const [measureTick, setMeasureTick] = useState(0);
|
|
147
|
+
const goTo = useCallback((next) => {
|
|
148
|
+
if (!controlled)
|
|
149
|
+
setOwnIndex(next);
|
|
150
|
+
props.onIndex?.(next);
|
|
151
|
+
}, [controlled, props.onIndex]);
|
|
152
|
+
const end = useCallback((reason) => {
|
|
153
|
+
if (!controlled)
|
|
154
|
+
setOwnIndex(0);
|
|
155
|
+
props.onClose(reason);
|
|
156
|
+
}, [controlled, props.onClose]);
|
|
157
|
+
const next = useCallback(() => {
|
|
158
|
+
if (index >= steps.length - 1)
|
|
159
|
+
end('finished');
|
|
160
|
+
else
|
|
161
|
+
goTo(index + 1);
|
|
162
|
+
}, [index, steps.length, end, goTo]);
|
|
163
|
+
const back = useCallback(() => { if (index > 0)
|
|
164
|
+
goTo(index - 1); }, [index, goTo]);
|
|
165
|
+
// ── put the page in this step's state, then wait for the target to turn up
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (!open || !step)
|
|
168
|
+
return;
|
|
169
|
+
let cancelled = false;
|
|
170
|
+
let timer = 0;
|
|
171
|
+
const find = () => (step.target ? document.querySelector(step.target) : null);
|
|
172
|
+
const settle = () => {
|
|
173
|
+
const target = find();
|
|
174
|
+
const found = rectOf(target);
|
|
175
|
+
if (found) {
|
|
176
|
+
// only scroll when it is actually out of view; a tour that jumps the page
|
|
177
|
+
// on every step is a tour nobody finishes
|
|
178
|
+
const offscreen = found.top < 0 || found.top + found.height > window.innerHeight;
|
|
179
|
+
if (offscreen)
|
|
180
|
+
target?.scrollIntoView?.({ block: 'center', behavior: 'smooth' });
|
|
181
|
+
setMeasureTick(tick => tick + 1);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
};
|
|
186
|
+
const deadline = Date.now() + TARGET_WAIT_MS;
|
|
187
|
+
const poll = () => {
|
|
188
|
+
if (cancelled)
|
|
189
|
+
return;
|
|
190
|
+
if (settle())
|
|
191
|
+
return;
|
|
192
|
+
if (!step.target) {
|
|
193
|
+
setHole(null);
|
|
194
|
+
setMeasureTick(tick => tick + 1);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (Date.now() >= deadline) {
|
|
198
|
+
setHole(null);
|
|
199
|
+
setMeasureTick(tick => tick + 1);
|
|
200
|
+
props.onMissingTarget?.(step, index);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
timer = window.setTimeout(poll, TARGET_POLL_MS);
|
|
204
|
+
};
|
|
205
|
+
setHole(null); // the old step's hole must not linger over the new step
|
|
206
|
+
Promise.resolve(step.onEnter?.()).then(() => { if (!cancelled)
|
|
207
|
+
poll(); });
|
|
208
|
+
return () => { cancelled = true; if (timer)
|
|
209
|
+
window.clearTimeout(timer); };
|
|
210
|
+
}, [open, index, step?.target]);
|
|
211
|
+
// ── where this step's target is. The coach mark does its own placing from here.
|
|
212
|
+
useLayoutEffect(() => {
|
|
213
|
+
if (!open || !step)
|
|
214
|
+
return;
|
|
215
|
+
const measure = () => {
|
|
216
|
+
const found = rectOf(step.target ? document.querySelector(step.target) : null);
|
|
217
|
+
setHole(found ? grow(found, step.padding ?? DEFAULT_SPOTLIGHT_PAD) : null);
|
|
218
|
+
};
|
|
219
|
+
measure();
|
|
220
|
+
window.addEventListener('resize', measure);
|
|
221
|
+
window.addEventListener('scroll', measure, true);
|
|
222
|
+
return () => {
|
|
223
|
+
window.removeEventListener('resize', measure);
|
|
224
|
+
window.removeEventListener('scroll', measure, true);
|
|
225
|
+
};
|
|
226
|
+
}, [open, index, measureTick, step?.target, step?.padding]);
|
|
227
|
+
// ── the keyboard owns the tour: focus follows each step, Escape leaves
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
if (!open)
|
|
230
|
+
return;
|
|
231
|
+
bubbleRef.current?.focus?.();
|
|
232
|
+
}, [open, index]);
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
if (!open)
|
|
235
|
+
return;
|
|
236
|
+
const onKey = (event) => {
|
|
237
|
+
if (event.key === 'Escape') {
|
|
238
|
+
event.preventDefault();
|
|
239
|
+
end('escaped');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (event.key === 'ArrowRight') {
|
|
243
|
+
event.preventDefault();
|
|
244
|
+
next();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (event.key === 'ArrowLeft') {
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
back();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (event.key !== 'Tab' || !blocking)
|
|
253
|
+
return;
|
|
254
|
+
// everything outside the bubble is unreachable anyway; keep Tab inside it
|
|
255
|
+
const focusable = bubbleRef.current?.querySelectorAll('button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
|
256
|
+
if (!focusable || focusable.length === 0)
|
|
257
|
+
return;
|
|
258
|
+
const first = focusable[0];
|
|
259
|
+
const last = focusable[focusable.length - 1];
|
|
260
|
+
const active = document.activeElement;
|
|
261
|
+
if (event.shiftKey && (active === first || active === bubbleRef.current)) {
|
|
262
|
+
event.preventDefault();
|
|
263
|
+
last.focus();
|
|
264
|
+
}
|
|
265
|
+
else if (!event.shiftKey && active === last) {
|
|
266
|
+
event.preventDefault();
|
|
267
|
+
first.focus();
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
document.addEventListener('keydown', onKey);
|
|
271
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
272
|
+
}, [open, blocking, next, back, end]);
|
|
273
|
+
if (!open || !step)
|
|
274
|
+
return null;
|
|
275
|
+
const titleId = `${base}-title`;
|
|
276
|
+
const isLast = index === steps.length - 1;
|
|
277
|
+
return (_jsxs("div", { id: base, class: cx('tui-tour', props.class), "data-part": "tour", children: [_jsxs(Coachmark, { hole: hole, placement: step.placement, blocking: blocking, radius: step.radius, bubbleRef: bubbleRef, label: step.title === undefined ? props.label ?? 'Tour' : undefined, labelledBy: step.title === undefined ? undefined : titleId, bubbleClass: props.bubbleClass, children: [step.title !== undefined && (_jsx("h2", { id: titleId, class: "tui-tour__title", "data-part": "title", children: step.title })), _jsx("div", { class: "tui-tour__body", "data-part": "body", children: step.body }), _jsxs("div", { class: "tui-tour__controls", "data-part": "controls", children: [showProgress && (_jsx("span", { class: "tui-tour__progress", "data-part": "progress", children: words.progress(index + 1, steps.length) })), _jsx("button", { type: "button", class: "tui-tour__skip", "data-part": "skip", onClick: () => end('skipped'), children: skipLabel }), _jsx("button", { type: "button", class: "tui-tour__back", "data-part": "back", disabled: index === 0, onClick: back, children: backLabel }), _jsx("button", { type: "button", class: "tui-tour__next", "data-part": "next", onClick: next, children: isLast ? doneLabel : nextLabel })] })] }), _jsx("span", { class: "tui-tour__sr", "data-part": "status", role: "status", "aria-live": "polite", children: words.announce(index + 1, steps.length) })] }));
|
|
278
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tapestry-ui/tour",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "A WCAG 2.1 AA product tour for Preact \u2014 spotlight, coach marks, keyboard, headless behavior, skin via CSS. The sequence is the component's; the page is yours.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Brandon Minton",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"sideEffects": [
|
|
9
|
+
"*.css"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/tour.d.ts",
|
|
14
|
+
"default": "./dist/tour.js"
|
|
15
|
+
},
|
|
16
|
+
"./styles.css": "./styles.css"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"styles.css",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc -p tsconfig.build.json",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"prepublishOnly": "npm run test && npm run build"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"preact": ">=10.24.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"jsdom": "^25.0.1",
|
|
36
|
+
"preact": "^10.29.4",
|
|
37
|
+
"typescript": "^6.0.3",
|
|
38
|
+
"vitest": "^4.1.10"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/ryurage/brandonminton.git",
|
|
43
|
+
"directory": "packages/tapestry-ui/tour"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"preact",
|
|
47
|
+
"tour",
|
|
48
|
+
"product-tour",
|
|
49
|
+
"onboarding",
|
|
50
|
+
"coachmark",
|
|
51
|
+
"spotlight",
|
|
52
|
+
"walkthrough",
|
|
53
|
+
"a11y",
|
|
54
|
+
"wcag",
|
|
55
|
+
"headless",
|
|
56
|
+
"tapestry-ui"
|
|
57
|
+
]
|
|
58
|
+
}
|
package/styles.css
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* @tapestry-ui/tour — STRUCTURAL styles only. The skin is yours: override the
|
|
2
|
+
custom properties, or ignore this file and style the [data-part] hooks from
|
|
3
|
+
scratch. The one colour here is the dim, because without it there is no
|
|
4
|
+
spotlight at all; it is a custom property like everything else. */
|
|
5
|
+
.tui-tour__spotlight { position: fixed; inset: 0; z-index: var(--tui-tour-z, 60); }
|
|
6
|
+
|
|
7
|
+
/* the bands are what catch clicks; the hole between them is left alone, which is
|
|
8
|
+
why the highlighted thing stays usable while the rest of the page does not */
|
|
9
|
+
.tui-tour__band { position: fixed; }
|
|
10
|
+
.tui-tour__spotlight[data-blocking="false"] { pointer-events: none; }
|
|
11
|
+
|
|
12
|
+
/* the dim is a shadow on the hole, not a fill over the page — that is what lets
|
|
13
|
+
the hole have rounded corners */
|
|
14
|
+
.tui-tour__hole {
|
|
15
|
+
position: fixed;
|
|
16
|
+
pointer-events: none;
|
|
17
|
+
border-radius: var(--tui-tour-hole-radius, 6px);
|
|
18
|
+
box-shadow: 0 0 0 100vmax var(--tui-tour-dim, rgb(0 0 0 / 0.6));
|
|
19
|
+
transition: var(--tui-tour-move, top 180ms ease, left 180ms ease, width 180ms ease, height 180ms ease);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.tui-tour__bubble {
|
|
23
|
+
position: fixed;
|
|
24
|
+
z-index: var(--tui-tour-bubble-z, 61);
|
|
25
|
+
box-sizing: border-box;
|
|
26
|
+
width: var(--tui-tour-bubble-w, min(22rem, calc(100vw - 24px)));
|
|
27
|
+
padding: var(--tui-tour-bubble-pad, 1rem);
|
|
28
|
+
}
|
|
29
|
+
.tui-tour__bubble:focus-visible { outline: var(--tui-tour-focus, 2px solid currentColor); outline-offset: 2px; }
|
|
30
|
+
.tui-tour__title { margin: 0 0 0.35em; font-size: 1rem; }
|
|
31
|
+
.tui-tour__body > :first-child { margin-top: 0; }
|
|
32
|
+
.tui-tour__body > :last-child { margin-bottom: 0; }
|
|
33
|
+
|
|
34
|
+
.tui-tour__controls {
|
|
35
|
+
display: flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
gap: var(--tui-tour-gap, 0.5em);
|
|
38
|
+
margin-top: var(--tui-tour-controls-top, 0.9em);
|
|
39
|
+
}
|
|
40
|
+
/* the count sits left, the buttons right — Skip furthest from Next, so the one
|
|
41
|
+
that ends the tour is never the one a thumb reaches for first */
|
|
42
|
+
.tui-tour__progress { margin-right: auto; }
|
|
43
|
+
.tui-tour__controls button { font: inherit; color: inherit; cursor: pointer; }
|
|
44
|
+
.tui-tour__controls button:disabled { cursor: default; opacity: 0.5; }
|
|
45
|
+
|
|
46
|
+
/* visually hidden, still read aloud */
|
|
47
|
+
.tui-tour__sr {
|
|
48
|
+
position: absolute;
|
|
49
|
+
width: 1px;
|
|
50
|
+
height: 1px;
|
|
51
|
+
margin: -1px;
|
|
52
|
+
padding: 0;
|
|
53
|
+
overflow: hidden;
|
|
54
|
+
clip-path: inset(50%);
|
|
55
|
+
white-space: nowrap;
|
|
56
|
+
border: 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@media (prefers-reduced-motion: reduce) {
|
|
60
|
+
.tui-tour__hole { transition: none; }
|
|
61
|
+
}
|