@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.
@@ -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
- /** Scrolls the target into view (instant under reduced motion). */
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
@@ -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;
@@ -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
- /** Close the app dialog the replay was in (Escape to Radix). */
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: "closeAppDialog";
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;
@@ -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
- /** More anchors the user may use during this step. */
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.64";
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
- withProgress(d, { last_error: `forbidden:${step.requires}`.slice(0, 128) });
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 away = !!step.route && !matchRoute(step.route, env.pathname, env.search, env.code);
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
- if (!step.route || matchRoute(step.route, env.pathname, env.search, env.code)) {
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
- withProgress(d, { last_error: `api_${sig.status}:${step.key}`.slice(0, 128) });
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
- withProgress(d, { last_error: `target_missing:${anchor}`.slice(0, 128) });
4483
- save(d);
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 === "step" && s.phase === "missing") {
4512
- setScreen(d, { ...s, phase: "waiting" });
4513
- markReadyIfVisible(d, suite, env);
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: "closeAppDialog" }, { type: "toast", text: COPY.replayEnded });
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 step = mission.steps[s.index];
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
- /** Scrolls the target into view (instant under reduced motion). */
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: prefersReducedMotion() ? "auto" : "smooth" });
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 === "closeAppDialog") {
6072
- // After this render takes the guard away, Escape to the app's
6073
- // dialog (Radix listens on the document).
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
- if (el && el.contains(node))
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
- // A Radix modal hides everything else from screen readers; not us.
6598
- const obs = new MutationObserver(() => {
6599
- if (el.getAttribute("aria-hidden") === "true")
6600
- el.removeAttribute("aria-hidden");
6601
- if (el.hasAttribute("data-aria-hidden"))
6602
- el.removeAttribute("data-aria-hidden");
6603
- if (el.hasAttribute("inert"))
6604
- el.removeAttribute("inert");
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);