@astryxdesign/lab 0.3.0-canary.b8cfd19 → 0.3.0-canary.fbc60c5
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/Tour/Tour.d.ts +85 -0
- package/dist/Tour/Tour.d.ts.map +1 -0
- package/dist/Tour/Tour.js +112 -0
- package/dist/Tour/TourContext.d.ts +38 -0
- package/dist/Tour/TourContext.d.ts.map +1 -0
- package/dist/Tour/TourContext.js +36 -0
- package/dist/Tour/TourStep.d.ts +77 -0
- package/dist/Tour/TourStep.d.ts.map +1 -0
- package/dist/Tour/TourStep.js +307 -0
- package/dist/Tour/index.d.ts +16 -0
- package/dist/Tour/index.d.ts.map +1 -0
- package/dist/Tour/index.js +15 -0
- package/dist/Tour/useTour.d.ts +27 -0
- package/dist/Tour/useTour.d.ts.map +1 -0
- package/dist/Tour/useTour.js +41 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/lab.css +9 -0
- package/package.json +3 -3
- package/src/Tour/Tour.doc.mjs +106 -0
- package/src/Tour/Tour.test.tsx +261 -0
- package/src/Tour/Tour.tsx +171 -0
- package/src/Tour/TourContext.ts +59 -0
- package/src/Tour/TourStep.tsx +398 -0
- package/src/Tour/index.ts +23 -0
- package/src/Tour/useTour.ts +58 -0
- package/src/index.ts +11 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Tour.tsx
|
|
3
|
+
* @input Uses React state/refs, TourContext
|
|
4
|
+
* @output Exports Tour controller component and TourProps
|
|
5
|
+
* @position Lab experiment (facebook/astryx#4239); controller consumed by index.ts
|
|
6
|
+
*
|
|
7
|
+
* Tour is the controller for a product-tour / NUX walkthrough. It renders no
|
|
8
|
+
* visible chrome of its own — it owns the tour state (active step, advance /
|
|
9
|
+
* retreat / complete / dismiss) and shares it with declaratively-nested
|
|
10
|
+
* `<TourStep>` children through context. Steps register on mount, so the step
|
|
11
|
+
* ORDER is taken from the children in document order (no step array to keep in
|
|
12
|
+
* sync with the markup).
|
|
13
|
+
*
|
|
14
|
+
* The behavior (a controller plus spotlight feature steps, an `isActive`
|
|
15
|
+
* switch, dismiss-with-reason, and step progress) is composed from Astryx
|
|
16
|
+
* primitives. Each step anchors its callout via Popover and draws its own
|
|
17
|
+
* highlight/backdrop (see TourStep) — the controller owns no chrome.
|
|
18
|
+
*
|
|
19
|
+
* SYNC: When modified, update these files to stay in sync:
|
|
20
|
+
* - /packages/lab/src/Tour/TourStep.tsx
|
|
21
|
+
* - /packages/lab/src/Tour/TourContext.ts
|
|
22
|
+
* - /packages/lab/src/Tour/useTour.ts
|
|
23
|
+
* - /packages/lab/src/Tour/Tour.doc.mjs (props table, features)
|
|
24
|
+
* - /packages/lab/src/Tour/Tour.test.tsx (tests for new/changed behavior)
|
|
25
|
+
* - /packages/lab/src/Tour/index.ts (exports if types change)
|
|
26
|
+
*/
|
|
27
|
+
import { type ReactNode } from 'react';
|
|
28
|
+
import { type TourDismissSource } from './TourContext';
|
|
29
|
+
export interface TourProps {
|
|
30
|
+
/**
|
|
31
|
+
* Whether the tour is running. When false, nothing renders and step state
|
|
32
|
+
* resets — the tour restarts from the first step next time it becomes active.
|
|
33
|
+
* (Controlled: the consumer owns "has this user seen the tour?".)
|
|
34
|
+
*/
|
|
35
|
+
isActive: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* The tour's steps — `<TourStep>` elements. Step order is taken from their
|
|
38
|
+
* order here; only the active step renders its callout.
|
|
39
|
+
*/
|
|
40
|
+
children?: ReactNode;
|
|
41
|
+
/**
|
|
42
|
+
* Called when the tour is dismissed, with the reason. Fires for every exit,
|
|
43
|
+
* including completing the last step (`source === 'complete'`). The consumer
|
|
44
|
+
* flips `isActive` to false in response, and can branch on the source to tell
|
|
45
|
+
* a successful finish apart from an early skip/backdrop/escape.
|
|
46
|
+
*/
|
|
47
|
+
onDismiss: (source: TourDismissSource) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Dim the page around the active step's target (a spotlight cutout — the
|
|
50
|
+
* target stays lit, everything else darkens). Use for modal-style steps that
|
|
51
|
+
* demand focus; leave off for a lightweight coachmark that only rings the
|
|
52
|
+
* target.
|
|
53
|
+
* @default false
|
|
54
|
+
*/
|
|
55
|
+
hasBackdrop?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Show the step count ("2 of 5") in each step.
|
|
58
|
+
* @default false
|
|
59
|
+
*/
|
|
60
|
+
isStepCountShown?: boolean;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Controller for a guided product tour. Renders no chrome; coordinates the
|
|
64
|
+
* active step among its `<TourStep>` children.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```
|
|
68
|
+
* const [isActive, setIsActive] = useState(true);
|
|
69
|
+
* const saveRef = useRef(null);
|
|
70
|
+
* const shareRef = useRef(null);
|
|
71
|
+
* <Tour isActive={isActive} hasBackdrop isStepCountShown onDismiss={() => setIsActive(false)}>
|
|
72
|
+
* <TourStep targetRef={saveRef} heading="Save your work">
|
|
73
|
+
* Changes save automatically to the cloud.
|
|
74
|
+
* </TourStep>
|
|
75
|
+
* <TourStep targetRef={shareRef} heading="Share it">
|
|
76
|
+
* Invite teammates from here.
|
|
77
|
+
* </TourStep>
|
|
78
|
+
* </Tour>
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export declare function Tour({ isActive, children, onDismiss, hasBackdrop, isStepCountShown, }: TourProps): import("react").JSX.Element;
|
|
82
|
+
export declare namespace Tour {
|
|
83
|
+
var displayName: string;
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=Tour.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Tour.d.ts","sourceRoot":"","sources":["../../src/Tour/Tour.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,EAA4C,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAChF,OAAO,EAEL,KAAK,iBAAiB,EAEvB,MAAM,eAAe,CAAC;AAEvB,MAAM,WAAW,SAAS;IACxB;;;;OAIG;IACH,QAAQ,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB;;;;;OAKG;IACH,SAAS,EAAE,CAAC,MAAM,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAC/C;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,IAAI,CAAC,EACnB,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,WAAmB,EACnB,gBAAwB,GACzB,EAAE,SAAS,+BAuEX;yBA7Ee,IAAI"}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file Tour.tsx
|
|
7
|
+
* @input Uses React state/refs, TourContext
|
|
8
|
+
* @output Exports Tour controller component and TourProps
|
|
9
|
+
* @position Lab experiment (facebook/astryx#4239); controller consumed by index.ts
|
|
10
|
+
*
|
|
11
|
+
* Tour is the controller for a product-tour / NUX walkthrough. It renders no
|
|
12
|
+
* visible chrome of its own — it owns the tour state (active step, advance /
|
|
13
|
+
* retreat / complete / dismiss) and shares it with declaratively-nested
|
|
14
|
+
* `<TourStep>` children through context. Steps register on mount, so the step
|
|
15
|
+
* ORDER is taken from the children in document order (no step array to keep in
|
|
16
|
+
* sync with the markup).
|
|
17
|
+
*
|
|
18
|
+
* The behavior (a controller plus spotlight feature steps, an `isActive`
|
|
19
|
+
* switch, dismiss-with-reason, and step progress) is composed from Astryx
|
|
20
|
+
* primitives. Each step anchors its callout via Popover and draws its own
|
|
21
|
+
* highlight/backdrop (see TourStep) — the controller owns no chrome.
|
|
22
|
+
*
|
|
23
|
+
* SYNC: When modified, update these files to stay in sync:
|
|
24
|
+
* - /packages/lab/src/Tour/TourStep.tsx
|
|
25
|
+
* - /packages/lab/src/Tour/TourContext.ts
|
|
26
|
+
* - /packages/lab/src/Tour/useTour.ts
|
|
27
|
+
* - /packages/lab/src/Tour/Tour.doc.mjs (props table, features)
|
|
28
|
+
* - /packages/lab/src/Tour/Tour.test.tsx (tests for new/changed behavior)
|
|
29
|
+
* - /packages/lab/src/Tour/index.ts (exports if types change)
|
|
30
|
+
*/
|
|
31
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
32
|
+
import { TourContext } from "./TourContext.js";
|
|
33
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
34
|
+
/**
|
|
35
|
+
* Controller for a guided product tour. Renders no chrome; coordinates the
|
|
36
|
+
* active step among its `<TourStep>` children.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```
|
|
40
|
+
* const [isActive, setIsActive] = useState(true);
|
|
41
|
+
* const saveRef = useRef(null);
|
|
42
|
+
* const shareRef = useRef(null);
|
|
43
|
+
* <Tour isActive={isActive} hasBackdrop isStepCountShown onDismiss={() => setIsActive(false)}>
|
|
44
|
+
* <TourStep targetRef={saveRef} heading="Save your work">
|
|
45
|
+
* Changes save automatically to the cloud.
|
|
46
|
+
* </TourStep>
|
|
47
|
+
* <TourStep targetRef={shareRef} heading="Share it">
|
|
48
|
+
* Invite teammates from here.
|
|
49
|
+
* </TourStep>
|
|
50
|
+
* </Tour>
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function Tour({
|
|
54
|
+
isActive,
|
|
55
|
+
children,
|
|
56
|
+
onDismiss,
|
|
57
|
+
hasBackdrop = false,
|
|
58
|
+
isStepCountShown = false
|
|
59
|
+
}) {
|
|
60
|
+
// Steps register on mount; insertion order (document order) defines the
|
|
61
|
+
// sequence. A plain array keeps registration order deterministic.
|
|
62
|
+
const [stepIds, setStepIds] = useState([]);
|
|
63
|
+
const [activeStepIndex, setActiveStepIndex] = useState(0);
|
|
64
|
+
const registerStep = useCallback(id => {
|
|
65
|
+
setStepIds(prev => prev.includes(id) ? prev : [...prev, id]);
|
|
66
|
+
return () => {
|
|
67
|
+
setStepIds(prev => prev.filter(stepId => stepId !== id));
|
|
68
|
+
};
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
// Reset to the first step whenever the tour is (re)started, so a dismissed
|
|
72
|
+
// tour begins from the top next time isActive flips back on.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!isActive) {
|
|
75
|
+
setActiveStepIndex(0);
|
|
76
|
+
}
|
|
77
|
+
}, [isActive]);
|
|
78
|
+
const stepCount = stepIds.length;
|
|
79
|
+
const onNext = useCallback(() => {
|
|
80
|
+
// Runs from a click/imperative call (never during render), so branch on the
|
|
81
|
+
// current index directly rather than inside a state updater — calling
|
|
82
|
+
// onDismiss from within setState triggers a "setState while rendering"
|
|
83
|
+
// warning.
|
|
84
|
+
if (activeStepIndex < stepCount - 1) {
|
|
85
|
+
setActiveStepIndex(activeStepIndex + 1);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Past the last step → complete. onDismiss carries the reason so a
|
|
89
|
+
// consumer can branch on 'complete' vs an early exit.
|
|
90
|
+
onDismiss('complete');
|
|
91
|
+
}, [activeStepIndex, stepCount, onDismiss]);
|
|
92
|
+
const onPrevious = useCallback(() => {
|
|
93
|
+
setActiveStepIndex(prev => prev > 0 ? prev - 1 : prev);
|
|
94
|
+
}, []);
|
|
95
|
+
const activeStepId = isActive ? stepIds[activeStepIndex] ?? null : null;
|
|
96
|
+
const contextValue = useMemo(() => ({
|
|
97
|
+
registerStep,
|
|
98
|
+
activeStepId,
|
|
99
|
+
activeStepIndex,
|
|
100
|
+
stepCount,
|
|
101
|
+
isStepCountShown,
|
|
102
|
+
hasBackdrop,
|
|
103
|
+
onNext,
|
|
104
|
+
onPrevious,
|
|
105
|
+
onDismiss
|
|
106
|
+
}), [registerStep, activeStepId, activeStepIndex, stepCount, isStepCountShown, hasBackdrop, onNext, onPrevious, onDismiss]);
|
|
107
|
+
return /*#__PURE__*/_jsx(TourContext.Provider, {
|
|
108
|
+
value: contextValue,
|
|
109
|
+
children: children
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
Tour.displayName = 'Tour';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a tour was dismissed. Lets a consumer distinguish "finished the whole
|
|
3
|
+
* tour" from the various early exits, so it can persist "has seen this tour"
|
|
4
|
+
* appropriately.
|
|
5
|
+
* - `backdrop`: clicked the dimmed background
|
|
6
|
+
* - `escape`: pressed Escape at any step
|
|
7
|
+
* - `close`: pressed the step's close (X) control
|
|
8
|
+
* - `skip`: chose "skip"/"maybe later" on the first step
|
|
9
|
+
* - `complete`: advanced past the final step
|
|
10
|
+
*/
|
|
11
|
+
export type TourDismissSource = 'backdrop' | 'escape' | 'close' | 'skip' | 'complete';
|
|
12
|
+
/**
|
|
13
|
+
* Value shared from the Tour controller to its steps. Steps register
|
|
14
|
+
* themselves on mount (so the controller learns the step order from the
|
|
15
|
+
* children, in document order) and read whether they are the active step.
|
|
16
|
+
*/
|
|
17
|
+
export interface TourContextValue {
|
|
18
|
+
/** Register a step by its stable id; returns an unregister cleanup. */
|
|
19
|
+
registerStep: (id: string) => () => void;
|
|
20
|
+
/** The id of the step that is currently active, or null when none. */
|
|
21
|
+
activeStepId: string | null;
|
|
22
|
+
/** Zero-based index of the active step among registered steps. */
|
|
23
|
+
activeStepIndex: number;
|
|
24
|
+
/** Total number of registered steps. */
|
|
25
|
+
stepCount: number;
|
|
26
|
+
/** Whether the tour's step count should be shown in each step. */
|
|
27
|
+
isStepCountShown: boolean;
|
|
28
|
+
/** Whether a dimmed background is shown behind the active step. */
|
|
29
|
+
hasBackdrop: boolean;
|
|
30
|
+
/** Advance to the next step (or complete on the last step). */
|
|
31
|
+
onNext: () => void;
|
|
32
|
+
/** Return to the previous step (no-op on the first step). */
|
|
33
|
+
onPrevious: () => void;
|
|
34
|
+
/** Dismiss the tour with a reason. */
|
|
35
|
+
onDismiss: (source: TourDismissSource) => void;
|
|
36
|
+
}
|
|
37
|
+
export declare const TourContext: import("react").Context<TourContextValue | null>;
|
|
38
|
+
//# sourceMappingURL=TourContext.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TourContext.d.ts","sourceRoot":"","sources":["../../src/Tour/TourContext.ts"],"names":[],"mappings":"AAmBA;;;;;;;;;GASG;AACH,MAAM,MAAM,iBAAiB,GAC3B,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,UAAU,CAAC;AAExD;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,uEAAuE;IACvE,YAAY,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,IAAI,CAAC;IACzC,sEAAsE;IACtE,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,kEAAkE;IAClE,eAAe,EAAE,MAAM,CAAC;IACxB,wCAAwC;IACxC,SAAS,EAAE,MAAM,CAAC;IAClB,kEAAkE;IAClE,gBAAgB,EAAE,OAAO,CAAC;IAC1B,mEAAmE;IACnE,WAAW,EAAE,OAAO,CAAC;IACrB,+DAA+D;IAC/D,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,6DAA6D;IAC7D,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,sCAAsC;IACtC,SAAS,EAAE,CAAC,MAAM,EAAE,iBAAiB,KAAK,IAAI,CAAC;CAChD;AAED,eAAO,MAAM,WAAW,kDAA+C,CAAC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file TourContext.ts
|
|
7
|
+
* @input Uses React createContext
|
|
8
|
+
* @output Exports TourContext, TourContextValue, TourDismissSource
|
|
9
|
+
* @position Internal context; provided by Tour, consumed by TourStep + useTour
|
|
10
|
+
*
|
|
11
|
+
* SYNC: When modified, update these files to stay in sync:
|
|
12
|
+
* - /packages/lab/src/Tour/Tour.tsx
|
|
13
|
+
* - /packages/lab/src/Tour/TourStep.tsx
|
|
14
|
+
* - /packages/lab/src/Tour/useTour.ts
|
|
15
|
+
* - /packages/lab/src/Tour/Tour.doc.mjs
|
|
16
|
+
*/
|
|
17
|
+
import { createContext } from 'react';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Why a tour was dismissed. Lets a consumer distinguish "finished the whole
|
|
21
|
+
* tour" from the various early exits, so it can persist "has seen this tour"
|
|
22
|
+
* appropriately.
|
|
23
|
+
* - `backdrop`: clicked the dimmed background
|
|
24
|
+
* - `escape`: pressed Escape at any step
|
|
25
|
+
* - `close`: pressed the step's close (X) control
|
|
26
|
+
* - `skip`: chose "skip"/"maybe later" on the first step
|
|
27
|
+
* - `complete`: advanced past the final step
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Value shared from the Tour controller to its steps. Steps register
|
|
32
|
+
* themselves on mount (so the controller learns the step order from the
|
|
33
|
+
* children, in document order) and read whether they are the active step.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export const TourContext = /*#__PURE__*/createContext(null);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file TourStep.tsx
|
|
3
|
+
* @input Uses React, Popover/Button/Text/Heading/Stack from @astryxdesign/core, TourContext
|
|
4
|
+
* @output Exports TourStep component and TourStepProps
|
|
5
|
+
* @position Lab experiment (facebook/astryx#4239); a single spotlight step in a Tour
|
|
6
|
+
*
|
|
7
|
+
* A TourStep highlights a target element and renders a callout anchored to it,
|
|
8
|
+
* with a heading, body, optional step progress ("2 of 5"), and back / next /
|
|
9
|
+
* close controls. It registers with the parent `<Tour>` on mount (so step
|
|
10
|
+
* order follows the children) and only renders its callout while it is the
|
|
11
|
+
* active step. Anchoring + the callout surface reuse the core `Popover`
|
|
12
|
+
* (`anchorRef` → the step's target), so positioning, top-layer rendering, and
|
|
13
|
+
* dismiss semantics come from the existing layer system rather than a bespoke
|
|
14
|
+
* implementation.
|
|
15
|
+
*
|
|
16
|
+
* The highlight is drawn as a separate overlay tracking the target's box — the
|
|
17
|
+
* consumer's element is never restyled, so there is no cascade fight with the
|
|
18
|
+
* target's own styles. That overlay is promoted into the browser TOP LAYER
|
|
19
|
+
* (via the popover API, like the rest of the layer system) so it sits above
|
|
20
|
+
* page content without any hardcoded z-index; the callout is promoted after
|
|
21
|
+
* it, so the callout stays above the highlight. Because it is promoted in
|
|
22
|
+
* place (not portaled out of the tree), it stays inside the consumer's Theme
|
|
23
|
+
* subtree and inherits theme tokens — including a scoped/nested theme's accent
|
|
24
|
+
* for the ring. Dimming is an opt-in spotlight cutout (dims around the target,
|
|
25
|
+
* not over it) rather than a flat scrim.
|
|
26
|
+
*
|
|
27
|
+
* SYNC: When modified, update these files to stay in sync:
|
|
28
|
+
* - /packages/lab/src/Tour/Tour.tsx
|
|
29
|
+
* - /packages/lab/src/Tour/TourContext.ts
|
|
30
|
+
* - /packages/lab/src/Tour/Tour.doc.mjs (props table, features)
|
|
31
|
+
* - /packages/lab/src/Tour/Tour.test.tsx (tests for new/changed behavior)
|
|
32
|
+
* - /packages/lab/src/Tour/index.ts (exports if types change)
|
|
33
|
+
*/
|
|
34
|
+
import { type ReactNode } from 'react';
|
|
35
|
+
import type { LayerAlignment, LayerPlacement } from '@astryxdesign/core/Layer';
|
|
36
|
+
export interface TourStepProps {
|
|
37
|
+
/**
|
|
38
|
+
* Ref to the element this step points at. The callout anchors to it (like a
|
|
39
|
+
* Popover trigger); it must be a `<button>` or `[role="button"]` element,
|
|
40
|
+
* matching Popover's `anchorRef` contract. Accepts a ref to any HTMLElement
|
|
41
|
+
* subtype (e.g. `useRef<HTMLButtonElement>(null)`).
|
|
42
|
+
*/
|
|
43
|
+
targetRef: React.RefObject<HTMLElement | null>;
|
|
44
|
+
/** Step heading. */
|
|
45
|
+
heading: ReactNode;
|
|
46
|
+
/** Step body content. */
|
|
47
|
+
children?: ReactNode;
|
|
48
|
+
/**
|
|
49
|
+
* Which side of the target the callout sits on.
|
|
50
|
+
* @default 'below'
|
|
51
|
+
*/
|
|
52
|
+
placement?: LayerPlacement;
|
|
53
|
+
/**
|
|
54
|
+
* How the callout aligns along the placement side — e.g. with `placement="below"`,
|
|
55
|
+
* `start` left-aligns it under the target, `center` centers it, `end` right-aligns it.
|
|
56
|
+
* @default 'start'
|
|
57
|
+
*/
|
|
58
|
+
alignment?: LayerAlignment;
|
|
59
|
+
/** Test id applied to the callout content. */
|
|
60
|
+
'data-testid'?: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A single spotlight step within a `<Tour>`. Renders its callout only while
|
|
64
|
+
* active.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```
|
|
68
|
+
* <TourStep targetRef={saveRef} heading="Save your work">
|
|
69
|
+
* Changes save automatically.
|
|
70
|
+
* </TourStep>
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export declare function TourStep({ targetRef, heading, children, placement, alignment, 'data-testid': testId, }: TourStepProps): import("react").JSX.Element | null;
|
|
74
|
+
export declare namespace TourStep {
|
|
75
|
+
var displayName: string;
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=TourStep.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TourStep.d.ts","sourceRoot":"","sources":["../../src/Tour/TourStep.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAGf,OAAO,KAAK,EAAC,cAAc,EAAE,cAAc,EAAC,MAAM,0BAA0B,CAAC;AAmK7E,MAAM,WAAW,aAAa;IAC5B;;;;;OAKG;IACH,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAC/C,oBAAoB;IACpB,OAAO,EAAE,SAAS,CAAC;IACnB,yBAAyB;IACzB,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB;;;OAGG;IACH,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B;;;;OAIG;IACH,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B,8CAA8C;IAC9C,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,QAAQ,CAAC,EACvB,SAAS,EACT,OAAO,EACP,QAAQ,EACR,SAAmB,EACnB,SAAmB,EACnB,aAAa,EAAE,MAAM,GACtB,EAAE,aAAa,sCA0If;yBAjJe,QAAQ"}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file TourStep.tsx
|
|
7
|
+
* @input Uses React, Popover/Button/Text/Heading/Stack from @astryxdesign/core, TourContext
|
|
8
|
+
* @output Exports TourStep component and TourStepProps
|
|
9
|
+
* @position Lab experiment (facebook/astryx#4239); a single spotlight step in a Tour
|
|
10
|
+
*
|
|
11
|
+
* A TourStep highlights a target element and renders a callout anchored to it,
|
|
12
|
+
* with a heading, body, optional step progress ("2 of 5"), and back / next /
|
|
13
|
+
* close controls. It registers with the parent `<Tour>` on mount (so step
|
|
14
|
+
* order follows the children) and only renders its callout while it is the
|
|
15
|
+
* active step. Anchoring + the callout surface reuse the core `Popover`
|
|
16
|
+
* (`anchorRef` → the step's target), so positioning, top-layer rendering, and
|
|
17
|
+
* dismiss semantics come from the existing layer system rather than a bespoke
|
|
18
|
+
* implementation.
|
|
19
|
+
*
|
|
20
|
+
* The highlight is drawn as a separate overlay tracking the target's box — the
|
|
21
|
+
* consumer's element is never restyled, so there is no cascade fight with the
|
|
22
|
+
* target's own styles. That overlay is promoted into the browser TOP LAYER
|
|
23
|
+
* (via the popover API, like the rest of the layer system) so it sits above
|
|
24
|
+
* page content without any hardcoded z-index; the callout is promoted after
|
|
25
|
+
* it, so the callout stays above the highlight. Because it is promoted in
|
|
26
|
+
* place (not portaled out of the tree), it stays inside the consumer's Theme
|
|
27
|
+
* subtree and inherits theme tokens — including a scoped/nested theme's accent
|
|
28
|
+
* for the ring. Dimming is an opt-in spotlight cutout (dims around the target,
|
|
29
|
+
* not over it) rather than a flat scrim.
|
|
30
|
+
*
|
|
31
|
+
* SYNC: When modified, update these files to stay in sync:
|
|
32
|
+
* - /packages/lab/src/Tour/Tour.tsx
|
|
33
|
+
* - /packages/lab/src/Tour/TourContext.ts
|
|
34
|
+
* - /packages/lab/src/Tour/Tour.doc.mjs (props table, features)
|
|
35
|
+
* - /packages/lab/src/Tour/Tour.test.tsx (tests for new/changed behavior)
|
|
36
|
+
* - /packages/lab/src/Tour/index.ts (exports if types change)
|
|
37
|
+
*/
|
|
38
|
+
import { useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
|
|
39
|
+
import * as stylex from '@stylexjs/stylex';
|
|
40
|
+
import { Popover } from '@astryxdesign/core/Popover';
|
|
41
|
+
import { Button } from '@astryxdesign/core/Button';
|
|
42
|
+
import { Text } from '@astryxdesign/core/Text';
|
|
43
|
+
import { Heading } from '@astryxdesign/core/Heading';
|
|
44
|
+
import { VStack, HStack } from '@astryxdesign/core/Layout';
|
|
45
|
+
import '@astryxdesign/core/theme/tokens.stylex';
|
|
46
|
+
import { colorVars, spacingVars, durationVars, easeVars } from '@astryxdesign/core/theme/tokens.stylex';
|
|
47
|
+
import { TourContext } from "./TourContext.js";
|
|
48
|
+
|
|
49
|
+
// Client-only layout effect (SSR renders no overlay, so this only runs on the
|
|
50
|
+
// client). Kept local since core does not export its isomorphic variant.
|
|
51
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
52
|
+
const useClientLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
|
53
|
+
|
|
54
|
+
// Gap between the target's edge and the highlight ring, in px.
|
|
55
|
+
const HIGHLIGHT_PADDING = 4;
|
|
56
|
+
const styles = {
|
|
57
|
+
footer: {
|
|
58
|
+
kGNEyG: "x6s0dn4",
|
|
59
|
+
kjj79g: "x1qughib",
|
|
60
|
+
$$css: true
|
|
61
|
+
},
|
|
62
|
+
calloutGapBelow: {
|
|
63
|
+
keoZOQ: "xtbrsbv",
|
|
64
|
+
$$css: true
|
|
65
|
+
},
|
|
66
|
+
calloutGapAbove: {
|
|
67
|
+
k1K539: "x1p37lm5",
|
|
68
|
+
$$css: true
|
|
69
|
+
},
|
|
70
|
+
calloutGapStart: {
|
|
71
|
+
k71WvV: "x1pezmd8",
|
|
72
|
+
$$css: true
|
|
73
|
+
},
|
|
74
|
+
calloutGapEnd: {
|
|
75
|
+
keTefX: "x1cmpsy9",
|
|
76
|
+
$$css: true
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const calloutGapStyles = {
|
|
80
|
+
below: styles.calloutGapBelow,
|
|
81
|
+
above: styles.calloutGapAbove,
|
|
82
|
+
start: styles.calloutGapStart,
|
|
83
|
+
end: styles.calloutGapEnd
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The highlight overlay: a top-layer box sized to the target, plus an optional
|
|
88
|
+
* spotlight-cutout dim. Rendered inline (not portaled) so it stays inside the
|
|
89
|
+
* consumer's Theme subtree and inherits theme tokens — the popover API promotes
|
|
90
|
+
* it into the top layer in place. Rendered before the callout so the callout
|
|
91
|
+
* promotes on top of it; promoted once (never re-promoted) so that ordering
|
|
92
|
+
* holds across re-measures.
|
|
93
|
+
*/
|
|
94
|
+
function TourHighlight({
|
|
95
|
+
rect,
|
|
96
|
+
hasBackdrop,
|
|
97
|
+
onBackdropClick
|
|
98
|
+
}) {
|
|
99
|
+
const ref = useRef(null);
|
|
100
|
+
useClientLayoutEffect(() => {
|
|
101
|
+
const el = ref.current;
|
|
102
|
+
if (el == null || typeof el.showPopover !== 'function') {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
el.showPopover();
|
|
106
|
+
return () => {
|
|
107
|
+
if (el.isConnected && typeof el.hidePopover === 'function') {
|
|
108
|
+
el.hidePopover();
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}, []);
|
|
112
|
+
return /*#__PURE__*/_jsx("div", {
|
|
113
|
+
ref: ref,
|
|
114
|
+
popover: "manual",
|
|
115
|
+
"data-testid": hasBackdrop ? 'tour-backdrop' : undefined,
|
|
116
|
+
"aria-hidden": "true",
|
|
117
|
+
onClick: hasBackdrop ? onBackdropClick : undefined,
|
|
118
|
+
...{
|
|
119
|
+
0: {
|
|
120
|
+
className: "xixxii4 x10a8y8t x1ghz6dp x1717udv xc342km xng3xce xjbqb8w x1rea2x4 x47corl"
|
|
121
|
+
},
|
|
122
|
+
1: {
|
|
123
|
+
className: "xixxii4 x10a8y8t x1ghz6dp x1717udv xc342km xng3xce xjbqb8w x1rea2x4 x67bb7w"
|
|
124
|
+
}
|
|
125
|
+
}[!!hasBackdrop << 0],
|
|
126
|
+
children: /*#__PURE__*/_jsx("div", {
|
|
127
|
+
"data-testid": "tour-highlight",
|
|
128
|
+
...{
|
|
129
|
+
0: {
|
|
130
|
+
className: "x10l6tqk x47corl xr5mita x1gfj591 xuedmi6 xlr8y92"
|
|
131
|
+
},
|
|
132
|
+
2: {
|
|
133
|
+
className: "x10l6tqk x47corl x1gfj591 xuedmi6 xlr8y92 xefcaua"
|
|
134
|
+
},
|
|
135
|
+
1: {
|
|
136
|
+
className: "x10l6tqk x47corl xr5mita x1gfj591 xuedmi6 xlr8y92 xg01cxk"
|
|
137
|
+
},
|
|
138
|
+
3: {
|
|
139
|
+
className: "x10l6tqk x47corl x1gfj591 xuedmi6 xlr8y92 xefcaua xg01cxk"
|
|
140
|
+
}
|
|
141
|
+
}[!!hasBackdrop << 1 | !!(rect == null) << 0],
|
|
142
|
+
style: rect != null ? {
|
|
143
|
+
top: rect.top - HIGHLIGHT_PADDING,
|
|
144
|
+
left: rect.left - HIGHLIGHT_PADDING,
|
|
145
|
+
width: rect.width + HIGHLIGHT_PADDING * 2,
|
|
146
|
+
height: rect.height + HIGHLIGHT_PADDING * 2,
|
|
147
|
+
borderRadius: rect.radius
|
|
148
|
+
} : undefined
|
|
149
|
+
})
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* A single spotlight step within a `<Tour>`. Renders its callout only while
|
|
154
|
+
* active.
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* ```
|
|
158
|
+
* <TourStep targetRef={saveRef} heading="Save your work">
|
|
159
|
+
* Changes save automatically.
|
|
160
|
+
* </TourStep>
|
|
161
|
+
* ```
|
|
162
|
+
*/
|
|
163
|
+
export function TourStep({
|
|
164
|
+
targetRef,
|
|
165
|
+
heading,
|
|
166
|
+
children,
|
|
167
|
+
placement = 'below',
|
|
168
|
+
alignment = 'start',
|
|
169
|
+
'data-testid': testId
|
|
170
|
+
}) {
|
|
171
|
+
const tour = useContext(TourContext);
|
|
172
|
+
const id = useId();
|
|
173
|
+
const isActiveStep = tour != null && tour.activeStepId === id;
|
|
174
|
+
|
|
175
|
+
// The active target's viewport box, tracked so the highlight overlay can sit
|
|
176
|
+
// exactly over it. null until measured (or when inactive).
|
|
177
|
+
const [rect, setRect] = useState(null);
|
|
178
|
+
|
|
179
|
+
// Register with the controller on mount so the tour learns this step (and
|
|
180
|
+
// its position among siblings). Unregister on unmount.
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (tour == null) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
return tour.registerStep(id);
|
|
186
|
+
}, [tour, id]);
|
|
187
|
+
|
|
188
|
+
// Track the target's box while this step is active. Re-measure on
|
|
189
|
+
// scroll/resize so the highlight stays glued to the target.
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
const el = targetRef.current;
|
|
192
|
+
if (el == null || !isActiveStep) {
|
|
193
|
+
setRect(null);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const measure = () => {
|
|
197
|
+
const box = el.getBoundingClientRect();
|
|
198
|
+
setRect({
|
|
199
|
+
top: box.top,
|
|
200
|
+
left: box.left,
|
|
201
|
+
width: box.width,
|
|
202
|
+
height: box.height,
|
|
203
|
+
radius: getComputedStyle(el).borderRadius || '0px'
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
measure();
|
|
207
|
+
window.addEventListener('scroll', measure, true);
|
|
208
|
+
window.addEventListener('resize', measure);
|
|
209
|
+
return () => {
|
|
210
|
+
window.removeEventListener('scroll', measure, true);
|
|
211
|
+
window.removeEventListener('resize', measure);
|
|
212
|
+
setRect(null);
|
|
213
|
+
};
|
|
214
|
+
}, [targetRef, isActiveStep]);
|
|
215
|
+
|
|
216
|
+
// Outside a <Tour>, or when this isn't the active step, render nothing.
|
|
217
|
+
if (tour == null || !isActiveStep) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const {
|
|
221
|
+
activeStepIndex,
|
|
222
|
+
stepCount,
|
|
223
|
+
isStepCountShown,
|
|
224
|
+
hasBackdrop,
|
|
225
|
+
onNext,
|
|
226
|
+
onPrevious,
|
|
227
|
+
onDismiss
|
|
228
|
+
} = tour;
|
|
229
|
+
const isFirstStep = activeStepIndex <= 0;
|
|
230
|
+
const isLastStep = stepCount > 0 && activeStepIndex === stepCount - 1;
|
|
231
|
+
const content = /*#__PURE__*/_jsxs("div", {
|
|
232
|
+
...{
|
|
233
|
+
className: "x78zum5 xdt5ytf xjcht0a x1va8c73"
|
|
234
|
+
},
|
|
235
|
+
"data-testid": testId,
|
|
236
|
+
children: [/*#__PURE__*/_jsxs(VStack, {
|
|
237
|
+
gap: 1,
|
|
238
|
+
children: [/*#__PURE__*/_jsx(Heading, {
|
|
239
|
+
level: 4,
|
|
240
|
+
children: heading
|
|
241
|
+
}), children != null && /*#__PURE__*/_jsx(Text, {
|
|
242
|
+
type: "body",
|
|
243
|
+
children: children
|
|
244
|
+
})]
|
|
245
|
+
}), /*#__PURE__*/_jsxs(HStack, {
|
|
246
|
+
gap: 2,
|
|
247
|
+
xstyle: styles.footer,
|
|
248
|
+
children: [isStepCountShown && stepCount > 0 ? /*#__PURE__*/_jsx(Text, {
|
|
249
|
+
type: "supporting",
|
|
250
|
+
color: "secondary",
|
|
251
|
+
children: `${activeStepIndex + 1} of ${stepCount}`
|
|
252
|
+
}) : /*#__PURE__*/_jsx("span", {}), /*#__PURE__*/_jsxs(HStack, {
|
|
253
|
+
gap: 2,
|
|
254
|
+
children: [!isFirstStep && /*#__PURE__*/_jsx(Button, {
|
|
255
|
+
variant: "ghost",
|
|
256
|
+
size: "sm",
|
|
257
|
+
label: "Back",
|
|
258
|
+
onClick: onPrevious
|
|
259
|
+
}), /*#__PURE__*/_jsx(Button, {
|
|
260
|
+
variant: "primary",
|
|
261
|
+
size: "sm",
|
|
262
|
+
label: isLastStep ? 'Done' : 'Next',
|
|
263
|
+
onClick: onNext
|
|
264
|
+
})]
|
|
265
|
+
})]
|
|
266
|
+
})]
|
|
267
|
+
});
|
|
268
|
+
return /*#__PURE__*/_jsxs(_Fragment, {
|
|
269
|
+
children: [/*#__PURE__*/_jsx(TourHighlight, {
|
|
270
|
+
rect: rect,
|
|
271
|
+
hasBackdrop: hasBackdrop,
|
|
272
|
+
onBackdropClick: () => onDismiss('backdrop')
|
|
273
|
+
}), /*#__PURE__*/_jsx(Popover
|
|
274
|
+
// Popover types anchorRef as RefObject<HTMLElement>; TourStep accepts a
|
|
275
|
+
// nullable ref for ergonomics (useRef<HTMLButtonElement>(null)). Popover
|
|
276
|
+
// guards a null `.current` internally, so this widening is safe.
|
|
277
|
+
, {
|
|
278
|
+
anchorRef: targetRef,
|
|
279
|
+
isOpen: true,
|
|
280
|
+
onOpenChange: open => {
|
|
281
|
+
// Popover reports close from light-dismiss (backdrop) or Escape.
|
|
282
|
+
// Route it to the tour as a dismissal so the whole tour ends, not
|
|
283
|
+
// just this step's popover. Escape and outside-click both surface
|
|
284
|
+
// here.
|
|
285
|
+
if (!open) {
|
|
286
|
+
onDismiss('close');
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
placement: placement,
|
|
290
|
+
alignment: alignment
|
|
291
|
+
// Size the callout to its content instead of matching the target's
|
|
292
|
+
// width (Popover's default minWidth: anchor-size(width) makes it span a
|
|
293
|
+
// wide target); the content's own maxWidth caps it.
|
|
294
|
+
,
|
|
295
|
+
width: "fit-content"
|
|
296
|
+
// Push the callout clear of the highlight ring so the ring is never
|
|
297
|
+
// covered (placement-aware; adds to Popover's own anchor gap).
|
|
298
|
+
,
|
|
299
|
+
xstyle: calloutGapStyles[placement],
|
|
300
|
+
label: typeof heading === 'string' ? heading : 'Tour step',
|
|
301
|
+
hasCloseButton: true,
|
|
302
|
+
closeButtonLabel: "Close tour",
|
|
303
|
+
content: content
|
|
304
|
+
})]
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
TourStep.displayName = 'TourStep';
|