@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 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
- # Temporary Holding Version
1
+ # @tapestry-ui/tour
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }