@paul-portfolio/react 0.7.0 → 0.8.1
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 +7 -0
- package/dist/GuidedTour.js +19 -3
- package/dist/Modal.js +39 -9
- package/package.json +1 -1
package/dist/GuidedTour.d.ts
CHANGED
|
@@ -4,6 +4,13 @@ export type GuidedTourStep = {
|
|
|
4
4
|
target?: string;
|
|
5
5
|
title: string;
|
|
6
6
|
body: ReactNode;
|
|
7
|
+
/**
|
|
8
|
+
* Run when this step becomes active — switch a tab, scroll a panel into view,
|
|
9
|
+
* anything the step needs set up before its target is measured. Fired from the
|
|
10
|
+
* navigation handler, so a tab switch has rendered before the spotlight reads
|
|
11
|
+
* the target's box.
|
|
12
|
+
*/
|
|
13
|
+
onEnter?: () => void;
|
|
7
14
|
};
|
|
8
15
|
type GuidedTourLabels = {
|
|
9
16
|
back?: string;
|
package/dist/GuidedTour.js
CHANGED
|
@@ -36,6 +36,14 @@ export function GuidedTour({ open, steps, onClose, onFinish, 'aria-label': ariaL
|
|
|
36
36
|
const raf = requestAnimationFrame(() => setIndex(0));
|
|
37
37
|
return () => cancelAnimationFrame(raf);
|
|
38
38
|
}, [open]);
|
|
39
|
+
// Fire the first step's onEnter as the tour opens (later steps fire from the
|
|
40
|
+
// navigation handler). Runs the consumer's callback, never this component's
|
|
41
|
+
// own setState, so it's clear of the set-state-in-effect rule.
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (open)
|
|
44
|
+
steps[0]?.onEnter?.();
|
|
45
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
46
|
+
}, [open]);
|
|
39
47
|
// Measure the current target and keep the spotlight on it through scroll and
|
|
40
48
|
// resize. Non-target steps (and a closed tour) clear the rect.
|
|
41
49
|
useEffect(() => {
|
|
@@ -99,16 +107,24 @@ export function GuidedTour({ open, steps, onClose, onFinish, 'aria-label': ariaL
|
|
|
99
107
|
previouslyFocused?.focus?.();
|
|
100
108
|
};
|
|
101
109
|
}, [open, onClose]);
|
|
102
|
-
|
|
110
|
+
// No document on the server, and the overlay portals to it — render nothing
|
|
111
|
+
// there rather than reaching createPortal(document.body). A host that opens
|
|
112
|
+
// the tour during SSR then gets a clean no-op instead of a crash.
|
|
113
|
+
if (!open || !current || typeof document === 'undefined')
|
|
103
114
|
return null;
|
|
104
|
-
const
|
|
115
|
+
const go = (target) => {
|
|
116
|
+
const clamped = Math.max(0, Math.min(steps.length - 1, target));
|
|
117
|
+
steps[clamped]?.onEnter?.();
|
|
118
|
+
setIndex(clamped);
|
|
119
|
+
};
|
|
120
|
+
const back = () => go(index - 1);
|
|
105
121
|
const next = () => {
|
|
106
122
|
if (isLast) {
|
|
107
123
|
onFinish?.();
|
|
108
124
|
onClose();
|
|
109
125
|
}
|
|
110
126
|
else {
|
|
111
|
-
|
|
127
|
+
go(index + 1);
|
|
112
128
|
}
|
|
113
129
|
};
|
|
114
130
|
const spotlight = rect
|
package/dist/Modal.js
CHANGED
|
@@ -15,28 +15,54 @@ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), selec
|
|
|
15
15
|
export function Modal({ open, onClose, title, className, children, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-describedby': ariaDescribedby, }) {
|
|
16
16
|
const titleId = useId();
|
|
17
17
|
const dialogRef = useRef(null);
|
|
18
|
-
//
|
|
19
|
-
//
|
|
18
|
+
// Read the latest onClose from a ref so the keydown listener below never has
|
|
19
|
+
// to be torn down and re-added when onClose changes identity.
|
|
20
|
+
const onCloseRef = useRef(onClose);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
onCloseRef.current = onClose;
|
|
23
|
+
}, [onClose]);
|
|
24
|
+
// On open: focus into the dialog, trap Tab, lock body scroll, and hide the
|
|
25
|
+
// rest of the page from assistive tech. All of it is undone on close. The
|
|
26
|
+
// effect depends only on `open` — never on onClose — so a parent re-render
|
|
27
|
+
// (e.g. a polling query) can't re-run it and steal focus from an input.
|
|
20
28
|
useEffect(() => {
|
|
21
29
|
if (!open)
|
|
22
30
|
return;
|
|
23
31
|
const previouslyFocused = document.activeElement;
|
|
24
32
|
const dialog = dialogRef.current;
|
|
25
|
-
|
|
33
|
+
// Lock body scroll, padding out the scrollbar's width so the page behind
|
|
34
|
+
// doesn't shift as it disappears.
|
|
35
|
+
const originalOverflow = document.body.style.overflow;
|
|
36
|
+
const originalPaddingRight = document.body.style.paddingRight;
|
|
37
|
+
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
|
38
|
+
document.body.style.overflow = 'hidden';
|
|
39
|
+
if (scrollbarWidth > 0) {
|
|
40
|
+
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
|
41
|
+
}
|
|
42
|
+
// Hide the background from assistive tech, without clobbering an
|
|
43
|
+
// aria-hidden that was already there.
|
|
44
|
+
const hidden = Array.from(document.body.children).filter((el) => el !== dialog && !el.contains(dialog) && !el.hasAttribute('aria-hidden'));
|
|
45
|
+
for (const el of hidden)
|
|
46
|
+
el.setAttribute('aria-hidden', 'true');
|
|
47
|
+
// Focus the first focusable element, or the dialog itself if it has none.
|
|
48
|
+
const focusables = dialog
|
|
49
|
+
? Array.from(dialog.querySelectorAll(FOCUSABLE))
|
|
50
|
+
: [];
|
|
51
|
+
(focusables[0] ?? dialog)?.focus();
|
|
26
52
|
function handleKey(e) {
|
|
27
53
|
if (e.key === 'Escape') {
|
|
28
|
-
|
|
54
|
+
onCloseRef.current();
|
|
29
55
|
return;
|
|
30
56
|
}
|
|
31
57
|
if (e.key !== 'Tab' || !dialog)
|
|
32
58
|
return;
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
59
|
+
const tabbable = Array.from(dialog.querySelectorAll(FOCUSABLE));
|
|
60
|
+
if (tabbable.length === 0) {
|
|
35
61
|
e.preventDefault();
|
|
36
62
|
return;
|
|
37
63
|
}
|
|
38
|
-
const first =
|
|
39
|
-
const last =
|
|
64
|
+
const first = tabbable[0];
|
|
65
|
+
const last = tabbable[tabbable.length - 1];
|
|
40
66
|
if (e.shiftKey && document.activeElement === first) {
|
|
41
67
|
e.preventDefault();
|
|
42
68
|
last.focus();
|
|
@@ -49,9 +75,13 @@ export function Modal({ open, onClose, title, className, children, 'aria-label':
|
|
|
49
75
|
document.addEventListener('keydown', handleKey);
|
|
50
76
|
return () => {
|
|
51
77
|
document.removeEventListener('keydown', handleKey);
|
|
78
|
+
document.body.style.overflow = originalOverflow;
|
|
79
|
+
document.body.style.paddingRight = originalPaddingRight;
|
|
80
|
+
for (const el of hidden)
|
|
81
|
+
el.removeAttribute('aria-hidden');
|
|
52
82
|
previouslyFocused?.focus?.();
|
|
53
83
|
};
|
|
54
|
-
}, [open
|
|
84
|
+
}, [open]);
|
|
55
85
|
if (!open)
|
|
56
86
|
return null;
|
|
57
87
|
const labelledby = ariaLabelledby ?? (title ? titleId : undefined);
|