@paul-portfolio/react 0.6.0 → 0.7.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/dist/GuidedTour.d.ts +36 -0
- package/dist/GuidedTour.js +123 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export type GuidedTourStep = {
|
|
3
|
+
/** Id of the element to spotlight. Omit for a centred card (an intro step). */
|
|
4
|
+
target?: string;
|
|
5
|
+
title: string;
|
|
6
|
+
body: ReactNode;
|
|
7
|
+
};
|
|
8
|
+
type GuidedTourLabels = {
|
|
9
|
+
back?: string;
|
|
10
|
+
next?: string;
|
|
11
|
+
skip?: string;
|
|
12
|
+
finish?: string;
|
|
13
|
+
};
|
|
14
|
+
type GuidedTourProps = {
|
|
15
|
+
open: boolean;
|
|
16
|
+
steps: GuidedTourStep[];
|
|
17
|
+
/** Skipped, dismissed (Escape), or finished — the tour should close. */
|
|
18
|
+
onClose: () => void;
|
|
19
|
+
/** Reached the end and pressed the finish control. Fires before `onClose`. */
|
|
20
|
+
onFinish?: () => void;
|
|
21
|
+
/** Accessible name for the dialog. */
|
|
22
|
+
'aria-label'?: string;
|
|
23
|
+
/** Override the control labels. */
|
|
24
|
+
labels?: GuidedTourLabels;
|
|
25
|
+
className?: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* A click-through guided tour. It walks `steps` one at a time, dimming the page
|
|
29
|
+
* and spotlighting each step's target element (measured live, so it tracks
|
|
30
|
+
* scroll and resize), with a card that pins near the target or centres when a
|
|
31
|
+
* step has none. Portals to the body, traps focus, and closes on Escape — the
|
|
32
|
+
* same shell as Modal. Purely presentational: the host owns `open` and decides
|
|
33
|
+
* what a finish or skip does.
|
|
34
|
+
*/
|
|
35
|
+
export declare function GuidedTour({ open, steps, onClose, onFinish, 'aria-label': ariaLabel, labels, className, }: GuidedTourProps): import("react").ReactPortal | null;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState, } from 'react';
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
import { cx } from './cx';
|
|
5
|
+
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
6
|
+
/** Keep the card on screen: pin it under the spotlight, or centre it. */
|
|
7
|
+
function cardStyle(rect) {
|
|
8
|
+
if (!rect || typeof window === 'undefined') {
|
|
9
|
+
return { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
position: 'fixed',
|
|
13
|
+
top: Math.min(rect.bottom + 12, window.innerHeight - 220),
|
|
14
|
+
left: Math.max(12, Math.min(rect.left, window.innerWidth - 340)),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A click-through guided tour. It walks `steps` one at a time, dimming the page
|
|
19
|
+
* and spotlighting each step's target element (measured live, so it tracks
|
|
20
|
+
* scroll and resize), with a card that pins near the target or centres when a
|
|
21
|
+
* step has none. Portals to the body, traps focus, and closes on Escape — the
|
|
22
|
+
* same shell as Modal. Purely presentational: the host owns `open` and decides
|
|
23
|
+
* what a finish or skip does.
|
|
24
|
+
*/
|
|
25
|
+
export function GuidedTour({ open, steps, onClose, onFinish, 'aria-label': ariaLabel = 'Guided tour', labels, className, }) {
|
|
26
|
+
const [index, setIndex] = useState(0);
|
|
27
|
+
const [rect, setRect] = useState(null);
|
|
28
|
+
const cardRef = useRef(null);
|
|
29
|
+
const current = steps[index];
|
|
30
|
+
const isLast = index === steps.length - 1;
|
|
31
|
+
// Start each opening at the first step. Deferred so the effect never sets
|
|
32
|
+
// state synchronously (the codebase's rule for motion-driven components).
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!open)
|
|
35
|
+
return;
|
|
36
|
+
const raf = requestAnimationFrame(() => setIndex(0));
|
|
37
|
+
return () => cancelAnimationFrame(raf);
|
|
38
|
+
}, [open]);
|
|
39
|
+
// Measure the current target and keep the spotlight on it through scroll and
|
|
40
|
+
// resize. Non-target steps (and a closed tour) clear the rect.
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!open)
|
|
43
|
+
return;
|
|
44
|
+
const target = current?.target;
|
|
45
|
+
const measure = () => {
|
|
46
|
+
const el = target ? document.getElementById(target) : null;
|
|
47
|
+
if (el) {
|
|
48
|
+
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
49
|
+
setRect(el.getBoundingClientRect());
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
setRect(null);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const raf = requestAnimationFrame(measure);
|
|
56
|
+
window.addEventListener('resize', measure);
|
|
57
|
+
window.addEventListener('scroll', measure, true);
|
|
58
|
+
return () => {
|
|
59
|
+
cancelAnimationFrame(raf);
|
|
60
|
+
window.removeEventListener('resize', measure);
|
|
61
|
+
window.removeEventListener('scroll', measure, true);
|
|
62
|
+
};
|
|
63
|
+
}, [open, current?.target]);
|
|
64
|
+
// Focus the card on open, restore focus on close, and trap Tab + handle
|
|
65
|
+
// Escape while it's up.
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!open)
|
|
68
|
+
return;
|
|
69
|
+
const previouslyFocused = document.activeElement;
|
|
70
|
+
const raf = requestAnimationFrame(() => cardRef.current?.focus());
|
|
71
|
+
function handleKey(e) {
|
|
72
|
+
if (e.key === 'Escape') {
|
|
73
|
+
onClose();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const card = cardRef.current;
|
|
77
|
+
if (e.key !== 'Tab' || !card)
|
|
78
|
+
return;
|
|
79
|
+
const focusables = Array.from(card.querySelectorAll(FOCUSABLE));
|
|
80
|
+
if (focusables.length === 0) {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const first = focusables[0];
|
|
85
|
+
const last = focusables[focusables.length - 1];
|
|
86
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
last.focus();
|
|
89
|
+
}
|
|
90
|
+
else if (!e.shiftKey && document.activeElement === last) {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
first.focus();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
document.addEventListener('keydown', handleKey);
|
|
96
|
+
return () => {
|
|
97
|
+
cancelAnimationFrame(raf);
|
|
98
|
+
document.removeEventListener('keydown', handleKey);
|
|
99
|
+
previouslyFocused?.focus?.();
|
|
100
|
+
};
|
|
101
|
+
}, [open, onClose]);
|
|
102
|
+
if (!open || !current)
|
|
103
|
+
return null;
|
|
104
|
+
const back = () => setIndex((i) => Math.max(0, i - 1));
|
|
105
|
+
const next = () => {
|
|
106
|
+
if (isLast) {
|
|
107
|
+
onFinish?.();
|
|
108
|
+
onClose();
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
setIndex((i) => i + 1);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const spotlight = rect
|
|
115
|
+
? {
|
|
116
|
+
top: rect.top - 6,
|
|
117
|
+
left: rect.left - 6,
|
|
118
|
+
width: rect.width + 12,
|
|
119
|
+
height: rect.height + 12,
|
|
120
|
+
}
|
|
121
|
+
: undefined;
|
|
122
|
+
return createPortal(_jsxs("div", { className: "tour", role: "presentation", children: [spotlight ? (_jsx("div", { className: "tour__spotlight", style: spotlight, "aria-hidden": true })) : (_jsx("div", { className: "tour__backdrop", "aria-hidden": true })), _jsxs("div", { ref: cardRef, role: "dialog", "aria-modal": "true", "aria-label": ariaLabel, tabIndex: -1, style: cardStyle(rect), className: cx('tour__card', className), children: [_jsxs("p", { className: "tour__step", children: ["Step ", index + 1, " of ", steps.length] }), _jsx("h2", { className: "tour__title", children: current.title }), _jsx("div", { className: "tour__body", children: current.body }), _jsxs("div", { className: "tour__actions", children: [_jsx("button", { type: "button", className: "tour__skip", onClick: onClose, children: labels?.skip ?? 'Skip' }), index > 0 && (_jsx("button", { type: "button", className: "tour__back", onClick: back, children: labels?.back ?? 'Back' })), _jsx("button", { type: "button", className: "tour__next", onClick: next, children: isLast ? labels?.finish ?? 'Finish' : labels?.next ?? 'Next' })] })] })] }), document.body);
|
|
123
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -42,5 +42,6 @@ export { Combobox, type ComboboxOption } from './Combobox';
|
|
|
42
42
|
export { ToastProvider, useToast, type ToastOptions, type ToastVariant, } from './Toast';
|
|
43
43
|
export { TokenUsageMeter } from './TokenUsageMeter';
|
|
44
44
|
export { VisuallyHidden } from './VisuallyHidden';
|
|
45
|
+
export { GuidedTour, type GuidedTourStep } from './GuidedTour';
|
|
45
46
|
export { usePrefersReducedMotion } from './usePrefersReducedMotion';
|
|
46
47
|
export { cx } from './cx';
|
package/dist/index.js
CHANGED
|
@@ -42,5 +42,6 @@ export { Combobox } from './Combobox';
|
|
|
42
42
|
export { ToastProvider, useToast, } from './Toast';
|
|
43
43
|
export { TokenUsageMeter } from './TokenUsageMeter';
|
|
44
44
|
export { VisuallyHidden } from './VisuallyHidden';
|
|
45
|
+
export { GuidedTour } from './GuidedTour';
|
|
45
46
|
export { usePrefersReducedMotion } from './usePrefersReducedMotion';
|
|
46
47
|
export { cx } from './cx';
|