@paul-portfolio/react 0.6.0 → 0.8.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.
@@ -0,0 +1,43 @@
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
+ * 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;
14
+ };
15
+ type GuidedTourLabels = {
16
+ back?: string;
17
+ next?: string;
18
+ skip?: string;
19
+ finish?: string;
20
+ };
21
+ type GuidedTourProps = {
22
+ open: boolean;
23
+ steps: GuidedTourStep[];
24
+ /** Skipped, dismissed (Escape), or finished — the tour should close. */
25
+ onClose: () => void;
26
+ /** Reached the end and pressed the finish control. Fires before `onClose`. */
27
+ onFinish?: () => void;
28
+ /** Accessible name for the dialog. */
29
+ 'aria-label'?: string;
30
+ /** Override the control labels. */
31
+ labels?: GuidedTourLabels;
32
+ className?: string;
33
+ };
34
+ /**
35
+ * A click-through guided tour. It walks `steps` one at a time, dimming the page
36
+ * and spotlighting each step's target element (measured live, so it tracks
37
+ * scroll and resize), with a card that pins near the target or centres when a
38
+ * step has none. Portals to the body, traps focus, and closes on Escape — the
39
+ * same shell as Modal. Purely presentational: the host owns `open` and decides
40
+ * what a finish or skip does.
41
+ */
42
+ export declare function GuidedTour({ open, steps, onClose, onFinish, 'aria-label': ariaLabel, labels, className, }: GuidedTourProps): import("react").ReactPortal | null;
43
+ export {};
@@ -0,0 +1,136 @@
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
+ // 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]);
47
+ // Measure the current target and keep the spotlight on it through scroll and
48
+ // resize. Non-target steps (and a closed tour) clear the rect.
49
+ useEffect(() => {
50
+ if (!open)
51
+ return;
52
+ const target = current?.target;
53
+ const measure = () => {
54
+ const el = target ? document.getElementById(target) : null;
55
+ if (el) {
56
+ el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
57
+ setRect(el.getBoundingClientRect());
58
+ }
59
+ else {
60
+ setRect(null);
61
+ }
62
+ };
63
+ const raf = requestAnimationFrame(measure);
64
+ window.addEventListener('resize', measure);
65
+ window.addEventListener('scroll', measure, true);
66
+ return () => {
67
+ cancelAnimationFrame(raf);
68
+ window.removeEventListener('resize', measure);
69
+ window.removeEventListener('scroll', measure, true);
70
+ };
71
+ }, [open, current?.target]);
72
+ // Focus the card on open, restore focus on close, and trap Tab + handle
73
+ // Escape while it's up.
74
+ useEffect(() => {
75
+ if (!open)
76
+ return;
77
+ const previouslyFocused = document.activeElement;
78
+ const raf = requestAnimationFrame(() => cardRef.current?.focus());
79
+ function handleKey(e) {
80
+ if (e.key === 'Escape') {
81
+ onClose();
82
+ return;
83
+ }
84
+ const card = cardRef.current;
85
+ if (e.key !== 'Tab' || !card)
86
+ return;
87
+ const focusables = Array.from(card.querySelectorAll(FOCUSABLE));
88
+ if (focusables.length === 0) {
89
+ e.preventDefault();
90
+ return;
91
+ }
92
+ const first = focusables[0];
93
+ const last = focusables[focusables.length - 1];
94
+ if (e.shiftKey && document.activeElement === first) {
95
+ e.preventDefault();
96
+ last.focus();
97
+ }
98
+ else if (!e.shiftKey && document.activeElement === last) {
99
+ e.preventDefault();
100
+ first.focus();
101
+ }
102
+ }
103
+ document.addEventListener('keydown', handleKey);
104
+ return () => {
105
+ cancelAnimationFrame(raf);
106
+ document.removeEventListener('keydown', handleKey);
107
+ previouslyFocused?.focus?.();
108
+ };
109
+ }, [open, onClose]);
110
+ if (!open || !current)
111
+ return null;
112
+ const go = (target) => {
113
+ const clamped = Math.max(0, Math.min(steps.length - 1, target));
114
+ steps[clamped]?.onEnter?.();
115
+ setIndex(clamped);
116
+ };
117
+ const back = () => go(index - 1);
118
+ const next = () => {
119
+ if (isLast) {
120
+ onFinish?.();
121
+ onClose();
122
+ }
123
+ else {
124
+ go(index + 1);
125
+ }
126
+ };
127
+ const spotlight = rect
128
+ ? {
129
+ top: rect.top - 6,
130
+ left: rect.left - 6,
131
+ width: rect.width + 12,
132
+ height: rect.height + 12,
133
+ }
134
+ : undefined;
135
+ 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);
136
+ }
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
- // Move focus into the dialog on open and restore it on close, and trap Tab
19
- // so keyboard focus can't wander behind the modal.
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
- dialog?.focus();
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
- onClose();
54
+ onCloseRef.current();
29
55
  return;
30
56
  }
31
57
  if (e.key !== 'Tab' || !dialog)
32
58
  return;
33
- const focusables = Array.from(dialog.querySelectorAll(FOCUSABLE));
34
- if (focusables.length === 0) {
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 = focusables[0];
39
- const last = focusables[focusables.length - 1];
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, onClose]);
84
+ }, [open]);
55
85
  if (!open)
56
86
  return null;
57
87
  const labelledby = ariaLabelledby ?? (title ? titleId : undefined);
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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paul-portfolio/react",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "React components for the Paul Design System",
5
5
  "license": "MIT",
6
6
  "repository": {