@gogitcms/design-system 0.15.0-next.3
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/README.md +77 -0
- package/css/components.css +312 -0
- package/css/tokens.css +212 -0
- package/package.json +65 -0
- package/src/ThemeProvider.tsx +86 -0
- package/src/__tests__/ApplyChangesModal.test.tsx +148 -0
- package/src/__tests__/BranchImport.test.tsx +46 -0
- package/src/__tests__/Button.test.tsx +45 -0
- package/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
- package/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
- package/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
- package/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
- package/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
- package/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
- package/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
- package/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
- package/src/__tests__/ContentBrowser.media.test.tsx +140 -0
- package/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
- package/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
- package/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
- package/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
- package/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
- package/src/__tests__/ContentBrowser.search.test.tsx +135 -0
- package/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
- package/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
- package/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
- package/src/__tests__/MediaBrowser.test.tsx +353 -0
- package/src/__tests__/MediaField.test.tsx +185 -0
- package/src/__tests__/Notifications.test.tsx +69 -0
- package/src/__tests__/Onboarding.test.tsx +287 -0
- package/src/__tests__/cssTokens.test.ts +201 -0
- package/src/__tests__/fieldComponents.test.ts +43 -0
- package/src/__tests__/reorder.test.ts +58 -0
- package/src/components/ApplyChangesModal.tsx +348 -0
- package/src/components/BranchImport.tsx +157 -0
- package/src/components/BranchMenu.tsx +192 -0
- package/src/components/Button.tsx +131 -0
- package/src/components/ChangeDetail.tsx +472 -0
- package/src/components/ChangeRequestSummary.tsx +173 -0
- package/src/components/CollabField.tsx +388 -0
- package/src/components/ContentBrowser.tsx +5073 -0
- package/src/components/Icon.tsx +28 -0
- package/src/components/Icon.web.tsx +31 -0
- package/src/components/Input.tsx +106 -0
- package/src/components/MediaBrowser.tsx +766 -0
- package/src/components/MediaField.tsx +670 -0
- package/src/components/MediaPreview.tsx +91 -0
- package/src/components/MediaPreview.web.tsx +169 -0
- package/src/components/NavRow.tsx +105 -0
- package/src/components/Notifications.tsx +301 -0
- package/src/components/Onboarding.tsx +751 -0
- package/src/components/ProjectMenu.tsx +124 -0
- package/src/components/Segment.tsx +87 -0
- package/src/components/Skeleton.tsx +216 -0
- package/src/components/Spinner.tsx +44 -0
- package/src/components/Text.tsx +85 -0
- package/src/components/documentDrafts.ts +213 -0
- package/src/components/layout.tsx +284 -0
- package/src/components/primitives.tsx +143 -0
- package/src/components/reorder.ts +40 -0
- package/src/fieldComponents.ts +63 -0
- package/src/icons.ts +102 -0
- package/src/index.ts +198 -0
- package/src/media.ts +229 -0
- package/src/theme.ts +116 -0
- package/src/web/Button.tsx +110 -0
- package/src/web/Icon.tsx +52 -0
- package/src/web/Input.tsx +39 -0
- package/src/web/index.ts +43 -0
- package/src/web/primitives.tsx +119 -0
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { Pressable, View, useWindowDimensions } from "react-native";
|
|
11
|
+
import { useTheme } from "../ThemeProvider";
|
|
12
|
+
import { Text } from "./Text";
|
|
13
|
+
import { Button, IconButton } from "./Button";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The guided-tour framework: a spotlight overlay that walks someone through a
|
|
17
|
+
* screen one anchored step at a time.
|
|
18
|
+
*
|
|
19
|
+
* Three pieces, deliberately separable:
|
|
20
|
+
*
|
|
21
|
+
* - `useOnboardingTarget(id)` marks a piece of UI as anchorable. It hands back
|
|
22
|
+
* a ref and nothing else, so anchoring a step to an existing element never
|
|
23
|
+
* changes that element's layout — the failure mode of every tour library
|
|
24
|
+
* that asks you to wrap things.
|
|
25
|
+
* - `OnboardingProvider` owns which flow is running and where in it we are,
|
|
26
|
+
* and renders the overlay.
|
|
27
|
+
* - `useOnboarding()` is the host's handle: start a tour, replay one, ask
|
|
28
|
+
* whether one has been seen.
|
|
29
|
+
*
|
|
30
|
+
* Persistence is a seam, not a dependency: the provider takes `progress` and
|
|
31
|
+
* calls `onProgress`, so the dashboard can back it with REST and the editor SPA
|
|
32
|
+
* with GraphQL without this file knowing either exists.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/** Where a step's card sits relative to its target. */
|
|
38
|
+
export type OnboardingPlacement = "auto" | "top" | "bottom" | "left" | "right" | "center";
|
|
39
|
+
|
|
40
|
+
export type OnboardingFlowStep = {
|
|
41
|
+
id: string;
|
|
42
|
+
title: string;
|
|
43
|
+
body: string;
|
|
44
|
+
/**
|
|
45
|
+
* The id passed to `useOnboardingTarget`. Absent — or present but not
|
|
46
|
+
* currently rendered — falls back to a centered card, which is what makes a
|
|
47
|
+
* step that describes the screen as a whole (or one whose target is hidden on
|
|
48
|
+
* mobile) degrade rather than break.
|
|
49
|
+
*/
|
|
50
|
+
target?: string;
|
|
51
|
+
placement?: OnboardingPlacement;
|
|
52
|
+
/**
|
|
53
|
+
* Run when the step becomes current. For steps whose target only exists once
|
|
54
|
+
* something is open — a menu, a drawer — the host opens it here.
|
|
55
|
+
*/
|
|
56
|
+
onEnter?: () => void;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type OnboardingFlow = {
|
|
60
|
+
id: string;
|
|
61
|
+
/**
|
|
62
|
+
* Bumped when the tour's content changes materially. A user whose recorded
|
|
63
|
+
* version is lower is offered the tour again; this is why completion is not
|
|
64
|
+
* stored as a bare boolean.
|
|
65
|
+
*/
|
|
66
|
+
version: number;
|
|
67
|
+
/** Shown as the overlay's kicker, e.g. "Dashboard tour". */
|
|
68
|
+
title: string;
|
|
69
|
+
steps: OnboardingFlowStep[];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type OnboardingStatus = "pending" | "in_progress" | "completed" | "skipped";
|
|
73
|
+
|
|
74
|
+
/** One flow's recorded position. Mirrors the server's row. */
|
|
75
|
+
export type OnboardingProgress = {
|
|
76
|
+
status: OnboardingStatus;
|
|
77
|
+
stepIndex: number;
|
|
78
|
+
version: number;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type OnboardingSession = {
|
|
82
|
+
flow: OnboardingFlow;
|
|
83
|
+
step: OnboardingFlowStep;
|
|
84
|
+
index: number;
|
|
85
|
+
total: number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type OnboardingApi = {
|
|
89
|
+
/** The running tour, or null. */
|
|
90
|
+
session: OnboardingSession | null;
|
|
91
|
+
/** Start (or resume) a flow. `atIndex` overrides the resume point. */
|
|
92
|
+
start: (flowId: string, atIndex?: number) => void;
|
|
93
|
+
/** Run a flow from the top regardless of what has been recorded. */
|
|
94
|
+
restart: (flowId: string) => void;
|
|
95
|
+
next: () => void;
|
|
96
|
+
back: () => void;
|
|
97
|
+
/** Dismiss: records `skipped`. */
|
|
98
|
+
skip: () => void;
|
|
99
|
+
/** Close without recording anything (route changes, unmount). */
|
|
100
|
+
stop: () => void;
|
|
101
|
+
/** What has been recorded for a flow — `pending` when nothing has. */
|
|
102
|
+
statusFor: (flowId: string) => OnboardingStatus;
|
|
103
|
+
/** True when the flow is worth offering, i.e. never seen or its version bumped. */
|
|
104
|
+
isUnseen: (flowId: string) => boolean;
|
|
105
|
+
flows: OnboardingFlow[];
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type Rect = { x: number; y: number; width: number; height: number };
|
|
109
|
+
|
|
110
|
+
// ── Target registry ────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
type Registry = {
|
|
113
|
+
register: (id: string, node: unknown) => void;
|
|
114
|
+
unregister: (id: string) => void;
|
|
115
|
+
get: (id: string) => unknown;
|
|
116
|
+
/** Fires when a target mounts or unmounts, so a waiting step can re-measure. */
|
|
117
|
+
subscribe: (fn: () => void) => () => void;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const TargetsCtx = createContext<Registry | null>(null);
|
|
121
|
+
const OnboardingCtx = createContext<OnboardingApi | null>(null);
|
|
122
|
+
|
|
123
|
+
function createRegistry(): Registry {
|
|
124
|
+
const nodes = new Map<string, unknown>();
|
|
125
|
+
const subs = new Set<() => void>();
|
|
126
|
+
const notify = () => subs.forEach((fn) => fn());
|
|
127
|
+
return {
|
|
128
|
+
register(id, node) {
|
|
129
|
+
nodes.set(id, node);
|
|
130
|
+
notify();
|
|
131
|
+
},
|
|
132
|
+
unregister(id) {
|
|
133
|
+
nodes.delete(id);
|
|
134
|
+
notify();
|
|
135
|
+
},
|
|
136
|
+
get: (id) => nodes.get(id),
|
|
137
|
+
subscribe(fn) {
|
|
138
|
+
subs.add(fn);
|
|
139
|
+
return () => subs.delete(fn);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Marks a piece of UI as a tour anchor. Spread the returned ref onto any View:
|
|
146
|
+
*
|
|
147
|
+
* const stats = useOnboardingTarget("dash.stats");
|
|
148
|
+
* <View ref={stats.ref} style={…}>
|
|
149
|
+
*
|
|
150
|
+
* Registering is layout-neutral by construction — no wrapper element, no style.
|
|
151
|
+
*/
|
|
152
|
+
export function useOnboardingTarget(id: string): { ref: (node: unknown) => void } {
|
|
153
|
+
const reg = useContext(TargetsCtx);
|
|
154
|
+
const ref = useCallback(
|
|
155
|
+
(node: unknown) => {
|
|
156
|
+
if (!reg) return;
|
|
157
|
+
if (node) reg.register(id, node);
|
|
158
|
+
else reg.unregister(id);
|
|
159
|
+
},
|
|
160
|
+
[id, reg],
|
|
161
|
+
);
|
|
162
|
+
return { ref };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Wrapper form of `useOnboardingTarget`, for anchoring something you don't own. */
|
|
166
|
+
export function OnboardingTarget({
|
|
167
|
+
id,
|
|
168
|
+
children,
|
|
169
|
+
style,
|
|
170
|
+
}: {
|
|
171
|
+
id: string;
|
|
172
|
+
children: React.ReactNode;
|
|
173
|
+
style?: any;
|
|
174
|
+
}) {
|
|
175
|
+
const { ref } = useOnboardingTarget(id);
|
|
176
|
+
return (
|
|
177
|
+
<View ref={ref as any} style={style} collapsable={false}>
|
|
178
|
+
{children}
|
|
179
|
+
</View>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Measurement ────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
// measureNode reads a node's viewport rect. On react-native-web the ref is the
|
|
186
|
+
// DOM element, so getBoundingClientRect is both available and cheaper than RN's
|
|
187
|
+
// callback API; measureInWindow is the native path. A zero-sized result counts
|
|
188
|
+
// as "not measurable" — that is what a display:none target looks like.
|
|
189
|
+
function measureNode(node: any): Promise<Rect | null> {
|
|
190
|
+
return new Promise((resolve) => {
|
|
191
|
+
if (!node) {
|
|
192
|
+
resolve(null);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (typeof node.getBoundingClientRect === "function") {
|
|
196
|
+
const r = node.getBoundingClientRect();
|
|
197
|
+
resolve(r.width || r.height ? { x: r.left, y: r.top, width: r.width, height: r.height } : null);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (typeof node.measureInWindow === "function") {
|
|
201
|
+
node.measureInWindow((x: number, y: number, width: number, height: number) => {
|
|
202
|
+
resolve(width || height ? { x, y, width, height } : null);
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
resolve(null);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// scrollIntoViewIfNeeded brings an off-screen anchor into view before the
|
|
211
|
+
// spotlight is drawn around it. Web-only and best-effort: a tour that silently
|
|
212
|
+
// highlights something below the fold is worse than one that scrolls.
|
|
213
|
+
function scrollIntoViewIfNeeded(node: any, rect: Rect | null, viewportH: number) {
|
|
214
|
+
if (!node || typeof node.scrollIntoView !== "function" || !rect) return false;
|
|
215
|
+
if (rect.y >= 0 && rect.y + rect.height <= viewportH) return false;
|
|
216
|
+
try {
|
|
217
|
+
node.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });
|
|
218
|
+
return true;
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Tracks a target's rect while a step is showing.
|
|
226
|
+
*
|
|
227
|
+
* Measuring once is not enough: the anchor may not have mounted yet (data still
|
|
228
|
+
* loading), the page may need scrolling to reach it, and the window may resize
|
|
229
|
+
* under the overlay. So this re-measures on a short poll that stops as soon as
|
|
230
|
+
* the rect settles, and on every resize, scroll and registry change.
|
|
231
|
+
*/
|
|
232
|
+
function useTargetRect(targetId: string | undefined, stepKey: string): Rect | null {
|
|
233
|
+
const reg = useContext(TargetsCtx);
|
|
234
|
+
const { height: viewportH } = useWindowDimensions();
|
|
235
|
+
const [rect, setRect] = useState<Rect | null>(null);
|
|
236
|
+
const last = useRef<Rect | null>(null);
|
|
237
|
+
const scrolled = useRef(false);
|
|
238
|
+
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
if (!targetId || !reg) {
|
|
241
|
+
last.current = null;
|
|
242
|
+
setRect(null);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
let cancelled = false;
|
|
246
|
+
let timer: any;
|
|
247
|
+
let settled = 0;
|
|
248
|
+
scrolled.current = false;
|
|
249
|
+
last.current = null;
|
|
250
|
+
|
|
251
|
+
const tick = async () => {
|
|
252
|
+
if (cancelled) return;
|
|
253
|
+
const node = reg.get(targetId);
|
|
254
|
+
const next = await measureNode(node);
|
|
255
|
+
if (cancelled) return;
|
|
256
|
+
if (next && !scrolled.current) {
|
|
257
|
+
scrolled.current = true;
|
|
258
|
+
// A scroll moves the rect; the poll below picks up the new one.
|
|
259
|
+
scrollIntoViewIfNeeded(node, next, viewportH);
|
|
260
|
+
}
|
|
261
|
+
settled = next && sameRect(last.current, next) ? settled + 1 : 0;
|
|
262
|
+
if (!sameRect(last.current, next)) {
|
|
263
|
+
last.current = next;
|
|
264
|
+
setRect(next);
|
|
265
|
+
}
|
|
266
|
+
// Back off once the rect has held still for a few frames, so a settled
|
|
267
|
+
// tour is not re-measuring the DOM twelve times a second.
|
|
268
|
+
timer = setTimeout(tick, settled > 3 ? 500 : 80);
|
|
269
|
+
};
|
|
270
|
+
tick();
|
|
271
|
+
|
|
272
|
+
const unsub = reg.subscribe(() => {
|
|
273
|
+
clearTimeout(timer);
|
|
274
|
+
settled = 0;
|
|
275
|
+
tick();
|
|
276
|
+
});
|
|
277
|
+
return () => {
|
|
278
|
+
cancelled = true;
|
|
279
|
+
clearTimeout(timer);
|
|
280
|
+
unsub();
|
|
281
|
+
};
|
|
282
|
+
}, [targetId, stepKey, reg, viewportH]);
|
|
283
|
+
|
|
284
|
+
return rect;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function sameRect(a: Rect | null, b: Rect | null): boolean {
|
|
288
|
+
if (!a || !b) return a === b;
|
|
289
|
+
return (
|
|
290
|
+
Math.abs(a.x - b.x) < 0.5 &&
|
|
291
|
+
Math.abs(a.y - b.y) < 0.5 &&
|
|
292
|
+
Math.abs(a.width - b.width) < 0.5 &&
|
|
293
|
+
Math.abs(a.height - b.height) < 0.5
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── Provider ───────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
export type OnboardingProviderProps = {
|
|
300
|
+
flows: OnboardingFlow[];
|
|
301
|
+
/**
|
|
302
|
+
* The flow this screen is about. Changing it ends any running tour — a tour
|
|
303
|
+
* anchored to a screen you have navigated away from has nothing to point at.
|
|
304
|
+
*/
|
|
305
|
+
activeFlow?: string;
|
|
306
|
+
/**
|
|
307
|
+
* Recorded progress, keyed by flow id. `undefined` means "not loaded yet" and
|
|
308
|
+
* suppresses auto-start, so a slow round-trip never replays a finished tour.
|
|
309
|
+
*/
|
|
310
|
+
progress?: Record<string, OnboardingProgress>;
|
|
311
|
+
/** Persist a position. Called on every step, on skip and on completion. */
|
|
312
|
+
onProgress?: (flowId: string, next: OnboardingProgress) => void;
|
|
313
|
+
/**
|
|
314
|
+
* Hold auto-start until the screen's data has landed. Anchors that render
|
|
315
|
+
* after their step would otherwise spotlight nothing.
|
|
316
|
+
*/
|
|
317
|
+
ready?: boolean;
|
|
318
|
+
/** False turns the whole thing off (embedded surfaces, tests). */
|
|
319
|
+
enabled?: boolean;
|
|
320
|
+
children: React.ReactNode;
|
|
321
|
+
style?: any;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
export function OnboardingProvider({
|
|
325
|
+
flows,
|
|
326
|
+
activeFlow,
|
|
327
|
+
progress,
|
|
328
|
+
onProgress,
|
|
329
|
+
ready = true,
|
|
330
|
+
enabled = true,
|
|
331
|
+
children,
|
|
332
|
+
style,
|
|
333
|
+
}: OnboardingProviderProps) {
|
|
334
|
+
const registry = useMemo(createRegistry, []);
|
|
335
|
+
const [session, setSession] = useState<{ flowId: string; index: number } | null>(null);
|
|
336
|
+
// Optimistic overlay on `progress`: the host round-trips a write, and until it
|
|
337
|
+
// comes back the local record is what stops an auto-start loop.
|
|
338
|
+
const [local, setLocal] = useState<Record<string, OnboardingProgress>>({});
|
|
339
|
+
|
|
340
|
+
const merged = useMemo(() => ({ ...(progress ?? {}), ...local }), [progress, local]);
|
|
341
|
+
const flowById = useCallback((id: string) => flows.find((f) => f.id === id), [flows]);
|
|
342
|
+
|
|
343
|
+
const record = useCallback(
|
|
344
|
+
(flowId: string, next: OnboardingProgress) => {
|
|
345
|
+
setLocal((prev) => ({ ...prev, [flowId]: next }));
|
|
346
|
+
onProgress?.(flowId, next);
|
|
347
|
+
},
|
|
348
|
+
[onProgress],
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const statusFor = useCallback(
|
|
352
|
+
(flowId: string): OnboardingStatus => merged[flowId]?.status ?? "pending",
|
|
353
|
+
[merged],
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const isUnseen = useCallback(
|
|
357
|
+
(flowId: string) => {
|
|
358
|
+
const flow = flowById(flowId);
|
|
359
|
+
const p = merged[flowId];
|
|
360
|
+
if (!flow) return false;
|
|
361
|
+
if (!p || p.status === "pending") return true;
|
|
362
|
+
// A bumped version re-offers the tour to someone who finished the old one.
|
|
363
|
+
return p.version < flow.version;
|
|
364
|
+
},
|
|
365
|
+
[flowById, merged],
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const start = useCallback(
|
|
369
|
+
(flowId: string, atIndex?: number) => {
|
|
370
|
+
const flow = flowById(flowId);
|
|
371
|
+
if (!flow || flow.steps.length === 0) return;
|
|
372
|
+
const stored = merged[flowId];
|
|
373
|
+
const resume =
|
|
374
|
+
atIndex ??
|
|
375
|
+
(stored && stored.status === "in_progress" && stored.version === flow.version
|
|
376
|
+
? stored.stepIndex
|
|
377
|
+
: 0);
|
|
378
|
+
const index = Math.min(Math.max(resume, 0), flow.steps.length - 1);
|
|
379
|
+
setSession({ flowId, index });
|
|
380
|
+
record(flowId, { status: "in_progress", stepIndex: index, version: flow.version });
|
|
381
|
+
},
|
|
382
|
+
[flowById, merged, record],
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const restart = useCallback((flowId: string) => start(flowId, 0), [start]);
|
|
386
|
+
|
|
387
|
+
const stop = useCallback(() => setSession(null), []);
|
|
388
|
+
|
|
389
|
+
const next = useCallback(() => {
|
|
390
|
+
setSession((cur) => {
|
|
391
|
+
if (!cur) return cur;
|
|
392
|
+
const flow = flowById(cur.flowId);
|
|
393
|
+
if (!flow) return null;
|
|
394
|
+
const last = flow.steps.length - 1;
|
|
395
|
+
if (cur.index >= last) {
|
|
396
|
+
record(cur.flowId, { status: "completed", stepIndex: last, version: flow.version });
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
const index = cur.index + 1;
|
|
400
|
+
record(cur.flowId, { status: "in_progress", stepIndex: index, version: flow.version });
|
|
401
|
+
return { ...cur, index };
|
|
402
|
+
});
|
|
403
|
+
}, [flowById, record]);
|
|
404
|
+
|
|
405
|
+
const back = useCallback(() => {
|
|
406
|
+
setSession((cur) => {
|
|
407
|
+
if (!cur || cur.index === 0) return cur;
|
|
408
|
+
const flow = flowById(cur.flowId);
|
|
409
|
+
if (!flow) return null;
|
|
410
|
+
const index = cur.index - 1;
|
|
411
|
+
record(cur.flowId, { status: "in_progress", stepIndex: index, version: flow.version });
|
|
412
|
+
return { ...cur, index };
|
|
413
|
+
});
|
|
414
|
+
}, [flowById, record]);
|
|
415
|
+
|
|
416
|
+
const skip = useCallback(() => {
|
|
417
|
+
setSession((cur) => {
|
|
418
|
+
if (!cur) return cur;
|
|
419
|
+
const flow = flowById(cur.flowId);
|
|
420
|
+
if (flow) {
|
|
421
|
+
record(cur.flowId, { status: "skipped", stepIndex: cur.index, version: flow.version });
|
|
422
|
+
}
|
|
423
|
+
return null;
|
|
424
|
+
});
|
|
425
|
+
}, [flowById, record]);
|
|
426
|
+
|
|
427
|
+
// Auto-start: the screen declares which flow it is about, and a flow nobody
|
|
428
|
+
// has seen (or whose version moved on) runs itself. An in-progress flow
|
|
429
|
+
// resumes where it stopped rather than restarting.
|
|
430
|
+
const activeRef = useRef<string | undefined>(undefined);
|
|
431
|
+
useEffect(() => {
|
|
432
|
+
if (activeRef.current !== activeFlow) {
|
|
433
|
+
activeRef.current = activeFlow;
|
|
434
|
+
// Leaving a screen ends its tour; nothing is recorded, so it resumes on
|
|
435
|
+
// the way back.
|
|
436
|
+
setSession(null);
|
|
437
|
+
}
|
|
438
|
+
}, [activeFlow]);
|
|
439
|
+
|
|
440
|
+
useEffect(() => {
|
|
441
|
+
if (!enabled || !ready || !activeFlow || progress === undefined || session) return;
|
|
442
|
+
const flow = flowById(activeFlow);
|
|
443
|
+
if (!flow) return;
|
|
444
|
+
const p = merged[activeFlow];
|
|
445
|
+
if (p && (p.status === "completed" || p.status === "skipped") && p.version >= flow.version) return;
|
|
446
|
+
start(activeFlow);
|
|
447
|
+
}, [enabled, ready, activeFlow, progress, session, merged, flowById, start]);
|
|
448
|
+
|
|
449
|
+
const currentFlow = session ? flowById(session.flowId) : undefined;
|
|
450
|
+
const api: OnboardingApi = useMemo(
|
|
451
|
+
() => ({
|
|
452
|
+
session:
|
|
453
|
+
currentFlow && session
|
|
454
|
+
? {
|
|
455
|
+
flow: currentFlow,
|
|
456
|
+
step: currentFlow.steps[session.index],
|
|
457
|
+
index: session.index,
|
|
458
|
+
total: currentFlow.steps.length,
|
|
459
|
+
}
|
|
460
|
+
: null,
|
|
461
|
+
start,
|
|
462
|
+
restart,
|
|
463
|
+
next,
|
|
464
|
+
back,
|
|
465
|
+
skip,
|
|
466
|
+
stop,
|
|
467
|
+
statusFor,
|
|
468
|
+
isUnseen,
|
|
469
|
+
flows,
|
|
470
|
+
}),
|
|
471
|
+
[currentFlow, session, start, restart, next, back, skip, stop, statusFor, isUnseen, flows],
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
return (
|
|
475
|
+
<TargetsCtx.Provider value={registry}>
|
|
476
|
+
<OnboardingCtx.Provider value={api}>
|
|
477
|
+
<View style={[{ flex: 1 }, style]}>
|
|
478
|
+
{children}
|
|
479
|
+
{enabled && api.session ? <OnboardingOverlay /> : null}
|
|
480
|
+
</View>
|
|
481
|
+
</OnboardingCtx.Provider>
|
|
482
|
+
</TargetsCtx.Provider>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** The host's handle on the running tour. Throws outside a provider. */
|
|
487
|
+
export function useOnboarding(): OnboardingApi {
|
|
488
|
+
const ctx = useContext(OnboardingCtx);
|
|
489
|
+
if (!ctx) throw new Error("useOnboarding outside OnboardingProvider");
|
|
490
|
+
return ctx;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Non-throwing form, for shared components that may render without a provider. */
|
|
494
|
+
export function useOnboardingOptional(): OnboardingApi | null {
|
|
495
|
+
return useContext(OnboardingCtx);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ── Overlay ────────────────────────────────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
const CARD_WIDTH = 340;
|
|
501
|
+
const CARD_GAP = 12;
|
|
502
|
+
const HOLE_PAD = 6;
|
|
503
|
+
const EDGE = 16;
|
|
504
|
+
|
|
505
|
+
// The scrim and the ring are fixed light-on-dark rather than themed: the
|
|
506
|
+
// spotlight always sits on a dark wash, so a themed ring would vanish into it
|
|
507
|
+
// in one mode. Same reasoning as the permanently dark top bar.
|
|
508
|
+
const SCRIM = "rgba(10,10,10,0.55)";
|
|
509
|
+
const RING = "rgba(255,255,255,0.92)";
|
|
510
|
+
|
|
511
|
+
export function OnboardingOverlay() {
|
|
512
|
+
const t = useTheme();
|
|
513
|
+
const { width: vw, height: vh } = useWindowDimensions();
|
|
514
|
+
const { session, next, back, skip } = useOnboarding();
|
|
515
|
+
const [cardSize, setCardSize] = useState<{ w: number; h: number } | null>(null);
|
|
516
|
+
|
|
517
|
+
const step = session?.step;
|
|
518
|
+
const stepKey = session ? `${session.flow.id}:${session.index}` : "";
|
|
519
|
+
const rect = useTargetRect(step?.target, stepKey);
|
|
520
|
+
|
|
521
|
+
// Steps that open something (a menu, a drawer) do it here, on entry.
|
|
522
|
+
const onEnter = step?.onEnter;
|
|
523
|
+
useEffect(() => {
|
|
524
|
+
onEnter?.();
|
|
525
|
+
}, [stepKey, onEnter]);
|
|
526
|
+
|
|
527
|
+
// Keyboard: the tour is a modal thing, so it takes the keys a modal takes.
|
|
528
|
+
useEffect(() => {
|
|
529
|
+
if (typeof document === "undefined") return;
|
|
530
|
+
const onKey = (e: any) => {
|
|
531
|
+
if (e.key === "Escape") {
|
|
532
|
+
e.preventDefault();
|
|
533
|
+
skip();
|
|
534
|
+
} else if (e.key === "ArrowRight" || e.key === "Enter") {
|
|
535
|
+
e.preventDefault();
|
|
536
|
+
next();
|
|
537
|
+
} else if (e.key === "ArrowLeft") {
|
|
538
|
+
e.preventDefault();
|
|
539
|
+
back();
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
document.addEventListener("keydown", onKey);
|
|
543
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
544
|
+
}, [next, back, skip]);
|
|
545
|
+
|
|
546
|
+
if (!session || !step) return null;
|
|
547
|
+
|
|
548
|
+
const hole = rect
|
|
549
|
+
? {
|
|
550
|
+
left: Math.max(rect.x - HOLE_PAD, 0),
|
|
551
|
+
top: Math.max(rect.y - HOLE_PAD, 0),
|
|
552
|
+
width: Math.min(rect.width + HOLE_PAD * 2, vw),
|
|
553
|
+
height: Math.min(rect.height + HOLE_PAD * 2, vh),
|
|
554
|
+
}
|
|
555
|
+
: null;
|
|
556
|
+
|
|
557
|
+
const cardW = Math.min(CARD_WIDTH, vw - EDGE * 2);
|
|
558
|
+
const card = placeCard(hole, cardSize, { vw, vh, cardW, placement: step.placement ?? "auto" });
|
|
559
|
+
const isLast = session.index === session.total - 1;
|
|
560
|
+
|
|
561
|
+
return (
|
|
562
|
+
<View
|
|
563
|
+
testID="onboarding-overlay"
|
|
564
|
+
style={{
|
|
565
|
+
position: "absolute",
|
|
566
|
+
top: 0,
|
|
567
|
+
left: 0,
|
|
568
|
+
right: 0,
|
|
569
|
+
bottom: 0,
|
|
570
|
+
zIndex: 100,
|
|
571
|
+
// The container spans the viewport, so leaving it hit-testable would
|
|
572
|
+
// swallow the click on the very control the step points at — the
|
|
573
|
+
// spotlight would be a picture of an interactive element rather than
|
|
574
|
+
// the element. It stays transparent to clicks and the pieces that do
|
|
575
|
+
// need them (the scrim panels, the card) opt back in individually.
|
|
576
|
+
// Written per-child rather than as `box-none` on the parent because
|
|
577
|
+
// react-native-web only honours "none"/"auto" from a style object.
|
|
578
|
+
pointerEvents: "none",
|
|
579
|
+
}}
|
|
580
|
+
>
|
|
581
|
+
{/* The scrim is four rects rather than one with a cut-out: it dims
|
|
582
|
+
everything but the anchor, and because nothing covers the anchor it
|
|
583
|
+
stays genuinely clickable — the user can do the thing being described
|
|
584
|
+
without leaving the tour. */}
|
|
585
|
+
{hole ? (
|
|
586
|
+
<>
|
|
587
|
+
<Backdrop style={{ left: 0, right: 0, top: 0, height: hole.top }} />
|
|
588
|
+
<Backdrop style={{ left: 0, right: 0, top: hole.top + hole.height, bottom: 0 }} />
|
|
589
|
+
<Backdrop style={{ left: 0, width: hole.left, top: hole.top, height: hole.height }} />
|
|
590
|
+
<Backdrop
|
|
591
|
+
style={{ left: hole.left + hole.width, right: 0, top: hole.top, height: hole.height }}
|
|
592
|
+
/>
|
|
593
|
+
<View
|
|
594
|
+
testID="onboarding-spotlight"
|
|
595
|
+
style={{
|
|
596
|
+
position: "absolute",
|
|
597
|
+
// In style rather than as a prop: the ring is decoration, and the
|
|
598
|
+
// anchor beneath it has to stay clickable through it.
|
|
599
|
+
pointerEvents: "none",
|
|
600
|
+
left: hole.left,
|
|
601
|
+
top: hole.top,
|
|
602
|
+
width: hole.width,
|
|
603
|
+
height: hole.height,
|
|
604
|
+
borderWidth: 2,
|
|
605
|
+
borderColor: RING,
|
|
606
|
+
borderRadius: t.radius.md,
|
|
607
|
+
}}
|
|
608
|
+
/>
|
|
609
|
+
</>
|
|
610
|
+
) : (
|
|
611
|
+
<Backdrop style={{ left: 0, right: 0, top: 0, bottom: 0 }} />
|
|
612
|
+
)}
|
|
613
|
+
|
|
614
|
+
<View
|
|
615
|
+
testID="onboarding-card"
|
|
616
|
+
onLayout={(e) =>
|
|
617
|
+
setCardSize({ w: e.nativeEvent.layout.width, h: e.nativeEvent.layout.height })
|
|
618
|
+
}
|
|
619
|
+
style={{
|
|
620
|
+
position: "absolute",
|
|
621
|
+
left: card.left,
|
|
622
|
+
top: card.top,
|
|
623
|
+
width: cardW,
|
|
624
|
+
pointerEvents: "auto",
|
|
625
|
+
// Held invisible for the first frame only: placement needs the card's
|
|
626
|
+
// measured height, and a card that jumps is worse than one that fades.
|
|
627
|
+
opacity: cardSize ? 1 : 0,
|
|
628
|
+
gap: t.space(3),
|
|
629
|
+
padding: t.space(4),
|
|
630
|
+
borderRadius: t.radius.lg,
|
|
631
|
+
borderWidth: 1,
|
|
632
|
+
borderColor: t.color.borderDefault,
|
|
633
|
+
backgroundColor: t.color.surfaceRaised,
|
|
634
|
+
boxShadow: "0 12px 32px rgba(10,10,10,0.28)",
|
|
635
|
+
}}
|
|
636
|
+
>
|
|
637
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
638
|
+
<Text variant="label" color="tertiary" style={{ flex: 1 }}>
|
|
639
|
+
{`${session.flow.title} · ${session.index + 1}/${session.total}`}
|
|
640
|
+
</Text>
|
|
641
|
+
<IconButton name="x" size="sm" label="Close tour" onPress={skip} testID="onboarding-close" />
|
|
642
|
+
</View>
|
|
643
|
+
|
|
644
|
+
<View style={{ gap: t.space(1.5) }}>
|
|
645
|
+
<Text variant="h3" weight="semibold">
|
|
646
|
+
{step.title}
|
|
647
|
+
</Text>
|
|
648
|
+
<Text color="secondary">{step.body}</Text>
|
|
649
|
+
</View>
|
|
650
|
+
|
|
651
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
652
|
+
<Dots total={session.total} index={session.index} />
|
|
653
|
+
<View style={{ flex: 1 }} />
|
|
654
|
+
{session.index > 0 ? (
|
|
655
|
+
<Button title="Back" size="sm" onPress={back} testID="onboarding-back" />
|
|
656
|
+
) : (
|
|
657
|
+
<Button title="Skip" variant="ghost" size="sm" onPress={skip} testID="onboarding-skip" />
|
|
658
|
+
)}
|
|
659
|
+
<Button
|
|
660
|
+
title={isLast ? "Done" : "Next"}
|
|
661
|
+
variant="primary"
|
|
662
|
+
size="sm"
|
|
663
|
+
onPress={next}
|
|
664
|
+
testID="onboarding-next"
|
|
665
|
+
/>
|
|
666
|
+
</View>
|
|
667
|
+
</View>
|
|
668
|
+
</View>
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Backdrop swallows presses so a click meant for the tour never lands on the
|
|
673
|
+
// dimmed UI behind it. It deliberately does not dismiss: losing your place by
|
|
674
|
+
// clicking slightly wide of the card is not a thing a tour should do.
|
|
675
|
+
function Backdrop({ style }: { style: any }) {
|
|
676
|
+
return (
|
|
677
|
+
<Pressable
|
|
678
|
+
onPress={() => {}}
|
|
679
|
+
style={{ position: "absolute", backgroundColor: SCRIM, pointerEvents: "auto", ...style }}
|
|
680
|
+
/>
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function Dots({ total, index }: { total: number; index: number }) {
|
|
685
|
+
const t = useTheme();
|
|
686
|
+
return (
|
|
687
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(1) }}>
|
|
688
|
+
{Array.from({ length: total }).map((_, i) => (
|
|
689
|
+
<View
|
|
690
|
+
key={i}
|
|
691
|
+
style={{
|
|
692
|
+
width: i === index ? 14 : 5,
|
|
693
|
+
height: 5,
|
|
694
|
+
borderRadius: t.radius.pill,
|
|
695
|
+
backgroundColor: i === index ? t.color.textPrimary : t.color.borderDefault,
|
|
696
|
+
}}
|
|
697
|
+
/>
|
|
698
|
+
))}
|
|
699
|
+
</View>
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
type Hole = { left: number; top: number; width: number; height: number };
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Places the card beside the spotlight, preferring below, then above, then to
|
|
707
|
+
* the side, and finally centered. Exported for the tests, which are the only
|
|
708
|
+
* practical way to check the fallbacks — three of the four need a viewport too
|
|
709
|
+
* small to reach by hand.
|
|
710
|
+
*/
|
|
711
|
+
export function placeCard(
|
|
712
|
+
hole: Hole | null,
|
|
713
|
+
cardSize: { w: number; h: number } | null,
|
|
714
|
+
view: { vw: number; vh: number; cardW: number; placement: OnboardingPlacement },
|
|
715
|
+
): { left: number; top: number } {
|
|
716
|
+
const { vw, vh, cardW, placement } = view;
|
|
717
|
+
const h = cardSize?.h ?? 180;
|
|
718
|
+
|
|
719
|
+
if (!hole || placement === "center") {
|
|
720
|
+
return { left: Math.max((vw - cardW) / 2, EDGE), top: Math.max((vh - h) / 2, EDGE) };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const clampX = (x: number) => Math.min(Math.max(x, EDGE), Math.max(vw - cardW - EDGE, EDGE));
|
|
724
|
+
const clampY = (y: number) => Math.min(Math.max(y, EDGE), Math.max(vh - h - EDGE, EDGE));
|
|
725
|
+
|
|
726
|
+
const below = hole.top + hole.height + CARD_GAP;
|
|
727
|
+
const above = hole.top - CARD_GAP - h;
|
|
728
|
+
const rightOf = hole.left + hole.width + CARD_GAP;
|
|
729
|
+
const leftOf = hole.left - CARD_GAP - cardW;
|
|
730
|
+
|
|
731
|
+
const fitsBelow = below + h <= vh - EDGE;
|
|
732
|
+
const fitsAbove = above >= EDGE;
|
|
733
|
+
const fitsRight = rightOf + cardW <= vw - EDGE;
|
|
734
|
+
const fitsLeft = leftOf >= EDGE;
|
|
735
|
+
|
|
736
|
+
// An explicit placement is honoured when it fits; when it doesn't, falling
|
|
737
|
+
// back beats rendering the card off-screen.
|
|
738
|
+
if (placement === "bottom" && fitsBelow) return { left: clampX(centerX(hole, cardW)), top: below };
|
|
739
|
+
if (placement === "top" && fitsAbove) return { left: clampX(centerX(hole, cardW)), top: above };
|
|
740
|
+
if (placement === "right" && fitsRight) return { left: rightOf, top: clampY(centerY(hole, h)) };
|
|
741
|
+
if (placement === "left" && fitsLeft) return { left: leftOf, top: clampY(centerY(hole, h)) };
|
|
742
|
+
|
|
743
|
+
if (fitsBelow) return { left: clampX(centerX(hole, cardW)), top: below };
|
|
744
|
+
if (fitsAbove) return { left: clampX(centerX(hole, cardW)), top: above };
|
|
745
|
+
if (fitsRight) return { left: rightOf, top: clampY(centerY(hole, h)) };
|
|
746
|
+
if (fitsLeft) return { left: leftOf, top: clampY(centerY(hole, h)) };
|
|
747
|
+
return { left: Math.max((vw - cardW) / 2, EDGE), top: Math.max((vh - h) / 2, EDGE) };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const centerX = (hole: Hole, cardW: number) => hole.left + hole.width / 2 - cardW / 2;
|
|
751
|
+
const centerY = (hole: Hole, h: number) => hole.top + hole.height / 2 - h / 2;
|