@eintrek/erp-shell 0.1.64 → 0.1.65
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/guide/dom.d.ts +15 -1
- package/dist/guide/guide-ui.d.ts +9 -1
- package/dist/guide/machine.d.ts +15 -3
- package/dist/guide/types.d.ts +35 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.esm.js +256 -40
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +256 -40
- package/dist/index.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +5 -5
package/dist/guide/dom.d.ts
CHANGED
|
@@ -86,7 +86,21 @@ export declare function revealPlan(rect: Rect, visible: Rect | null, viewportHei
|
|
|
86
86
|
block: "center" | "nearest";
|
|
87
87
|
inline: "nearest";
|
|
88
88
|
} | null;
|
|
89
|
-
/**
|
|
89
|
+
/** Room left beside a target scrolled into a sideways scroll box. */
|
|
90
|
+
export declare const SIDEWAYS_MARGIN = 12;
|
|
91
|
+
/**
|
|
92
|
+
* How far to scroll a sideways scroll box so the target sits inside its
|
|
93
|
+
* visible part (`box`: the box cut by the viewport) with `margin` to spare on
|
|
94
|
+
* both sides; 0 when it already does. Clamped to what the box can scroll.
|
|
95
|
+
* A target wider than the room is aligned to its start.
|
|
96
|
+
*/
|
|
97
|
+
export declare function sidewaysDelta(target: Rect, box: Rect, scrollLeft: number, maxScrollLeft: number, margin?: number): number;
|
|
98
|
+
/**
|
|
99
|
+
* Scrolls the target into view (instant under reduced motion). In a box that
|
|
100
|
+
* scrolls sideways (a row of tabs on a phone) the box is scrolled first,
|
|
101
|
+
* leaving SIDEWAYS_MARGIN beside the target so its whole label shows; then
|
|
102
|
+
* the page, up or down only.
|
|
103
|
+
*/
|
|
90
104
|
export declare function revealTarget(el: Element): void;
|
|
91
105
|
/**
|
|
92
106
|
* The scrim's blocking area: the screen (with a 10 px margin) minus the union
|
package/dist/guide/guide-ui.d.ts
CHANGED
|
@@ -22,9 +22,12 @@ export declare const IconTrophy: () => import("react/jsx-runtime").JSX.Element;
|
|
|
22
22
|
* rectangles that do not overlap (blockedRects). A press inside a hole
|
|
23
23
|
* reaches the page underneath; any other lands on the path (marked
|
|
24
24
|
* data-eg-block) and is cancelled.
|
|
25
|
+
* - `open`: the holes a press may go through, when not all of them (a "view"
|
|
26
|
+
* step's target is shown but not pressable). Default: every hole.
|
|
25
27
|
*/
|
|
26
|
-
export declare function Scrim({ holes, width, height }: {
|
|
28
|
+
export declare function Scrim({ holes, open, width, height }: {
|
|
27
29
|
holes: Rect[];
|
|
30
|
+
open?: Rect[];
|
|
28
31
|
width: number;
|
|
29
32
|
height: number;
|
|
30
33
|
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -72,6 +75,11 @@ export interface StepLayerProps {
|
|
|
72
75
|
covered?: boolean;
|
|
73
76
|
/** False for a "view" step with nothing to point at: the bubble is centred. */
|
|
74
77
|
hasTarget: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Presses on the target reach the page. False on a "view" step: the target
|
|
80
|
+
* is shown, read-only; ถัดไป is the way on. Default true.
|
|
81
|
+
*/
|
|
82
|
+
targetPressable?: boolean;
|
|
75
83
|
viewport: {
|
|
76
84
|
width: number;
|
|
77
85
|
height: number;
|
package/dist/guide/machine.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* No React, no DOM: the tests run it in Node (through the test loader).
|
|
8
8
|
*/
|
|
9
|
-
import type { GuideContext, GuideMission, GuideStep, GuideSuite, GuideText, GuideTrigger } from "./types";
|
|
9
|
+
import type { GuideAbortAction, GuideContext, GuideMission, GuideStep, GuideSuite, GuideText, GuideTrigger } from "./types";
|
|
10
10
|
import { type RouteParams } from "./patterns";
|
|
11
11
|
import type { GuideAudience, GuideProgress, MissionEntry } from "./progress";
|
|
12
12
|
export type StepPhase =
|
|
@@ -180,9 +180,13 @@ export type GuideEffect = {
|
|
|
180
180
|
type: "navigate";
|
|
181
181
|
href: string;
|
|
182
182
|
}
|
|
183
|
-
/**
|
|
183
|
+
/**
|
|
184
|
+
* Put the page back after a replay ended early: press the form's ยกเลิก,
|
|
185
|
+
* emit an app event, Escape to the open dialog (GuideAbortAction).
|
|
186
|
+
*/
|
|
184
187
|
| {
|
|
185
|
-
type: "
|
|
188
|
+
type: "abort";
|
|
189
|
+
actions: GuideAbortAction[];
|
|
186
190
|
};
|
|
187
191
|
export interface GuideResult {
|
|
188
192
|
state: GuideState;
|
|
@@ -216,6 +220,14 @@ export declare function spotlightTarget(step: GuideStep, ctx: GuideContext, brid
|
|
|
216
220
|
anchor: string;
|
|
217
221
|
id?: string;
|
|
218
222
|
} | null;
|
|
223
|
+
/**
|
|
224
|
+
* The page a step happens on: its own `route`, or else where the steps before
|
|
225
|
+
* it left the user (an earlier step's route trigger or route). Undefined when
|
|
226
|
+
* nothing says (a mission's first steps, which work from any page).
|
|
227
|
+
*/
|
|
228
|
+
export declare function stepHomeRoute(steps: GuideStep[], index: number): string | undefined;
|
|
229
|
+
/** What puts the page back when a replay ends at this step (see GuideStep.abort). */
|
|
230
|
+
export declare function abortActionsFor(mission: GuideMission, index: number): GuideAbortAction[];
|
|
219
231
|
/** Status of each mission as the panel shows it. */
|
|
220
232
|
export type MissionView = "todo" | "next" | "active" | "skipped" | "done" | "already" | "locked" | "waiting";
|
|
221
233
|
export declare function missionSettled(m: GuideMission, entry: MissionEntry | undefined, ctx: GuideContext): boolean;
|
package/dist/guide/types.d.ts
CHANGED
|
@@ -168,8 +168,18 @@ export interface GuideStep {
|
|
|
168
168
|
onMissing?: GuideOnMissing;
|
|
169
169
|
/** How long to wait for the target, while nothing is loading. 8000 ms. */
|
|
170
170
|
timeoutMs?: number;
|
|
171
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* More anchors the user may use during this step. A "view" step's own
|
|
173
|
+
* target is read-only (presses on it are blocked, like the rest of the
|
|
174
|
+
* page; ถัดไป is the only way on) unless it is listed here too.
|
|
175
|
+
*/
|
|
172
176
|
allow?: GuideAnchorKey[];
|
|
177
|
+
/**
|
|
178
|
+
* How to put the page back when a replay ends at this step without saving
|
|
179
|
+
* (จบโดยไม่บันทึก, จบการทบทวน): press the form's ยกเลิก, emit an app event,
|
|
180
|
+
* or Escape to the open dialog. Overrides the mission's `abort`.
|
|
181
|
+
*/
|
|
182
|
+
abort?: GuideAbortAction | GuideAbortAction[];
|
|
173
183
|
/**
|
|
174
184
|
* "commit" steps: what a refused save means. Return the key of the step to
|
|
175
185
|
* go back to (a field) and/or the line to show. Default: stay, show the
|
|
@@ -180,6 +190,24 @@ export interface GuideStep {
|
|
|
180
190
|
message?: string;
|
|
181
191
|
} | void;
|
|
182
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* One thing the engine does to put the page back when a replay ends early.
|
|
195
|
+
* Run in order, after the tour has let go of the page; an anchor that is not
|
|
196
|
+
* on the page is passed over.
|
|
197
|
+
*/
|
|
198
|
+
export type GuideAbortAction =
|
|
199
|
+
/** Press this anchor (the form's ยกเลิก, a dialog's close). */
|
|
200
|
+
{
|
|
201
|
+
click: GuideAnchorKey;
|
|
202
|
+
}
|
|
203
|
+
/** `emitGuideEvent(name)`: the page cancels its own editing. */
|
|
204
|
+
| {
|
|
205
|
+
event: string;
|
|
206
|
+
}
|
|
207
|
+
/** Escape to the open dialog (Radix closes on it). */
|
|
208
|
+
| {
|
|
209
|
+
escape: true;
|
|
210
|
+
};
|
|
183
211
|
/** What `capture` sees: the trigger that advanced the step. */
|
|
184
212
|
export interface GuideCaptureEvent {
|
|
185
213
|
route?: {
|
|
@@ -219,6 +247,12 @@ export interface GuideMission {
|
|
|
219
247
|
title?: GuideText;
|
|
220
248
|
subtitle?: GuideText;
|
|
221
249
|
};
|
|
250
|
+
/**
|
|
251
|
+
* How to put the page back when a replay of this mission ends without
|
|
252
|
+
* saving (a step's own `abort` wins). Default: Escape, and only from a step
|
|
253
|
+
* that is not a checkpoint (inside the dialog or form the tour opened).
|
|
254
|
+
*/
|
|
255
|
+
abort?: GuideAbortAction | GuideAbortAction[];
|
|
222
256
|
steps: GuideStep[];
|
|
223
257
|
}
|
|
224
258
|
/** Shown in place of the welcome while the org is not ready for the tour. */
|
package/dist/index.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export { HelpDialogHost, type HelpDialogHostProps } from "./help/help-dialog";
|
|
|
33
33
|
export { openHelp, closeHelp, type HelpAppId } from "./help/help-store";
|
|
34
34
|
export { describeBrowser, describeAttached, detectHelpApp, type HelpContext, } from "./help/help-context";
|
|
35
35
|
export { SHELL_VERSION } from "./version";
|
|
36
|
-
export type { GuideAnchorKey, GuideApiFailure, GuideApiMatch, GuideAppId, GuideCaptureEvent, GuideConfig, GuideContext, GuideFacts, GuideGate, GuideMission, GuideOnMissing, GuideStep, GuideStepKind, GuideSuite, GuideText, GuideTrigger, } from "./guide/types";
|
|
36
|
+
export type { GuideAbortAction, GuideAnchorKey, GuideApiFailure, GuideApiMatch, GuideAppId, GuideCaptureEvent, GuideConfig, GuideContext, GuideFacts, GuideGate, GuideMission, GuideOnMissing, GuideStep, GuideStepKind, GuideSuite, GuideText, GuideTrigger, } from "./guide/types";
|
|
37
37
|
export { API_EVENT as GUIDE_API_EVENT, GUIDE_EVENT_PREFIX, GUIDE_LAYER_ATTRIBUTE, emitGuideEvent, guideAnchor, reportApiEvent, type ApiEventDetail, type GuideAnchorProps, } from "./guide/events";
|
|
38
38
|
export { openGuidePanel, useGuideEntry, type GuideEntry } from "./guide/guide-store";
|
|
39
39
|
export { GuideHost, type GuideHostProps } from "./guide/guide-host";
|
package/dist/index.esm.js
CHANGED
|
@@ -1956,7 +1956,7 @@ function platformApiBase(app) {
|
|
|
1956
1956
|
* This package's version, attached to help messages so the back office can
|
|
1957
1957
|
* tell which shell a report came from. Keep in step with package.json.
|
|
1958
1958
|
*/
|
|
1959
|
-
const SHELL_VERSION = "0.1.
|
|
1959
|
+
const SHELL_VERSION = "0.1.65";
|
|
1960
1960
|
|
|
1961
1961
|
const HELP_APP_LABEL = {
|
|
1962
1962
|
platform: "แพลตฟอร์ม",
|
|
@@ -3892,6 +3892,52 @@ function needsBridge(step, env, ctx) {
|
|
|
3892
3892
|
!env.sidebarOpen &&
|
|
3893
3893
|
!env.isVisible(step.target, targetIdOf(step, ctx)));
|
|
3894
3894
|
}
|
|
3895
|
+
/**
|
|
3896
|
+
* The page a step happens on: its own `route`, or else where the steps before
|
|
3897
|
+
* it left the user (an earlier step's route trigger or route). Undefined when
|
|
3898
|
+
* nothing says (a mission's first steps, which work from any page).
|
|
3899
|
+
*/
|
|
3900
|
+
function stepHomeRoute(steps, index) {
|
|
3901
|
+
for (let j = Math.min(index, steps.length - 1); j >= 0; j--) {
|
|
3902
|
+
const s = steps[j];
|
|
3903
|
+
if (j < index) {
|
|
3904
|
+
// What this earlier step moved the user to comes after where it happened.
|
|
3905
|
+
const t = triggersOf(s).find((x) => "route" in x);
|
|
3906
|
+
if (t)
|
|
3907
|
+
return t.route;
|
|
3908
|
+
}
|
|
3909
|
+
if (s.route)
|
|
3910
|
+
return s.route;
|
|
3911
|
+
}
|
|
3912
|
+
return undefined;
|
|
3913
|
+
}
|
|
3914
|
+
/**
|
|
3915
|
+
* The page a step is shown on, or the "ทำต่อ" card when the user is elsewhere:
|
|
3916
|
+
* its route; for a checkpoint without one, the page the steps before it lead
|
|
3917
|
+
* to (so going back to it from another page cannot loop).
|
|
3918
|
+
*/
|
|
3919
|
+
function placeRoute(steps, index) {
|
|
3920
|
+
const step = steps[index];
|
|
3921
|
+
if (step.route)
|
|
3922
|
+
return step.route;
|
|
3923
|
+
return step.checkpoint ? stepHomeRoute(steps, index) : undefined;
|
|
3924
|
+
}
|
|
3925
|
+
/** The user is not on the page the step happens on. */
|
|
3926
|
+
function offRoute(steps, index, env) {
|
|
3927
|
+
const home = stepHomeRoute(steps, index);
|
|
3928
|
+
return !!home && !matchRoute(home, env.pathname, env.search, env.code);
|
|
3929
|
+
}
|
|
3930
|
+
/** What puts the page back when a replay ends at this step (see GuideStep.abort). */
|
|
3931
|
+
function abortActionsFor(mission, index) {
|
|
3932
|
+
const step = mission.steps[index];
|
|
3933
|
+
const own = step?.abort ?? mission.abort;
|
|
3934
|
+
if (own)
|
|
3935
|
+
return Array.isArray(own) ? own : [own];
|
|
3936
|
+
// Default: Escape to the dialog the tour opened; nothing on a stable page.
|
|
3937
|
+
if (!step || index === 0 || step.checkpoint)
|
|
3938
|
+
return [];
|
|
3939
|
+
return [{ escape: true }];
|
|
3940
|
+
}
|
|
3895
3941
|
function routeParamsFor(step, env) {
|
|
3896
3942
|
if (!step.route)
|
|
3897
3943
|
return {};
|
|
@@ -4056,7 +4102,9 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
4056
4102
|
const params = routeParamsFor(step, env);
|
|
4057
4103
|
const ctx = guideContext(env, d.state.progress.context, params);
|
|
4058
4104
|
if (step.requires && !safe(() => env.can(step.requires), true)) {
|
|
4059
|
-
|
|
4105
|
+
// A replay writes nothing, not even what went wrong.
|
|
4106
|
+
if (!opts.replay)
|
|
4107
|
+
withProgress(d, { last_error: `forbidden:${step.requires}`.slice(0, 128) });
|
|
4060
4108
|
i++;
|
|
4061
4109
|
continue;
|
|
4062
4110
|
}
|
|
@@ -4082,7 +4130,8 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
4082
4130
|
}
|
|
4083
4131
|
const step = steps[i];
|
|
4084
4132
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4085
|
-
const
|
|
4133
|
+
const where = placeRoute(steps, i);
|
|
4134
|
+
const away = !!where && !matchRoute(where, env.pathname, env.search, env.code);
|
|
4086
4135
|
const bridge = !away && needsBridge(step, env, ctx);
|
|
4087
4136
|
setScreen(d, {
|
|
4088
4137
|
name: "step",
|
|
@@ -4128,6 +4177,35 @@ function markReadyIfVisible(d, suite, env) {
|
|
|
4128
4177
|
d.effects.push({ type: "announce", text: s.error.title });
|
|
4129
4178
|
}
|
|
4130
4179
|
}
|
|
4180
|
+
/** Where to send the user to resume at a step: its resumeUrl, or its page filled in. */
|
|
4181
|
+
function resumeHref(mission, index, d, env) {
|
|
4182
|
+
const step = mission.steps[index];
|
|
4183
|
+
const ctx = guideContext(env, d.state.progress.context);
|
|
4184
|
+
const where = placeRoute(mission.steps, index);
|
|
4185
|
+
return (safe(() => step.resumeUrl?.(ctx), undefined) ??
|
|
4186
|
+
(where ? fillRoute(where, { ...d.state.progress.context, code: env.code }) : null));
|
|
4187
|
+
}
|
|
4188
|
+
/**
|
|
4189
|
+
* The user left the page the step happens on (a link, the browser's back):
|
|
4190
|
+
* back to the last checkpoint. On another page that is the "ทำต่อ" card;
|
|
4191
|
+
* `navigate` (ลองอีกครั้ง) goes there at once.
|
|
4192
|
+
*/
|
|
4193
|
+
function recoverOffRoute(d, suite, env, navigate) {
|
|
4194
|
+
const s = d.state.screen;
|
|
4195
|
+
if (s.name !== "step")
|
|
4196
|
+
return;
|
|
4197
|
+
const mission = findMission(suite, s.mission);
|
|
4198
|
+
if (!mission)
|
|
4199
|
+
return;
|
|
4200
|
+
const cp = checkpointAtOrBefore(mission.steps, s.index);
|
|
4201
|
+
enterStep(d, suite, env, mission, cp, { replay: s.replay });
|
|
4202
|
+
const now = d.state.screen;
|
|
4203
|
+
if (navigate && now.name === "step" && now.phase === "away") {
|
|
4204
|
+
const href = resumeHref(mission, now.index, d, env);
|
|
4205
|
+
if (href)
|
|
4206
|
+
d.effects.push({ type: "navigate", href });
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4131
4209
|
function captureInto(d, step, e, ctx) {
|
|
4132
4210
|
if (!step.capture)
|
|
4133
4211
|
return;
|
|
@@ -4265,6 +4343,17 @@ function apiFailureFor(step, sig) {
|
|
|
4265
4343
|
(!t.api.method || t.api.method.toUpperCase() === sig.method.toUpperCase()) &&
|
|
4266
4344
|
matchApiPath(t.api.path, sig.path));
|
|
4267
4345
|
}
|
|
4346
|
+
/** A replay ends early: what puts the page back (nothing on another page). */
|
|
4347
|
+
function pushAbort(d, suite, s) {
|
|
4348
|
+
if (s.phase === "away")
|
|
4349
|
+
return;
|
|
4350
|
+
const mission = findMission(suite, s.mission);
|
|
4351
|
+
if (!mission)
|
|
4352
|
+
return;
|
|
4353
|
+
const actions = abortActionsFor(mission, s.index);
|
|
4354
|
+
if (actions.length)
|
|
4355
|
+
d.effects.push({ type: "abort", actions });
|
|
4356
|
+
}
|
|
4268
4357
|
function reduceGuide(state, action, suite, env) {
|
|
4269
4358
|
const d = { state, effects: [] };
|
|
4270
4359
|
const s = state.screen;
|
|
@@ -4345,7 +4434,8 @@ function reduceGuide(state, action, suite, env) {
|
|
|
4345
4434
|
const step = mission.steps[s.index];
|
|
4346
4435
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4347
4436
|
if (s.phase === "away") {
|
|
4348
|
-
|
|
4437
|
+
const where = placeRoute(mission.steps, s.index);
|
|
4438
|
+
if (!where || matchRoute(where, env.pathname, env.search, env.code)) {
|
|
4349
4439
|
// Arrived: run the step again from here (it may already be passed).
|
|
4350
4440
|
enterStep(d, suite, env, mission, s.index, { replay: s.replay, error: s.error });
|
|
4351
4441
|
}
|
|
@@ -4362,6 +4452,11 @@ function reduceGuide(state, action, suite, env) {
|
|
|
4362
4452
|
break;
|
|
4363
4453
|
}
|
|
4364
4454
|
}
|
|
4455
|
+
if (!s.bridge && !stepTargetVisible(step, env, ctx, false) && offRoute(mission.steps, s.index, env)) {
|
|
4456
|
+
// The target is missing because the page is another one.
|
|
4457
|
+
recoverOffRoute(d, suite, env, false);
|
|
4458
|
+
break;
|
|
4459
|
+
}
|
|
4365
4460
|
if (armed !== s.armed)
|
|
4366
4461
|
setScreen(d, { ...s, armed });
|
|
4367
4462
|
const cur = d.state.screen;
|
|
@@ -4416,7 +4511,8 @@ function reduceGuide(state, action, suite, env) {
|
|
|
4416
4511
|
: { title: COPY.saveFailed, detail: sig.message };
|
|
4417
4512
|
setScreen(d, { ...s, error });
|
|
4418
4513
|
d.effects.push({ type: "announce", text: [error.title, error.detail].filter(Boolean).join(". ") });
|
|
4419
|
-
|
|
4514
|
+
if (!s.replay)
|
|
4515
|
+
withProgress(d, { last_error: `api_${sig.status}:${step.key}`.slice(0, 128) });
|
|
4420
4516
|
}
|
|
4421
4517
|
break;
|
|
4422
4518
|
}
|
|
@@ -4461,6 +4557,10 @@ function reduceGuide(state, action, suite, env) {
|
|
|
4461
4557
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4462
4558
|
if (stepTargetVisible(step, env, ctx, s.bridge))
|
|
4463
4559
|
break;
|
|
4560
|
+
if (!s.bridge && offRoute(mission.steps, s.index, env)) {
|
|
4561
|
+
recoverOffRoute(d, suite, env, false);
|
|
4562
|
+
break;
|
|
4563
|
+
}
|
|
4464
4564
|
const cp = checkpointAtOrBefore(mission.steps, s.index);
|
|
4465
4565
|
if (cp < s.index && !s.bridge) {
|
|
4466
4566
|
// A dialog or a menu closed under the step: back to what opens it.
|
|
@@ -4478,9 +4578,16 @@ function reduceGuide(state, action, suite, env) {
|
|
|
4478
4578
|
if (!mission)
|
|
4479
4579
|
break;
|
|
4480
4580
|
const step = mission.steps[s.index];
|
|
4581
|
+
if (!s.bridge && offRoute(mission.steps, s.index, env)) {
|
|
4582
|
+
recoverOffRoute(d, suite, env, false);
|
|
4583
|
+
break;
|
|
4584
|
+
}
|
|
4481
4585
|
const anchor = s.bridge ? BRIDGE_TARGET : (step.target ?? step.key);
|
|
4482
|
-
|
|
4483
|
-
|
|
4586
|
+
// Diagnostic only: kept with the progress, sent with its next save (a
|
|
4587
|
+
// replay keeps nothing). The same error again changes nothing, so it
|
|
4588
|
+
// never sends a PUT on its own.
|
|
4589
|
+
if (!s.replay)
|
|
4590
|
+
withProgress(d, { last_error: `target_missing:${anchor}`.slice(0, 128) });
|
|
4484
4591
|
const misses = s.misses + 1;
|
|
4485
4592
|
const policy = step.onMissing ?? "retry";
|
|
4486
4593
|
const cp = checkpointAtOrBefore(mission.steps, s.index);
|
|
@@ -4507,12 +4614,19 @@ function reduceGuide(state, action, suite, env) {
|
|
|
4507
4614
|
}
|
|
4508
4615
|
break;
|
|
4509
4616
|
}
|
|
4510
|
-
case "retry":
|
|
4511
|
-
if (s.name
|
|
4512
|
-
|
|
4513
|
-
|
|
4617
|
+
case "retry": {
|
|
4618
|
+
if (s.name !== "step" || s.phase !== "missing")
|
|
4619
|
+
break;
|
|
4620
|
+
const mission = findMission(suite, s.mission);
|
|
4621
|
+
if (mission && !s.bridge && offRoute(mission.steps, s.index, env)) {
|
|
4622
|
+
// Polling again cannot help on another page: go back to the step's.
|
|
4623
|
+
recoverOffRoute(d, suite, env, true);
|
|
4624
|
+
break;
|
|
4514
4625
|
}
|
|
4626
|
+
setScreen(d, { ...s, phase: "waiting" });
|
|
4627
|
+
markReadyIfVisible(d, suite, env);
|
|
4515
4628
|
break;
|
|
4629
|
+
}
|
|
4516
4630
|
case "blocked":
|
|
4517
4631
|
if (s.name === "step")
|
|
4518
4632
|
d.state = { ...d.state, nudge: d.state.nudge + 1 };
|
|
@@ -4521,6 +4635,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
4521
4635
|
if (s.name !== "step")
|
|
4522
4636
|
break;
|
|
4523
4637
|
if (s.replay) {
|
|
4638
|
+
pushAbort(d, suite, s);
|
|
4524
4639
|
setScreen(d, { name: "idle" });
|
|
4525
4640
|
d.effects.push({ type: "toast", text: COPY.replayEnded });
|
|
4526
4641
|
}
|
|
@@ -4541,8 +4656,9 @@ function reduceGuide(state, action, suite, env) {
|
|
|
4541
4656
|
case "endWithoutSave":
|
|
4542
4657
|
if (s.name !== "step" || !s.replay)
|
|
4543
4658
|
break;
|
|
4659
|
+
pushAbort(d, suite, s);
|
|
4544
4660
|
setScreen(d, { name: "idle" });
|
|
4545
|
-
d.effects.push({ type: "
|
|
4661
|
+
d.effects.push({ type: "toast", text: COPY.replayEnded });
|
|
4546
4662
|
break;
|
|
4547
4663
|
case "goResume": {
|
|
4548
4664
|
if (s.name !== "step" || s.phase !== "away")
|
|
@@ -4550,10 +4666,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
4550
4666
|
const mission = findMission(suite, s.mission);
|
|
4551
4667
|
if (!mission)
|
|
4552
4668
|
break;
|
|
4553
|
-
const
|
|
4554
|
-
const ctx = guideContext(env, d.state.progress.context);
|
|
4555
|
-
const href = safe(() => step.resumeUrl?.(ctx), undefined) ??
|
|
4556
|
-
(step.route ? fillRoute(step.route, { ...d.state.progress.context, code: env.code }) : null);
|
|
4669
|
+
const href = resumeHref(mission, s.index, d, env);
|
|
4557
4670
|
if (href)
|
|
4558
4671
|
d.effects.push({ type: "navigate", href });
|
|
4559
4672
|
break;
|
|
@@ -5163,14 +5276,57 @@ function revealPlan(rect, visible, viewportHeight) {
|
|
|
5163
5276
|
return null;
|
|
5164
5277
|
return { block: offVertically ? "center" : "nearest", inline: "nearest" };
|
|
5165
5278
|
}
|
|
5166
|
-
/**
|
|
5279
|
+
/** Room left beside a target scrolled into a sideways scroll box. */
|
|
5280
|
+
const SIDEWAYS_MARGIN = 12;
|
|
5281
|
+
/**
|
|
5282
|
+
* How far to scroll a sideways scroll box so the target sits inside its
|
|
5283
|
+
* visible part (`box`: the box cut by the viewport) with `margin` to spare on
|
|
5284
|
+
* both sides; 0 when it already does. Clamped to what the box can scroll.
|
|
5285
|
+
* A target wider than the room is aligned to its start.
|
|
5286
|
+
*/
|
|
5287
|
+
function sidewaysDelta(target, box, scrollLeft, maxScrollLeft, margin = SIDEWAYS_MARGIN) {
|
|
5288
|
+
const left = box.x + margin;
|
|
5289
|
+
const right = box.x + box.width - margin;
|
|
5290
|
+
let delta = 0;
|
|
5291
|
+
if (target.x < left || target.width > right - left)
|
|
5292
|
+
delta = target.x - left;
|
|
5293
|
+
else if (target.x + target.width > right)
|
|
5294
|
+
delta = target.x + target.width - right;
|
|
5295
|
+
const next = Math.min(Math.max(scrollLeft + delta, 0), Math.max(maxScrollLeft, 0));
|
|
5296
|
+
return next - scrollLeft;
|
|
5297
|
+
}
|
|
5298
|
+
/** The nearest ancestor that scrolls sideways and has somewhere to scroll. */
|
|
5299
|
+
function sidewaysScroller(el) {
|
|
5300
|
+
if (typeof getComputedStyle !== "function")
|
|
5301
|
+
return null;
|
|
5302
|
+
for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
|
|
5303
|
+
if (scrollsX(getComputedStyle(n).overflowX) && n.scrollWidth > n.clientWidth + 1)
|
|
5304
|
+
return n;
|
|
5305
|
+
}
|
|
5306
|
+
return null;
|
|
5307
|
+
}
|
|
5308
|
+
/**
|
|
5309
|
+
* Scrolls the target into view (instant under reduced motion). In a box that
|
|
5310
|
+
* scrolls sideways (a row of tabs on a phone) the box is scrolled first,
|
|
5311
|
+
* leaving SIDEWAYS_MARGIN beside the target so its whole label shows; then
|
|
5312
|
+
* the page, up or down only.
|
|
5313
|
+
*/
|
|
5167
5314
|
function revealTarget(el) {
|
|
5315
|
+
const behavior = prefersReducedMotion() ? "auto" : "smooth";
|
|
5316
|
+
const scroller = sidewaysScroller(el);
|
|
5317
|
+
if (scroller) {
|
|
5318
|
+
const box = intersectRect(rectOf(scroller), viewportRect()) ?? rectOf(scroller);
|
|
5319
|
+
const dx = sidewaysDelta(rectOf(el), box, scroller.scrollLeft, scroller.scrollWidth - scroller.clientWidth);
|
|
5320
|
+
// Instant: the page's own scroll below must not cancel it.
|
|
5321
|
+
if (Math.abs(dx) >= 1)
|
|
5322
|
+
scroller.scrollLeft += dx;
|
|
5323
|
+
}
|
|
5168
5324
|
const vh = window.visualViewport?.height ?? window.innerHeight;
|
|
5169
5325
|
const plan = revealPlan(rectOf(el), visibleRectOf(el), vh);
|
|
5170
5326
|
if (!plan)
|
|
5171
5327
|
return;
|
|
5172
5328
|
try {
|
|
5173
|
-
el.scrollIntoView({ ...plan, behavior
|
|
5329
|
+
el.scrollIntoView({ ...plan, behavior });
|
|
5174
5330
|
}
|
|
5175
5331
|
catch {
|
|
5176
5332
|
el.scrollIntoView();
|
|
@@ -5446,10 +5602,12 @@ const rectPath = (r) => `M${r.x} ${r.y}H${r.x + r.width}V${r.y + r.height}H${r.x
|
|
|
5446
5602
|
* rectangles that do not overlap (blockedRects). A press inside a hole
|
|
5447
5603
|
* reaches the page underneath; any other lands on the path (marked
|
|
5448
5604
|
* data-eg-block) and is cancelled.
|
|
5605
|
+
* - `open`: the holes a press may go through, when not all of them (a "view"
|
|
5606
|
+
* step's target is shown but not pressable). Default: every hole.
|
|
5449
5607
|
*/
|
|
5450
|
-
function Scrim({ holes, width, height }) {
|
|
5608
|
+
function Scrim({ holes, open, width, height }) {
|
|
5451
5609
|
const maskId = "eg-scrim-mask-" + React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
|
|
5452
|
-
const block = blockedRects(holes, width, height).map(rectPath).join("");
|
|
5610
|
+
const block = blockedRects(open ?? holes, width, height).map(rectPath).join("");
|
|
5453
5611
|
return (jsxs("svg", { className: "eg-scrim-svg", width: width, height: height, "aria-hidden": "true", children: [holes.length ? (jsx("defs", { children: jsxs("mask", { id: maskId, maskUnits: "userSpaceOnUse", x: -10, y: -10, width: width + 20, height: height + 20, children: [jsx("rect", { x: -10, y: -10, width: width + 20, height: height + 20, fill: "#fff" }), holes.map((h, i) => (jsx("path", { d: roundRect(h, 12), fill: "#000" }, i)))] }) })) : null, jsx("rect", { className: "eg-scrim-fill", x: -10, y: -10, width: width + 20, height: height + 20, mask: holes.length ? `url(#${maskId})` : undefined }), jsx("path", { className: "eg-scrim-hit", d: block, "data-eg-block": "" })] }));
|
|
5454
5612
|
}
|
|
5455
5613
|
/** Whether two rectangles overlap. */
|
|
@@ -5615,6 +5773,7 @@ function StepLayer(p) {
|
|
|
5615
5773
|
const popovers = p.popovers ?? [];
|
|
5616
5774
|
const covered = !!p.covered;
|
|
5617
5775
|
const holes = [...(spot && !covered ? [spot] : []), ...popovers, ...p.holes];
|
|
5776
|
+
const openHoles = p.targetPressable === false ? [...popovers, ...p.holes] : holes;
|
|
5618
5777
|
const ringShown = !!spot && p.phase === "ready" && !covered && !popovers.some(o => meets(o, spot));
|
|
5619
5778
|
const hand = spot && p.showHand && p.phase === "ready" && !covered && !popovers.length ? handPosition(spot, viewport.height) : null;
|
|
5620
5779
|
let arrowStyle;
|
|
@@ -5628,7 +5787,7 @@ function StepLayer(p) {
|
|
|
5628
5787
|
else
|
|
5629
5788
|
arrowStyle = { bottom: -7, left: pos.ax };
|
|
5630
5789
|
}
|
|
5631
|
-
return (jsxs(Fragment$1, { children: [jsx(Scrim, { holes: p.phase === "ready" ? holes : [], width: viewport.width, height: Math.max(viewport.height + viewport.offsetTop, 0) }), spot && ringShown ? (jsx("div", { ref: ringRef, className: "eg-ring" + (p.moving ? " eg-move" : ""), style: { left: spot.x, top: spot.y, width: spot.width, height: spot.height }, "aria-hidden": "true" })) : null, hand ? (jsx("div", { className: "eg-hand" + (hand.flip ? " eg-flip" : ""), style: { left: hand.left, top: hand.top }, "aria-hidden": "true", children: jsx("svg", { viewBox: "0 0 24 24", children: jsx("path", { d: HAND_PATH }) }) })) : null, showBubble ? (jsxs("div", { ref: bubbleRef, className: "eg-bubble" + (p.bubble.error ? " eg-err" : ""), role: "dialog", "aria-modal": "false", "aria-labelledby": BUBBLE_TITLE_ID, "aria-describedby": BUBBLE_WHY_ID, tabIndex: -1, "data-eg-bubble": "", style: {
|
|
5790
|
+
return (jsxs(Fragment$1, { children: [jsx(Scrim, { holes: p.phase === "ready" ? holes : [], open: p.phase === "ready" ? openHoles : [], width: viewport.width, height: Math.max(viewport.height + viewport.offsetTop, 0) }), spot && ringShown ? (jsx("div", { ref: ringRef, className: "eg-ring" + (p.moving ? " eg-move" : ""), style: { left: spot.x, top: spot.y, width: spot.width, height: spot.height }, "aria-hidden": "true" })) : null, hand ? (jsx("div", { className: "eg-hand" + (hand.flip ? " eg-flip" : ""), style: { left: hand.left, top: hand.top }, "aria-hidden": "true", children: jsx("svg", { viewBox: "0 0 24 24", children: jsx("path", { d: HAND_PATH }) }) })) : null, showBubble ? (jsxs("div", { ref: bubbleRef, className: "eg-bubble" + (p.bubble.error ? " eg-err" : ""), role: "dialog", "aria-modal": "false", "aria-labelledby": BUBBLE_TITLE_ID, "aria-describedby": BUBBLE_WHY_ID, tabIndex: -1, "data-eg-bubble": "", style: {
|
|
5632
5791
|
left: pos?.left ?? 0,
|
|
5633
5792
|
top: pos?.top ?? 0,
|
|
5634
5793
|
width: phone ? viewport.width - 32 : undefined,
|
|
@@ -5910,6 +6069,45 @@ function usePrimaryTab(name) {
|
|
|
5910
6069
|
return primary;
|
|
5911
6070
|
}
|
|
5912
6071
|
/* ------------------------------------------------------------------ */
|
|
6072
|
+
/* Putting the page back after a replay */
|
|
6073
|
+
/* ------------------------------------------------------------------ */
|
|
6074
|
+
const ABORT_DELAY_MS = 60;
|
|
6075
|
+
const ABORT_GAP_MS = 150;
|
|
6076
|
+
/**
|
|
6077
|
+
* Runs a replay's abort actions in order, once the tour has let go of the
|
|
6078
|
+
* page: press an anchor (passed over when it is not there), emit an app
|
|
6079
|
+
* event, or Escape to the open dialog (Radix listens on the document).
|
|
6080
|
+
*/
|
|
6081
|
+
function runAbort(actions) {
|
|
6082
|
+
actions.forEach((a, i) => {
|
|
6083
|
+
setTimeout(() => {
|
|
6084
|
+
try {
|
|
6085
|
+
if ("click" in a) {
|
|
6086
|
+
findAnchor(a.click)?.click();
|
|
6087
|
+
}
|
|
6088
|
+
else if ("event" in a) {
|
|
6089
|
+
emitGuideEvent(a.event);
|
|
6090
|
+
}
|
|
6091
|
+
else if ("escape" in a) {
|
|
6092
|
+
const t = document.activeElement ?? document.body;
|
|
6093
|
+
t.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true, cancelable: true }));
|
|
6094
|
+
}
|
|
6095
|
+
}
|
|
6096
|
+
catch {
|
|
6097
|
+
/* never breaks the app */
|
|
6098
|
+
}
|
|
6099
|
+
}, ABORT_DELAY_MS + i * ABORT_GAP_MS);
|
|
6100
|
+
});
|
|
6101
|
+
}
|
|
6102
|
+
/** A step's own target takes presses: not on a "view" step, unless the step allows it. */
|
|
6103
|
+
function targetPressable(step, bridge) {
|
|
6104
|
+
if (!step || bridge)
|
|
6105
|
+
return true;
|
|
6106
|
+
if (stepKind(step) !== "view")
|
|
6107
|
+
return true;
|
|
6108
|
+
return !!step.target && (step.allow ?? []).includes(step.target);
|
|
6109
|
+
}
|
|
6110
|
+
/* ------------------------------------------------------------------ */
|
|
5913
6111
|
/* Host */
|
|
5914
6112
|
/* ------------------------------------------------------------------ */
|
|
5915
6113
|
const TARGET_PAD = 6;
|
|
@@ -6068,13 +6266,9 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
6068
6266
|
else
|
|
6069
6267
|
router.push(e.href);
|
|
6070
6268
|
}
|
|
6071
|
-
else if (e.type === "
|
|
6072
|
-
// After this render takes the guard away,
|
|
6073
|
-
|
|
6074
|
-
setTimeout(() => {
|
|
6075
|
-
const t = document.activeElement ?? document.body;
|
|
6076
|
-
t.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true, cancelable: true }));
|
|
6077
|
-
}, 60);
|
|
6269
|
+
else if (e.type === "abort") {
|
|
6270
|
+
// After this render takes the guard away, put the page back.
|
|
6271
|
+
runAbort(e.actions);
|
|
6078
6272
|
}
|
|
6079
6273
|
}
|
|
6080
6274
|
catch {
|
|
@@ -6417,7 +6611,10 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
6417
6611
|
if (!(node instanceof Node))
|
|
6418
6612
|
return false;
|
|
6419
6613
|
const el = targetElRef.current;
|
|
6420
|
-
|
|
6614
|
+
// A "view" step's target is there to be read: ถัดไป is the way on.
|
|
6615
|
+
const sc = stateRef.current?.screen;
|
|
6616
|
+
const pressable = targetPressable(stepInfoRef.current?.step, sc?.name === "step" && sc.bridge);
|
|
6617
|
+
if (el && pressable && el.contains(node))
|
|
6421
6618
|
return true;
|
|
6422
6619
|
const elNode = node instanceof Element ? node : node.parentElement;
|
|
6423
6620
|
if (!elNode)
|
|
@@ -6516,7 +6713,7 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
6516
6713
|
const el = targetElRef.current;
|
|
6517
6714
|
const step = stepInfoRef.current?.step;
|
|
6518
6715
|
const isInput = !!step && !sc.bridge && stepKind(step) === "input";
|
|
6519
|
-
const inTargetList = el ? focusablesIn(isInput ? (fieldOf(el) ?? el) : el) : [];
|
|
6716
|
+
const inTargetList = el && targetPressable(step, sc.bridge) ? focusablesIn(isInput ? (fieldOf(el) ?? el) : el) : [];
|
|
6520
6717
|
const bubble = layer?.querySelector("[data-eg-bubble]") ?? null;
|
|
6521
6718
|
list = [...inTargetList, ...(bubble ? Array.from(bubble.querySelectorAll(FOCUSABLE)) : [])];
|
|
6522
6719
|
for (const p of openPoppers())
|
|
@@ -6591,19 +6788,38 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
6591
6788
|
useEffect(() => {
|
|
6592
6789
|
const el = document.createElement("div");
|
|
6593
6790
|
el.setAttribute(GUIDE_LAYER_ATTRIBUTE, "");
|
|
6791
|
+
// A Radix modal (dialog, select, menu) hides everything outside it from
|
|
6792
|
+
// screen readers with aria-hidden's hideOthers. It leaves [aria-live]
|
|
6793
|
+
// elements and their insides alone, but walks into their ancestors and
|
|
6794
|
+
// hides the siblings: with only the status line live, the bubble and its
|
|
6795
|
+
// buttons were hidden. "off" announces nothing; it only makes the whole
|
|
6796
|
+
// layer one of the kept elements.
|
|
6797
|
+
el.setAttribute("aria-live", "off");
|
|
6594
6798
|
document.body.appendChild(el);
|
|
6595
6799
|
layerRef.current = el;
|
|
6596
6800
|
setLayer(el);
|
|
6597
|
-
//
|
|
6598
|
-
|
|
6599
|
-
|
|
6600
|
-
|
|
6601
|
-
|
|
6602
|
-
|
|
6603
|
-
|
|
6604
|
-
|
|
6801
|
+
// And should anything still hide the layer or a part of it (another
|
|
6802
|
+
// library, a later hideOthers), take it off again. hideOthers marks what
|
|
6803
|
+
// it hides with data-aria-hidden (the layer never sets that, or inert);
|
|
6804
|
+
// aria-hidden goes only where it was just added, so the layer's own
|
|
6805
|
+
// decorative aria-hidden stays.
|
|
6806
|
+
const obs = new MutationObserver(records => {
|
|
6807
|
+
for (const r of records) {
|
|
6808
|
+
const n = r.target;
|
|
6809
|
+
if (!(n instanceof Element))
|
|
6810
|
+
continue;
|
|
6811
|
+
if (r.attributeName === "inert" && n.hasAttribute("inert"))
|
|
6812
|
+
n.removeAttribute("inert");
|
|
6813
|
+
else if (r.attributeName === "data-aria-hidden" && n.hasAttribute("data-aria-hidden"))
|
|
6814
|
+
n.removeAttribute("data-aria-hidden");
|
|
6815
|
+
else if (r.attributeName === "aria-hidden" &&
|
|
6816
|
+
n.getAttribute("aria-hidden") === "true" &&
|
|
6817
|
+
(n === el || r.oldValue === null)) {
|
|
6818
|
+
n.removeAttribute("aria-hidden");
|
|
6819
|
+
}
|
|
6820
|
+
}
|
|
6605
6821
|
});
|
|
6606
|
-
obs.observe(el, { attributes: true, attributeFilter: ["aria-hidden", "data-aria-hidden", "inert"] });
|
|
6822
|
+
obs.observe(el, { attributes: true, attributeOldValue: true, subtree: true, attributeFilter: ["aria-hidden", "data-aria-hidden", "inert"] });
|
|
6607
6823
|
return () => {
|
|
6608
6824
|
obs.disconnect();
|
|
6609
6825
|
el.remove();
|
|
@@ -6681,7 +6897,7 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
6681
6897
|
error: screen.error,
|
|
6682
6898
|
showNext: !bridge && (kind === "input" || kind === "view"),
|
|
6683
6899
|
showEndWithoutSave: !bridge && screen.replay && kind === "commit",
|
|
6684
|
-
}, hasTarget: bridge || !!step.target, phase: screen.phase, confirmExit: screen.confirmExit, nudge: state.nudge, moving: moving, onExit: () => dispatch({ type: "exit" }), onNext: () => dispatch({ type: "next" }), onEndWithoutSave: () => dispatch({ type: "endWithoutSave" }), onRetry: () => dispatch({ type: "retry" }), onGoResume: () => dispatch({ type: "goResume" }), onExitConfirm: () => dispatch({ type: "exitConfirm" }), onExitCancel: () => dispatch({ type: "exitCancel" }) }));
|
|
6900
|
+
}, hasTarget: bridge || !!step.target, targetPressable: targetPressable(step, bridge), phase: screen.phase, confirmExit: screen.confirmExit, nudge: state.nudge, moving: moving, onExit: () => dispatch({ type: "exit" }), onNext: () => dispatch({ type: "next" }), onEndWithoutSave: () => dispatch({ type: "endWithoutSave" }), onRetry: () => dispatch({ type: "retry" }), onGoResume: () => dispatch({ type: "goResume" }), onExitConfirm: () => dispatch({ type: "exitConfirm" }), onExitCancel: () => dispatch({ type: "exitCancel" }) }));
|
|
6685
6901
|
}
|
|
6686
6902
|
else if (screen.name === "celebrate" && celebrateShown) {
|
|
6687
6903
|
const mission = findMission(suite, screen.mission);
|