@eintrek/erp-shell 0.1.63 → 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 +81 -1
- package/dist/guide/guide-styles.d.ts +1 -1
- package/dist/guide/guide-ui.d.ts +47 -6
- package/dist/guide/machine.d.ts +15 -3
- package/dist/guide/progress.d.ts +7 -0
- package/dist/guide/types.d.ts +35 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.esm.js +598 -92
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +597 -91
- package/dist/index.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +5 -5
package/dist/index.esm.js
CHANGED
|
@@ -14,7 +14,7 @@ import { createPortal } from 'react-dom';
|
|
|
14
14
|
import { useTheme, ThemeProvider as ThemeProvider$1 } from 'next-themes';
|
|
15
15
|
import axios, { isAxiosError, AxiosHeaders } from 'axios';
|
|
16
16
|
export { isAxiosError } from 'axios';
|
|
17
|
-
import { computePosition, offset,
|
|
17
|
+
import { computePosition, offset, shift, arrow } from '@floating-ui/dom';
|
|
18
18
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -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 {};
|
|
@@ -4033,7 +4079,7 @@ function announceStep(d, suite, env) {
|
|
|
4033
4079
|
const step = mission.steps[s.index];
|
|
4034
4080
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4035
4081
|
const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
|
|
4036
|
-
const why = s.bridge ? COPY.bridgeWhy(suite.appName) : resolveText(step.why, ctx);
|
|
4082
|
+
const why = s.bridge ? COPY.bridgeWhy(suite.appName) : plainTitle(resolveText(step.why, ctx));
|
|
4037
4083
|
d.effects.push({
|
|
4038
4084
|
type: "announce",
|
|
4039
4085
|
text: `ขั้นที่ ${s.index + 1} จาก ${mission.steps.length}. ${title}${why ? `. ${why}` : ""}`,
|
|
@@ -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;
|
|
@@ -4195,6 +4273,7 @@ function startMission(d, suite, env, key, replay) {
|
|
|
4195
4273
|
const entry = d.state.progress.missions[key];
|
|
4196
4274
|
// A mission already done is only ever replayed; its done status stays.
|
|
4197
4275
|
const isReplay = replay || entry?.status === "done";
|
|
4276
|
+
const before = d.state.progress;
|
|
4198
4277
|
d.state = { ...d.state, panelOpen: false, justDone: null };
|
|
4199
4278
|
if (d.state.progress.welcome === "not_seen")
|
|
4200
4279
|
withProgress(d, { welcome: "started" });
|
|
@@ -4212,7 +4291,9 @@ function startMission(d, suite, env, key, replay) {
|
|
|
4212
4291
|
index = checkpointAtOrBefore(mission.steps, at);
|
|
4213
4292
|
}
|
|
4214
4293
|
enterStep(d, suite, env, mission, index, { replay: isReplay });
|
|
4215
|
-
|
|
4294
|
+
// A replay never writes progress; only what starting it changed (the
|
|
4295
|
+
// welcome, first seen through a replay) is saved.
|
|
4296
|
+
if (isReplay && d.state.progress !== before)
|
|
4216
4297
|
save(d);
|
|
4217
4298
|
}
|
|
4218
4299
|
function exitMission(d) {
|
|
@@ -4262,6 +4343,17 @@ function apiFailureFor(step, sig) {
|
|
|
4262
4343
|
(!t.api.method || t.api.method.toUpperCase() === sig.method.toUpperCase()) &&
|
|
4263
4344
|
matchApiPath(t.api.path, sig.path));
|
|
4264
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
|
+
}
|
|
4265
4357
|
function reduceGuide(state, action, suite, env) {
|
|
4266
4358
|
const d = { state, effects: [] };
|
|
4267
4359
|
const s = state.screen;
|
|
@@ -4342,7 +4434,8 @@ function reduceGuide(state, action, suite, env) {
|
|
|
4342
4434
|
const step = mission.steps[s.index];
|
|
4343
4435
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4344
4436
|
if (s.phase === "away") {
|
|
4345
|
-
|
|
4437
|
+
const where = placeRoute(mission.steps, s.index);
|
|
4438
|
+
if (!where || matchRoute(where, env.pathname, env.search, env.code)) {
|
|
4346
4439
|
// Arrived: run the step again from here (it may already be passed).
|
|
4347
4440
|
enterStep(d, suite, env, mission, s.index, { replay: s.replay, error: s.error });
|
|
4348
4441
|
}
|
|
@@ -4359,6 +4452,11 @@ function reduceGuide(state, action, suite, env) {
|
|
|
4359
4452
|
break;
|
|
4360
4453
|
}
|
|
4361
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
|
+
}
|
|
4362
4460
|
if (armed !== s.armed)
|
|
4363
4461
|
setScreen(d, { ...s, armed });
|
|
4364
4462
|
const cur = d.state.screen;
|
|
@@ -4413,7 +4511,8 @@ function reduceGuide(state, action, suite, env) {
|
|
|
4413
4511
|
: { title: COPY.saveFailed, detail: sig.message };
|
|
4414
4512
|
setScreen(d, { ...s, error });
|
|
4415
4513
|
d.effects.push({ type: "announce", text: [error.title, error.detail].filter(Boolean).join(". ") });
|
|
4416
|
-
|
|
4514
|
+
if (!s.replay)
|
|
4515
|
+
withProgress(d, { last_error: `api_${sig.status}:${step.key}`.slice(0, 128) });
|
|
4417
4516
|
}
|
|
4418
4517
|
break;
|
|
4419
4518
|
}
|
|
@@ -4458,6 +4557,10 @@ function reduceGuide(state, action, suite, env) {
|
|
|
4458
4557
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4459
4558
|
if (stepTargetVisible(step, env, ctx, s.bridge))
|
|
4460
4559
|
break;
|
|
4560
|
+
if (!s.bridge && offRoute(mission.steps, s.index, env)) {
|
|
4561
|
+
recoverOffRoute(d, suite, env, false);
|
|
4562
|
+
break;
|
|
4563
|
+
}
|
|
4461
4564
|
const cp = checkpointAtOrBefore(mission.steps, s.index);
|
|
4462
4565
|
if (cp < s.index && !s.bridge) {
|
|
4463
4566
|
// A dialog or a menu closed under the step: back to what opens it.
|
|
@@ -4475,9 +4578,16 @@ function reduceGuide(state, action, suite, env) {
|
|
|
4475
4578
|
if (!mission)
|
|
4476
4579
|
break;
|
|
4477
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
|
+
}
|
|
4478
4585
|
const anchor = s.bridge ? BRIDGE_TARGET : (step.target ?? step.key);
|
|
4479
|
-
|
|
4480
|
-
|
|
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) });
|
|
4481
4591
|
const misses = s.misses + 1;
|
|
4482
4592
|
const policy = step.onMissing ?? "retry";
|
|
4483
4593
|
const cp = checkpointAtOrBefore(mission.steps, s.index);
|
|
@@ -4504,12 +4614,19 @@ function reduceGuide(state, action, suite, env) {
|
|
|
4504
4614
|
}
|
|
4505
4615
|
break;
|
|
4506
4616
|
}
|
|
4507
|
-
case "retry":
|
|
4508
|
-
if (s.name
|
|
4509
|
-
|
|
4510
|
-
|
|
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;
|
|
4511
4625
|
}
|
|
4626
|
+
setScreen(d, { ...s, phase: "waiting" });
|
|
4627
|
+
markReadyIfVisible(d, suite, env);
|
|
4512
4628
|
break;
|
|
4629
|
+
}
|
|
4513
4630
|
case "blocked":
|
|
4514
4631
|
if (s.name === "step")
|
|
4515
4632
|
d.state = { ...d.state, nudge: d.state.nudge + 1 };
|
|
@@ -4518,6 +4635,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
4518
4635
|
if (s.name !== "step")
|
|
4519
4636
|
break;
|
|
4520
4637
|
if (s.replay) {
|
|
4638
|
+
pushAbort(d, suite, s);
|
|
4521
4639
|
setScreen(d, { name: "idle" });
|
|
4522
4640
|
d.effects.push({ type: "toast", text: COPY.replayEnded });
|
|
4523
4641
|
}
|
|
@@ -4538,8 +4656,9 @@ function reduceGuide(state, action, suite, env) {
|
|
|
4538
4656
|
case "endWithoutSave":
|
|
4539
4657
|
if (s.name !== "step" || !s.replay)
|
|
4540
4658
|
break;
|
|
4659
|
+
pushAbort(d, suite, s);
|
|
4541
4660
|
setScreen(d, { name: "idle" });
|
|
4542
|
-
d.effects.push({ type: "
|
|
4661
|
+
d.effects.push({ type: "toast", text: COPY.replayEnded });
|
|
4543
4662
|
break;
|
|
4544
4663
|
case "goResume": {
|
|
4545
4664
|
if (s.name !== "step" || s.phase !== "away")
|
|
@@ -4547,10 +4666,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
4547
4666
|
const mission = findMission(suite, s.mission);
|
|
4548
4667
|
if (!mission)
|
|
4549
4668
|
break;
|
|
4550
|
-
const
|
|
4551
|
-
const ctx = guideContext(env, d.state.progress.context);
|
|
4552
|
-
const href = safe(() => step.resumeUrl?.(ctx), undefined) ??
|
|
4553
|
-
(step.route ? fillRoute(step.route, { ...d.state.progress.context, code: env.code }) : null);
|
|
4669
|
+
const href = resumeHref(mission, s.index, d, env);
|
|
4554
4670
|
if (href)
|
|
4555
4671
|
d.effects.push({ type: "navigate", href });
|
|
4556
4672
|
break;
|
|
@@ -4786,6 +4902,27 @@ function putBody(p, app) {
|
|
|
4786
4902
|
TERMINAL.includes(p.server_status),
|
|
4787
4903
|
};
|
|
4788
4904
|
}
|
|
4905
|
+
function stable(v) {
|
|
4906
|
+
if (Array.isArray(v))
|
|
4907
|
+
return `[${v.map(stable).join(",")}]`;
|
|
4908
|
+
if (isRecord(v)) {
|
|
4909
|
+
return `{${Object.keys(v)
|
|
4910
|
+
.sort()
|
|
4911
|
+
.map(k => `${JSON.stringify(k)}:${stable(v[k])}`)
|
|
4912
|
+
.join(",")}}`;
|
|
4913
|
+
}
|
|
4914
|
+
return JSON.stringify(v) ?? "null";
|
|
4915
|
+
}
|
|
4916
|
+
/**
|
|
4917
|
+
* What a PUT would store, without the rev it is checked against: two equal
|
|
4918
|
+
* keys mean the save would change nothing, and it is not sent (a replay
|
|
4919
|
+
* starting, a step entered again, never bump the row's rev). Key order does
|
|
4920
|
+
* not matter.
|
|
4921
|
+
*/
|
|
4922
|
+
function payloadKey(p, app) {
|
|
4923
|
+
const { rev: _rev, restart: _restart, ...rest } = putBody(p, app);
|
|
4924
|
+
return stable(rest);
|
|
4925
|
+
}
|
|
4789
4926
|
const MISSION_RANK = { todo: 0, skipped: 1, active: 1, done: 3 };
|
|
4790
4927
|
const WELCOME_RANK = { not_seen: 0, skipped: 1, started: 1 };
|
|
4791
4928
|
/**
|
|
@@ -4920,8 +5057,9 @@ function isPresent(el) {
|
|
|
4920
5057
|
if (r.width <= 0 || r.height <= 0)
|
|
4921
5058
|
return false;
|
|
4922
5059
|
// Slid off the side (a closed drawer); off the top or bottom is only
|
|
4923
|
-
// scrolled away, and the tour scrolls it back.
|
|
4924
|
-
|
|
5060
|
+
// scrolled away, and the tour scrolls it back. So is a tab scrolled out of
|
|
5061
|
+
// a row of tabs that scrolls sideways, as long as the row is on screen.
|
|
5062
|
+
if ((r.right <= 0 || r.left >= window.innerWidth) && !inSidewaysScroller(el))
|
|
4925
5063
|
return false;
|
|
4926
5064
|
if (el.getAttribute("data-state") === "closed")
|
|
4927
5065
|
return false;
|
|
@@ -5001,6 +5139,26 @@ function sameRect(a, b) {
|
|
|
5001
5139
|
const POPPER_SELECTOR = "[data-radix-popper-content-wrapper]";
|
|
5002
5140
|
/** Toasts stay usable above the tour. */
|
|
5003
5141
|
const TOASTER_SELECTOR = "[data-sonner-toaster]";
|
|
5142
|
+
/** Open dialogs (Radix dialog, alert dialog, sheet, popover content). */
|
|
5143
|
+
const OPEN_DIALOG_SELECTOR = '[role="dialog"][data-state="open"],[role="alertdialog"][data-state="open"]';
|
|
5144
|
+
/**
|
|
5145
|
+
* Whether an open dialog of the app's covers the target: the target is
|
|
5146
|
+
* neither in it nor around it (a confirm opened by the step's own button).
|
|
5147
|
+
* The ring and the hand would be drawn over the dialog, pointing at a button
|
|
5148
|
+
* behind it.
|
|
5149
|
+
*/
|
|
5150
|
+
function coveredByDialog(el) {
|
|
5151
|
+
if (!hasDocument() || !el)
|
|
5152
|
+
return false;
|
|
5153
|
+
for (const d of Array.from(document.querySelectorAll(OPEN_DIALOG_SELECTOR))) {
|
|
5154
|
+
if (inGuideLayer(d) || !isPresent(d))
|
|
5155
|
+
continue;
|
|
5156
|
+
if (d.contains(el) || el.contains(d))
|
|
5157
|
+
continue;
|
|
5158
|
+
return true;
|
|
5159
|
+
}
|
|
5160
|
+
return false;
|
|
5161
|
+
}
|
|
5004
5162
|
function openPoppers() {
|
|
5005
5163
|
if (!hasDocument())
|
|
5006
5164
|
return [];
|
|
@@ -5018,18 +5176,224 @@ function prefersReducedMotion() {
|
|
|
5018
5176
|
function isPhoneWidth() {
|
|
5019
5177
|
return typeof window !== "undefined" && window.innerWidth < 768;
|
|
5020
5178
|
}
|
|
5021
|
-
|
|
5179
|
+
/* ---------------- what is really on screen ---------------- */
|
|
5180
|
+
const INFINITE = 1e7;
|
|
5181
|
+
/** The overlap of two rectangles, or null when they do not meet. */
|
|
5182
|
+
function intersectRect(a, b) {
|
|
5183
|
+
const x = Math.max(a.x, b.x);
|
|
5184
|
+
const y = Math.max(a.y, b.y);
|
|
5185
|
+
const right = Math.min(a.x + a.width, b.x + b.width);
|
|
5186
|
+
const bottom = Math.min(a.y + a.height, b.y + b.height);
|
|
5187
|
+
if (right - x <= 0 || bottom - y <= 0)
|
|
5188
|
+
return null;
|
|
5189
|
+
return { x, y, width: right - x, height: bottom - y };
|
|
5190
|
+
}
|
|
5191
|
+
/** The part of `rect` inside every clip (scroll boxes, the viewport); null when none is. */
|
|
5192
|
+
function visibleRect(rect, clips) {
|
|
5193
|
+
let r = rect;
|
|
5194
|
+
for (const c of clips) {
|
|
5195
|
+
if (!r)
|
|
5196
|
+
return null;
|
|
5197
|
+
r = intersectRect(r, c);
|
|
5198
|
+
}
|
|
5199
|
+
return r;
|
|
5200
|
+
}
|
|
5201
|
+
const clipsX = (v) => v !== "visible";
|
|
5202
|
+
const scrollsX = (v) => v === "auto" || v === "scroll";
|
|
5203
|
+
/**
|
|
5204
|
+
* The ancestors that cut the element off: anything with overflow other than
|
|
5205
|
+
* visible on an axis. The body and the root scroll the page and are covered by
|
|
5206
|
+
* the viewport. Read once per target; their rectangles are read every frame.
|
|
5207
|
+
*/
|
|
5208
|
+
function clipAncestors(el) {
|
|
5209
|
+
const out = [];
|
|
5210
|
+
if (typeof getComputedStyle !== "function")
|
|
5211
|
+
return out;
|
|
5212
|
+
for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
|
|
5213
|
+
if (n.hasAttribute(GUIDE_LAYER_ATTRIBUTE))
|
|
5214
|
+
break;
|
|
5215
|
+
const s = getComputedStyle(n);
|
|
5216
|
+
const x = clipsX(s.overflowX);
|
|
5217
|
+
const y = clipsX(s.overflowY);
|
|
5218
|
+
if (x || y)
|
|
5219
|
+
out.push({ el: n, x, y });
|
|
5220
|
+
if (s.position === "fixed")
|
|
5221
|
+
break;
|
|
5222
|
+
}
|
|
5223
|
+
return out;
|
|
5224
|
+
}
|
|
5225
|
+
/** An ancestor's clip as a rectangle: unbounded on an axis it does not clip. */
|
|
5226
|
+
function clipRectOf(c) {
|
|
5227
|
+
const r = c.el.getBoundingClientRect();
|
|
5228
|
+
return {
|
|
5229
|
+
x: c.x ? r.left : -INFINITE,
|
|
5230
|
+
y: c.y ? r.top : -INFINITE,
|
|
5231
|
+
width: c.x ? r.width : INFINITE * 2,
|
|
5232
|
+
height: c.y ? r.height : INFINITE * 2,
|
|
5233
|
+
};
|
|
5234
|
+
}
|
|
5235
|
+
function viewportRect() {
|
|
5236
|
+
const vv = typeof window !== "undefined" ? window.visualViewport : undefined;
|
|
5237
|
+
return {
|
|
5238
|
+
x: 0,
|
|
5239
|
+
y: vv ? vv.offsetTop : 0,
|
|
5240
|
+
width: window.innerWidth,
|
|
5241
|
+
height: vv ? vv.height : window.innerHeight,
|
|
5242
|
+
};
|
|
5243
|
+
}
|
|
5244
|
+
/**
|
|
5245
|
+
* The part of the element a person can see: its box cut by every scroll box
|
|
5246
|
+
* around it and by the viewport. The spotlight, its hole, the ring and the
|
|
5247
|
+
* hand use this, so a tab half out of a sideways-scrolling row of tabs is not
|
|
5248
|
+
* spotlit where it cannot be pressed. Null when none of it is on screen.
|
|
5249
|
+
*/
|
|
5250
|
+
function visibleRectOf(el, ancestors = clipAncestors(el)) {
|
|
5251
|
+
return visibleRect(rectOf(el), [...ancestors.map(clipRectOf), viewportRect()]);
|
|
5252
|
+
}
|
|
5253
|
+
/** Inside a box that scrolls sideways (with somewhere to scroll) that is itself on screen. */
|
|
5254
|
+
function inSidewaysScroller(el) {
|
|
5255
|
+
if (typeof getComputedStyle !== "function")
|
|
5256
|
+
return false;
|
|
5257
|
+
for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
|
|
5258
|
+
const s = getComputedStyle(n);
|
|
5259
|
+
if (scrollsX(s.overflowX) && n.scrollWidth > n.clientWidth + 1) {
|
|
5260
|
+
const r = n.getBoundingClientRect();
|
|
5261
|
+
return r.width > 0 && r.right > 0 && r.left < window.innerWidth;
|
|
5262
|
+
}
|
|
5263
|
+
}
|
|
5264
|
+
return false;
|
|
5265
|
+
}
|
|
5266
|
+
/**
|
|
5267
|
+
* How to bring a target into view, or null when it already is: to the middle
|
|
5268
|
+
* when it is above or below the comfortable band (under the header, at the
|
|
5269
|
+
* bottom edge), the nearest edge when only a side is cut off (a tab out of a
|
|
5270
|
+
* sideways-scrolling row, on a phone).
|
|
5271
|
+
*/
|
|
5272
|
+
function revealPlan(rect, visible, viewportHeight) {
|
|
5273
|
+
const offVertically = rect.y < 70 || rect.y + rect.height > viewportHeight - 20;
|
|
5274
|
+
const cutSideways = !visible || visible.width < rect.width - 1;
|
|
5275
|
+
if (!offVertically && !cutSideways)
|
|
5276
|
+
return null;
|
|
5277
|
+
return { block: offVertically ? "center" : "nearest", inline: "nearest" };
|
|
5278
|
+
}
|
|
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
|
+
*/
|
|
5022
5314
|
function revealTarget(el) {
|
|
5023
|
-
const
|
|
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
|
+
}
|
|
5024
5324
|
const vh = window.visualViewport?.height ?? window.innerHeight;
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5325
|
+
const plan = revealPlan(rectOf(el), visibleRectOf(el), vh);
|
|
5326
|
+
if (!plan)
|
|
5327
|
+
return;
|
|
5328
|
+
try {
|
|
5329
|
+
el.scrollIntoView({ ...plan, behavior });
|
|
5330
|
+
}
|
|
5331
|
+
catch {
|
|
5332
|
+
el.scrollIntoView();
|
|
5333
|
+
}
|
|
5334
|
+
}
|
|
5335
|
+
/* ---------------- the scrim's holes, as one union ---------------- */
|
|
5336
|
+
/**
|
|
5337
|
+
* The scrim's blocking area: the screen (with a 10 px margin) minus the union
|
|
5338
|
+
* of the holes, as rectangles that do not overlap. Cutting each hole out with
|
|
5339
|
+
* an even-odd fill made the place where two holes overlap (a field and the
|
|
5340
|
+
* select list open over it) solid again, and the option there could not be
|
|
5341
|
+
* pressed.
|
|
5342
|
+
*
|
|
5343
|
+
* Every hole edge splits the screen into a grid; a cell is blocked when no
|
|
5344
|
+
* hole covers it. Cells in a row are joined, and identical rows stacked.
|
|
5345
|
+
*/
|
|
5346
|
+
function blockedRects(holes, width, height) {
|
|
5347
|
+
const x0 = -10;
|
|
5348
|
+
const y0 = -10;
|
|
5349
|
+
const x1 = width + 10;
|
|
5350
|
+
const y1 = height + 10;
|
|
5351
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
5352
|
+
const hs = holes
|
|
5353
|
+
.filter(h => h.width > 0 && h.height > 0)
|
|
5354
|
+
.map(h => ({
|
|
5355
|
+
l: clamp(h.x, x0, x1),
|
|
5356
|
+
t: clamp(h.y, y0, y1),
|
|
5357
|
+
r: clamp(h.x + h.width, x0, x1),
|
|
5358
|
+
b: clamp(h.y + h.height, y0, y1),
|
|
5359
|
+
}))
|
|
5360
|
+
.filter(h => h.r > h.l && h.b > h.t);
|
|
5361
|
+
if (!hs.length)
|
|
5362
|
+
return [{ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }];
|
|
5363
|
+
const uniq = (v) => Array.from(new Set(v)).sort((a, b) => a - b);
|
|
5364
|
+
const xs = uniq([x0, x1, ...hs.flatMap(h => [h.l, h.r])]);
|
|
5365
|
+
const ys = uniq([y0, y1, ...hs.flatMap(h => [h.t, h.b])]);
|
|
5366
|
+
const out = [];
|
|
5367
|
+
let prev = null;
|
|
5368
|
+
for (let j = 0; j < ys.length - 1; j++) {
|
|
5369
|
+
const top = ys[j];
|
|
5370
|
+
const bottom = ys[j + 1];
|
|
5371
|
+
const cy = (top + bottom) / 2;
|
|
5372
|
+
const row = [];
|
|
5373
|
+
for (let i = 0; i < xs.length - 1; i++) {
|
|
5374
|
+
const left = xs[i];
|
|
5375
|
+
const right = xs[i + 1];
|
|
5376
|
+
const cx = (left + right) / 2;
|
|
5377
|
+
const covered = hs.some(h => cx > h.l && cx < h.r && cy > h.t && cy < h.b);
|
|
5378
|
+
if (covered)
|
|
5379
|
+
continue;
|
|
5380
|
+
const last = row[row.length - 1];
|
|
5381
|
+
if (last && last.x + last.width === left)
|
|
5382
|
+
last.width = right - last.x;
|
|
5383
|
+
else
|
|
5384
|
+
row.push({ x: left, y: top, width: right - left, height: bottom - top });
|
|
5028
5385
|
}
|
|
5029
|
-
|
|
5030
|
-
|
|
5386
|
+
const key = row.map(r => `${r.x},${r.width}`).join("|");
|
|
5387
|
+
if (prev && prev.key === key) {
|
|
5388
|
+
for (const r of prev.rects)
|
|
5389
|
+
r.height += bottom - top;
|
|
5390
|
+
}
|
|
5391
|
+
else {
|
|
5392
|
+
out.push(...row);
|
|
5393
|
+
prev = { key, rects: row };
|
|
5031
5394
|
}
|
|
5032
5395
|
}
|
|
5396
|
+
return out;
|
|
5033
5397
|
}
|
|
5034
5398
|
|
|
5035
5399
|
/**
|
|
@@ -5051,7 +5415,8 @@ const GUIDE_CSS = `
|
|
|
5051
5415
|
.eg-i{pointer-events:auto}
|
|
5052
5416
|
.eg-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
5053
5417
|
.eg-scrim-svg{position:fixed;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}
|
|
5054
|
-
.eg-scrim-svg
|
|
5418
|
+
.eg-scrim-svg .eg-scrim-fill{fill:var(--eg-scrim);pointer-events:none}
|
|
5419
|
+
.eg-scrim-svg .eg-scrim-hit{fill:transparent;pointer-events:all}
|
|
5055
5420
|
.eg-scrim{position:fixed;inset:0;background:var(--eg-scrim);pointer-events:auto}
|
|
5056
5421
|
.eg-scrim.eg-light{background:rgba(12,20,34,.45)}
|
|
5057
5422
|
.eg-scrim.eg-faint{background:rgba(12,20,34,.35)}
|
|
@@ -5160,6 +5525,7 @@ const GUIDE_CSS = `
|
|
|
5160
5525
|
.eg-q-item.eg-done .eg-t b{color:var(--eg-muted)}
|
|
5161
5526
|
.eg-q-item .eg-t small{display:block;font-size:12px;color:var(--eg-muted)}
|
|
5162
5527
|
.eg-q-item .eg-t small.eg-sk{color:#b06a00}
|
|
5528
|
+
.eg-q-item .eg-t small.eg-lock{color:var(--eg-fg);font-weight:500}
|
|
5163
5529
|
.eg-q-item .eg-btn{height:32px;padding:0 12px;font-size:13px}
|
|
5164
5530
|
.eg-q-acts{display:flex;flex-direction:column;align-items:flex-end;gap:2px}
|
|
5165
5531
|
.eg-q-acts .eg-link{height:22px;font-size:12px;padding:0 2px}
|
|
@@ -5224,15 +5590,53 @@ function roundRect(r, radius) {
|
|
|
5224
5590
|
`H${x + rad}A${rad} ${rad} 0 0 1 ${x} ${y + h - rad}` +
|
|
5225
5591
|
`V${y + rad}A${rad} ${rad} 0 0 1 ${x + rad} ${y}Z`);
|
|
5226
5592
|
}
|
|
5593
|
+
const rectPath = (r) => `M${r.x} ${r.y}H${r.x + r.width}V${r.y + r.height}H${r.x}Z`;
|
|
5594
|
+
/**
|
|
5595
|
+
* The dim layer that eats every press outside the holes. The holes are a
|
|
5596
|
+
* union: where two overlap (a field and the select list open over it) the
|
|
5597
|
+
* page stays open.
|
|
5598
|
+
*
|
|
5599
|
+
* - The look: one rectangle masked by the rounded holes (a mask adds holes
|
|
5600
|
+
* up, so an overlap is not filled back in).
|
|
5601
|
+
* - The presses: a transparent path made of the screen minus the holes, as
|
|
5602
|
+
* rectangles that do not overlap (blockedRects). A press inside a hole
|
|
5603
|
+
* reaches the page underneath; any other lands on the path (marked
|
|
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.
|
|
5607
|
+
*/
|
|
5608
|
+
function Scrim({ holes, open, width, height }) {
|
|
5609
|
+
const maskId = "eg-scrim-mask-" + React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
|
|
5610
|
+
const block = blockedRects(open ?? holes, width, height).map(rectPath).join("");
|
|
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": "" })] }));
|
|
5612
|
+
}
|
|
5613
|
+
/** Whether two rectangles overlap. */
|
|
5614
|
+
const meets = (a, b) => !!intersectRect(a, b);
|
|
5615
|
+
/** The smallest rectangle around all of them. */
|
|
5616
|
+
function boundingRect(rs) {
|
|
5617
|
+
const x = Math.min(...rs.map(r => r.x));
|
|
5618
|
+
const y = Math.min(...rs.map(r => r.y));
|
|
5619
|
+
const right = Math.max(...rs.map(r => r.x + r.width));
|
|
5620
|
+
const bottom = Math.max(...rs.map(r => r.y + r.height));
|
|
5621
|
+
return { x, y, width: right - x, height: bottom - y };
|
|
5622
|
+
}
|
|
5623
|
+
/** Where the bubble may go, in order of preference. */
|
|
5624
|
+
const BUBBLE_PLACEMENTS = ["right", "bottom", "top", "left"];
|
|
5227
5625
|
/**
|
|
5228
|
-
* The
|
|
5229
|
-
*
|
|
5230
|
-
*
|
|
5626
|
+
* The first placement (in BUBBLE_PLACEMENTS order) whose bubble is on screen,
|
|
5627
|
+
* off the target (with its open list), and off everything to keep clear;
|
|
5628
|
+
* failing that, the first that is only on screen and off the target; -1 when
|
|
5629
|
+
* none is.
|
|
5231
5630
|
*/
|
|
5232
|
-
function
|
|
5233
|
-
const
|
|
5234
|
-
const
|
|
5235
|
-
|
|
5631
|
+
function pickPlacement(results, bw, bh, anchor, clear, bounds) {
|
|
5632
|
+
const box = (r) => ({ x: r.x, y: r.y, width: bw, height: bh });
|
|
5633
|
+
const fits = (b) => b.x >= bounds.x - 1 && b.y >= bounds.y - 1 && b.x + b.width <= bounds.x + bounds.width + 1 && b.y + b.height <= bounds.y + bounds.height + 1;
|
|
5634
|
+
const ok = results.map(r => {
|
|
5635
|
+
const b = box(r);
|
|
5636
|
+
return fits(b) && !meets(b, anchor);
|
|
5637
|
+
});
|
|
5638
|
+
const best = results.findIndex((r, i) => ok[i] && !clear.some(c => meets(box(r), c)));
|
|
5639
|
+
return best >= 0 ? best : ok.indexOf(true);
|
|
5236
5640
|
}
|
|
5237
5641
|
const BUBBLE_TITLE_ID = "eg-bubble-title";
|
|
5238
5642
|
const BUBBLE_WHY_ID = "eg-bubble-why";
|
|
@@ -5272,12 +5676,21 @@ function StepLayer(p) {
|
|
|
5272
5676
|
const gap = p.showHand ? 34 : 14;
|
|
5273
5677
|
const spotKey = spot ? `${spot.x}|${spot.y}|${spot.width}|${spot.height}` : "";
|
|
5274
5678
|
const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.showNext}`;
|
|
5679
|
+
// What the bubble sits beside: the target, with an open list over it (the
|
|
5680
|
+
// list is what the person is choosing from now). What it must not cover:
|
|
5681
|
+
// open lists, and the other anchors the step allows (a map step's
|
|
5682
|
+
// coordinate fields).
|
|
5683
|
+
const popoverList = p.popovers ?? [];
|
|
5684
|
+
const anchor = spot ? boundingRect([spot, ...popoverList.filter(o => meets(o, spot))]) : null;
|
|
5685
|
+
const clear = [...popoverList, ...p.holes];
|
|
5686
|
+
const anchorKey = anchor ? `${anchor.x}|${anchor.y}|${anchor.width}|${anchor.height}` : "";
|
|
5687
|
+
const clearKey = clear.map(r => `${r.x},${r.y},${r.width},${r.height}`).join(";");
|
|
5275
5688
|
useLayoutEffect(() => {
|
|
5276
5689
|
const el = bubbleRef.current;
|
|
5277
5690
|
if (!el || !showBubble)
|
|
5278
5691
|
return;
|
|
5279
5692
|
const m = 16;
|
|
5280
|
-
if (!spot) {
|
|
5693
|
+
if (!spot || !anchor) {
|
|
5281
5694
|
// Nothing to point at: the middle of the screen (the bottom on a phone).
|
|
5282
5695
|
const bw = el.offsetWidth;
|
|
5283
5696
|
const bh = el.offsetHeight;
|
|
@@ -5288,13 +5701,13 @@ function StepLayer(p) {
|
|
|
5288
5701
|
}
|
|
5289
5702
|
if (phone) {
|
|
5290
5703
|
// Docked to the top or the bottom of the screen, whichever covers less
|
|
5291
|
-
// of the target (target in the lower half: top).
|
|
5704
|
+
// of the target and its open list (target in the lower half: top).
|
|
5292
5705
|
const vh = viewport.height;
|
|
5293
5706
|
const oy = viewport.offsetTop;
|
|
5294
5707
|
const bh = el.offsetHeight;
|
|
5295
5708
|
const topT = oy + m + 4;
|
|
5296
5709
|
const botT = oy + vh - bh - m;
|
|
5297
|
-
const { y, height: h } =
|
|
5710
|
+
const { y, height: h } = anchor;
|
|
5298
5711
|
const overlapTop = Math.max(0, topT + bh + 8 - y);
|
|
5299
5712
|
const overlapBot = Math.max(0, y + h + gap - botT);
|
|
5300
5713
|
const top = y + h / 2 > vh / 2
|
|
@@ -5308,40 +5721,42 @@ function StepLayer(p) {
|
|
|
5308
5721
|
return;
|
|
5309
5722
|
}
|
|
5310
5723
|
let cancelled = false;
|
|
5724
|
+
const a = anchor;
|
|
5311
5725
|
const ref = {
|
|
5312
5726
|
getBoundingClientRect: () => ({
|
|
5313
|
-
x:
|
|
5314
|
-
y:
|
|
5315
|
-
left:
|
|
5316
|
-
top:
|
|
5317
|
-
width:
|
|
5318
|
-
height:
|
|
5319
|
-
right:
|
|
5320
|
-
bottom:
|
|
5727
|
+
x: a.x,
|
|
5728
|
+
y: a.y,
|
|
5729
|
+
left: a.x,
|
|
5730
|
+
top: a.y,
|
|
5731
|
+
width: a.width,
|
|
5732
|
+
height: a.height,
|
|
5733
|
+
right: a.x + a.width,
|
|
5734
|
+
bottom: a.y + a.height,
|
|
5321
5735
|
}),
|
|
5322
5736
|
};
|
|
5323
|
-
computePosition(ref, el, {
|
|
5737
|
+
const at = (placement) => computePosition(ref, el, {
|
|
5324
5738
|
strategy: "fixed",
|
|
5325
|
-
placement
|
|
5739
|
+
placement,
|
|
5326
5740
|
middleware: [
|
|
5327
|
-
offset(
|
|
5328
|
-
flip({ fallbackPlacements: ["bottom", "top", "left"], padding: m }),
|
|
5741
|
+
offset(placement.startsWith("bottom") ? gap : 14),
|
|
5329
5742
|
shift({ padding: m }),
|
|
5330
5743
|
...(arrowRef.current ? [arrow({ element: arrowRef.current, padding: 14 })] : []),
|
|
5331
5744
|
],
|
|
5332
|
-
})
|
|
5333
|
-
|
|
5745
|
+
});
|
|
5746
|
+
Promise.all(BUBBLE_PLACEMENTS.map(at))
|
|
5747
|
+
.then(results => {
|
|
5334
5748
|
if (cancelled)
|
|
5335
5749
|
return;
|
|
5336
5750
|
const bw = el.offsetWidth;
|
|
5337
5751
|
const bh = el.offsetHeight;
|
|
5338
|
-
const
|
|
5339
|
-
const
|
|
5340
|
-
if (
|
|
5752
|
+
const bounds = { x: m, y: viewport.offsetTop + m, width: window.innerWidth - 2 * m, height: viewport.height - 2 * m };
|
|
5753
|
+
const i = pickPlacement(results, bw, bh, a, clear, bounds);
|
|
5754
|
+
if (i < 0) {
|
|
5341
5755
|
// Nowhere beside it: the bottom-right corner.
|
|
5342
5756
|
setPos({ left: window.innerWidth - bw - m, top: viewport.height + viewport.offsetTop - bh - m, side: "dock" });
|
|
5343
5757
|
return;
|
|
5344
5758
|
}
|
|
5759
|
+
const { x, y, placement, middlewareData } = results[i];
|
|
5345
5760
|
const side = placement.split("-")[0];
|
|
5346
5761
|
setPos({ left: x, top: y, side, ax: middlewareData.arrow?.x, ay: middlewareData.arrow?.y });
|
|
5347
5762
|
})
|
|
@@ -5352,11 +5767,15 @@ function StepLayer(p) {
|
|
|
5352
5767
|
return () => {
|
|
5353
5768
|
cancelled = true;
|
|
5354
5769
|
};
|
|
5355
|
-
//
|
|
5770
|
+
// The keys stand for spot, the lists, the holes and the bubble's size.
|
|
5356
5771
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
5357
|
-
}, [spotKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop]);
|
|
5358
|
-
const
|
|
5359
|
-
const
|
|
5772
|
+
}, [spotKey, anchorKey, clearKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop]);
|
|
5773
|
+
const popovers = p.popovers ?? [];
|
|
5774
|
+
const covered = !!p.covered;
|
|
5775
|
+
const holes = [...(spot && !covered ? [spot] : []), ...popovers, ...p.holes];
|
|
5776
|
+
const openHoles = p.targetPressable === false ? [...popovers, ...p.holes] : holes;
|
|
5777
|
+
const ringShown = !!spot && p.phase === "ready" && !covered && !popovers.some(o => meets(o, spot));
|
|
5778
|
+
const hand = spot && p.showHand && p.phase === "ready" && !covered && !popovers.length ? handPosition(spot, viewport.height) : null;
|
|
5360
5779
|
let arrowStyle;
|
|
5361
5780
|
if (pos && pos.side !== "dock") {
|
|
5362
5781
|
if (pos.side === "right")
|
|
@@ -5368,7 +5787,7 @@ function StepLayer(p) {
|
|
|
5368
5787
|
else
|
|
5369
5788
|
arrowStyle = { bottom: -7, left: pos.ax };
|
|
5370
5789
|
}
|
|
5371
|
-
return (jsxs(Fragment$1, { children: [jsx(Scrim, { holes: p.phase === "ready" ? holes : [], width: viewport.width, height: Math.max(viewport.height + viewport.offsetTop, 0) }), spot &&
|
|
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: {
|
|
5372
5791
|
left: pos?.left ?? 0,
|
|
5373
5792
|
top: pos?.top ?? 0,
|
|
5374
5793
|
width: phone ? viewport.width - 32 : undefined,
|
|
@@ -5379,7 +5798,7 @@ function BubbleBody(p) {
|
|
|
5379
5798
|
const b = p.bubble;
|
|
5380
5799
|
const title = b.error ? b.error.title : b.title;
|
|
5381
5800
|
const why = b.error ? (b.error.detail ?? "") : b.why;
|
|
5382
|
-
return (jsxs(Fragment$1, { children: [jsxs("div", { className: "eg-b-top", children: [jsxs("span", { className: "eg-mis", children: ["\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08 ", b.missionNo, " \u00B7 ", b.missionTitle, b.replay ? " (ทบทวน)" : ""] }), jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", b.index + 1, "/", b.total] })] }), jsx("div", { className: "eg-pips", "aria-hidden": "true", children: Array.from({ length: b.total }, (_, i) => (jsx("i", { className: i < b.index ? "eg-d" : i === b.index ? "eg-c" : undefined }, i))) }), jsx("p", { className: "eg-b-t", id: BUBBLE_TITLE_ID, children: jsx(GuideTitle, { text: title }) }), jsx("p", { className: "eg-b-w", id: BUBBLE_WHY_ID, hidden: !why, children: why }), jsxs("div", { className: "eg-b-f", children: [jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: b.replay ? "จบการทบทวน" : "ออกจากไกด์" }), jsxs("span", { style: { display: "flex", gap: 6 }, children: [b.showEndWithoutSave ? (jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: p.onEndWithoutSave, children: "\u0E08\u0E1A\u0E42\u0E14\u0E22\u0E44\u0E21\u0E48\u0E1A\u0E31\u0E19\u0E17\u0E36\u0E01" })) : null, b.showNext ? (jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onNext, children: "\u0E16\u0E31\u0E14\u0E44\u0E1B" })) : null] })] })] }));
|
|
5801
|
+
return (jsxs(Fragment$1, { children: [jsxs("div", { className: "eg-b-top", children: [jsxs("span", { className: "eg-mis", children: ["\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08 ", b.missionNo, " \u00B7 ", b.missionTitle, b.replay ? " (ทบทวน)" : ""] }), jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", b.index + 1, "/", b.total] })] }), jsx("div", { className: "eg-pips", "aria-hidden": "true", children: Array.from({ length: b.total }, (_, i) => (jsx("i", { className: i < b.index ? "eg-d" : i === b.index ? "eg-c" : undefined }, i))) }), jsx("p", { className: "eg-b-t", id: BUBBLE_TITLE_ID, children: jsx(GuideTitle, { text: title }) }), jsx("p", { className: "eg-b-w", id: BUBBLE_WHY_ID, hidden: !why, children: jsx(GuideTitle, { text: why }) }), jsxs("div", { className: "eg-b-f", children: [jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: b.replay ? "จบการทบทวน" : "ออกจากไกด์" }), jsxs("span", { style: { display: "flex", gap: 6 }, children: [b.showEndWithoutSave ? (jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: p.onEndWithoutSave, children: "\u0E08\u0E1A\u0E42\u0E14\u0E22\u0E44\u0E21\u0E48\u0E1A\u0E31\u0E19\u0E17\u0E36\u0E01" })) : null, b.showNext ? (jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onNext, children: "\u0E16\u0E31\u0E14\u0E44\u0E1B" })) : null] })] })] }));
|
|
5383
5802
|
}
|
|
5384
5803
|
/** The fingertip at ≈62% across and down the target; upside down near the bottom. */
|
|
5385
5804
|
function handPosition(spot, viewportHeight) {
|
|
@@ -5492,6 +5911,9 @@ function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
|
|
|
5492
5911
|
meta = jsx("span", { children: "\u0E02\u0E49\u0E32\u0E21\u0E44\u0E27\u0E49" });
|
|
5493
5912
|
else if (v === "waiting" && gate)
|
|
5494
5913
|
meta = gate.pending;
|
|
5914
|
+
// Locked: what to do first gets its own line under the title, so the title
|
|
5915
|
+
// keeps the row's width and the button stays short.
|
|
5916
|
+
const lockHint = v === "locked" && item.lockedBy ? `ทำ ${item.lockedBy} ก่อน` : null;
|
|
5495
5917
|
let button;
|
|
5496
5918
|
if (v === "done")
|
|
5497
5919
|
button = jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: () => onStart(item.key, true), children: "\u0E17\u0E33\u0E0B\u0E49\u0E33" });
|
|
@@ -5500,11 +5922,11 @@ function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
|
|
|
5500
5922
|
else if (v === "waiting" && gate)
|
|
5501
5923
|
button = (jsx("a", { className: "eg-btn eg-ghost", href: gate.href, style: { textDecoration: "none" }, children: gate.label }));
|
|
5502
5924
|
else if (v === "locked")
|
|
5503
|
-
button = (
|
|
5925
|
+
button = (jsx("button", { type: "button", className: "eg-btn eg-ghost", disabled: true, "aria-label": lockHint ? `เริ่ม (${lockHint})` : undefined, children: "\u0E40\u0E23\u0E34\u0E48\u0E21" }));
|
|
5504
5926
|
else
|
|
5505
5927
|
button = (jsx("button", { type: "button", className: "eg-btn " + (v === "next" ? "eg-primary" : "eg-ghost"), onClick: () => onStart(item.key, false), children: v === "skipped" || v === "active" ? "ทำต่อ" : "เริ่ม" }));
|
|
5506
5928
|
const dismissable = item.optional && (v === "todo" || v === "next");
|
|
5507
|
-
return (jsxs("li", { className: cls, children: [jsx("span", { className: "eg-n", children: ticked ? jsx(IconCheck, {}) : n }), jsxs("div", { className: "eg-t", children: [jsx("b", { children: item.title }), jsx("small", { className: v === "skipped" ? "eg-sk" : undefined, children: meta })] }), dismissable ? (jsxs("span", { className: "eg-q-acts", children: [button, jsx("button", { type: "button", className: "eg-btn eg-link", onClick: () => onDismiss(item.key), children: "\u0E44\u0E21\u0E48\u0E43\u0E0A\u0E49\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E19\u0E35\u0E49" })] })) : (button)] }));
|
|
5929
|
+
return (jsxs("li", { className: cls, children: [jsx("span", { className: "eg-n", children: ticked ? jsx(IconCheck, {}) : n }), jsxs("div", { className: "eg-t", children: [jsx("b", { children: item.title }), lockHint ? jsx("small", { className: "eg-lock", children: lockHint }) : null, jsx("small", { className: v === "skipped" ? "eg-sk" : undefined, children: meta })] }), dismissable ? (jsxs("span", { className: "eg-q-acts", children: [button, jsx("button", { type: "button", className: "eg-btn eg-link", onClick: () => onDismiss(item.key), children: "\u0E44\u0E21\u0E48\u0E43\u0E0A\u0E49\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E19\u0E35\u0E49" })] })) : (button)] }));
|
|
5508
5930
|
}
|
|
5509
5931
|
function GuideToast({ text }) {
|
|
5510
5932
|
return (jsx("div", { className: "eg-toast", role: "status", children: text }));
|
|
@@ -5647,6 +6069,45 @@ function usePrimaryTab(name) {
|
|
|
5647
6069
|
return primary;
|
|
5648
6070
|
}
|
|
5649
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
|
+
/* ------------------------------------------------------------------ */
|
|
5650
6111
|
/* Host */
|
|
5651
6112
|
/* ------------------------------------------------------------------ */
|
|
5652
6113
|
const TARGET_PAD = 6;
|
|
@@ -5692,6 +6153,9 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
5692
6153
|
/* ---------------- machine ---------------- */
|
|
5693
6154
|
const [state, setState] = useState(null);
|
|
5694
6155
|
const stateRef = useRef(null);
|
|
6156
|
+
// What the server holds (payloadKey), so a save that would change nothing
|
|
6157
|
+
// is not sent and does not bump its rev.
|
|
6158
|
+
const savedKey = useRef(null);
|
|
5695
6159
|
useEffect(() => {
|
|
5696
6160
|
if (!answer || answer.audience === "none" || answer.newerOnServer) {
|
|
5697
6161
|
stateRef.current = null;
|
|
@@ -5700,6 +6164,7 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
5700
6164
|
}
|
|
5701
6165
|
if (stateRef.current)
|
|
5702
6166
|
return;
|
|
6167
|
+
savedKey.current = answer.progress && answer.progress.rev > 0 ? payloadKey(answer.progress, suite.app) : null;
|
|
5703
6168
|
// Resume from whichever copy is further along: the server's, or this
|
|
5704
6169
|
// tab's session copy from before a refresh (its rev is the server's).
|
|
5705
6170
|
const mirror = readMirror(organizationId, suite);
|
|
@@ -5756,7 +6221,11 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
5756
6221
|
const cur = stateRef.current;
|
|
5757
6222
|
if (!cur)
|
|
5758
6223
|
return;
|
|
6224
|
+
if (payloadKey(cur.progress, suite.app) === savedKey.current)
|
|
6225
|
+
continue;
|
|
5759
6226
|
const row = await saveGuides(organizationId, suite, cur.progress);
|
|
6227
|
+
if (row)
|
|
6228
|
+
savedKey.current = payloadKey(row, suite.app);
|
|
5760
6229
|
if (row && stateRef.current) {
|
|
5761
6230
|
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
5762
6231
|
dispatchRef.current({ type: "serverRow", progress: row });
|
|
@@ -5797,13 +6266,9 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
5797
6266
|
else
|
|
5798
6267
|
router.push(e.href);
|
|
5799
6268
|
}
|
|
5800
|
-
else if (e.type === "
|
|
5801
|
-
// After this render takes the guard away,
|
|
5802
|
-
|
|
5803
|
-
setTimeout(() => {
|
|
5804
|
-
const t = document.activeElement ?? document.body;
|
|
5805
|
-
t.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true, cancelable: true }));
|
|
5806
|
-
}, 60);
|
|
6269
|
+
else if (e.type === "abort") {
|
|
6270
|
+
// After this render takes the guard away, put the page back.
|
|
6271
|
+
runAbort(e.actions);
|
|
5807
6272
|
}
|
|
5808
6273
|
}
|
|
5809
6274
|
catch {
|
|
@@ -5933,7 +6398,9 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
5933
6398
|
scrolling, dialogs animating in, the drawer sliding, the keyboard. */
|
|
5934
6399
|
const [layout, setLayout] = useState({
|
|
5935
6400
|
spot: null,
|
|
6401
|
+
covered: false,
|
|
5936
6402
|
holes: [],
|
|
6403
|
+
popovers: [],
|
|
5937
6404
|
vw: 0,
|
|
5938
6405
|
vh: 0,
|
|
5939
6406
|
oy: 0,
|
|
@@ -5945,9 +6412,22 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
5945
6412
|
}
|
|
5946
6413
|
let raf = 0;
|
|
5947
6414
|
let lostSince = null;
|
|
5948
|
-
let last = { spot: null, holes: [], vw: 0, vh: 0, oy: 0 };
|
|
6415
|
+
let last = { spot: null, covered: false, holes: [], popovers: [], vw: 0, vh: 0, oy: 0 };
|
|
5949
6416
|
targetElRef.current = target ? findAnchor(target.anchor, target.id) : null;
|
|
5950
6417
|
const allow = stepInfo?.step.allow ?? [];
|
|
6418
|
+
// The spotlight is the part of the target a person can see: cut by the
|
|
6419
|
+
// scroll boxes around it (a row of tabs that scrolls sideways) and the
|
|
6420
|
+
// viewport. Wholly out of view (scrolled away up or down), its own box,
|
|
6421
|
+
// and the tour scrolls it back.
|
|
6422
|
+
let clipFor = null;
|
|
6423
|
+
let clips = [];
|
|
6424
|
+
const spotOf = (el) => {
|
|
6425
|
+
if (el !== clipFor) {
|
|
6426
|
+
clipFor = el;
|
|
6427
|
+
clips = clipAncestors(el);
|
|
6428
|
+
}
|
|
6429
|
+
return padRect(visibleRectOf(el, clips) ?? rectOf(el), TARGET_PAD);
|
|
6430
|
+
};
|
|
5951
6431
|
const tick = () => {
|
|
5952
6432
|
raf = requestAnimationFrame(tick);
|
|
5953
6433
|
const vv = window.visualViewport;
|
|
@@ -5962,14 +6442,14 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
5962
6442
|
let spot = null;
|
|
5963
6443
|
if (target && el && isPresent(el)) {
|
|
5964
6444
|
lostSince = null;
|
|
5965
|
-
spot =
|
|
6445
|
+
spot = spotOf(el);
|
|
5966
6446
|
}
|
|
5967
6447
|
else if (target) {
|
|
5968
6448
|
// Maybe re-rendered under another node.
|
|
5969
6449
|
const again = findAnchor(target.anchor, target.id);
|
|
5970
6450
|
if (again) {
|
|
5971
6451
|
targetElRef.current = again;
|
|
5972
|
-
spot =
|
|
6452
|
+
spot = spotOf(again);
|
|
5973
6453
|
lostSince = null;
|
|
5974
6454
|
}
|
|
5975
6455
|
else if (phase === "ready") {
|
|
@@ -5982,21 +6462,25 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
5982
6462
|
}
|
|
5983
6463
|
if (phase === "waiting" && spot)
|
|
5984
6464
|
dispatchRef.current({ type: "sync" });
|
|
5985
|
-
const
|
|
6465
|
+
const popovers = [];
|
|
5986
6466
|
for (const p of openPoppers())
|
|
5987
|
-
|
|
6467
|
+
popovers.push(padRect(rectOf(p), 2));
|
|
6468
|
+
const holes = [];
|
|
5988
6469
|
for (const a of allow) {
|
|
5989
6470
|
const ael = findAnchor(a);
|
|
5990
6471
|
if (ael)
|
|
5991
6472
|
holes.push(padRect(rectOf(ael), TARGET_PAD));
|
|
5992
6473
|
}
|
|
5993
|
-
const
|
|
6474
|
+
const covered = !!spot && coveredByDialog(targetElRef.current);
|
|
6475
|
+
const next = { spot, covered, holes, popovers, vw, vh, oy };
|
|
6476
|
+
const sameList = (a, b) => a.length === b.length && a.every((h, i) => sameRect(h, b[i]));
|
|
5994
6477
|
if (!sameRect(next.spot, last.spot) ||
|
|
6478
|
+
next.covered !== last.covered ||
|
|
5995
6479
|
next.vw !== last.vw ||
|
|
5996
6480
|
next.vh !== last.vh ||
|
|
5997
6481
|
next.oy !== last.oy ||
|
|
5998
|
-
next.holes
|
|
5999
|
-
next.
|
|
6482
|
+
!sameList(next.holes, last.holes) ||
|
|
6483
|
+
!sameList(next.popovers, last.popovers)) {
|
|
6000
6484
|
last = next;
|
|
6001
6485
|
setLayout(next);
|
|
6002
6486
|
}
|
|
@@ -6127,7 +6611,10 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
6127
6611
|
if (!(node instanceof Node))
|
|
6128
6612
|
return false;
|
|
6129
6613
|
const el = targetElRef.current;
|
|
6130
|
-
|
|
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))
|
|
6131
6618
|
return true;
|
|
6132
6619
|
const elNode = node instanceof Element ? node : node.parentElement;
|
|
6133
6620
|
if (!elNode)
|
|
@@ -6226,7 +6713,7 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
6226
6713
|
const el = targetElRef.current;
|
|
6227
6714
|
const step = stepInfoRef.current?.step;
|
|
6228
6715
|
const isInput = !!step && !sc.bridge && stepKind(step) === "input";
|
|
6229
|
-
const inTargetList = el ? focusablesIn(isInput ? (fieldOf(el) ?? el) : el) : [];
|
|
6716
|
+
const inTargetList = el && targetPressable(step, sc.bridge) ? focusablesIn(isInput ? (fieldOf(el) ?? el) : el) : [];
|
|
6230
6717
|
const bubble = layer?.querySelector("[data-eg-bubble]") ?? null;
|
|
6231
6718
|
list = [...inTargetList, ...(bubble ? Array.from(bubble.querySelectorAll(FOCUSABLE)) : [])];
|
|
6232
6719
|
for (const p of openPoppers())
|
|
@@ -6301,19 +6788,38 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
6301
6788
|
useEffect(() => {
|
|
6302
6789
|
const el = document.createElement("div");
|
|
6303
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");
|
|
6304
6798
|
document.body.appendChild(el);
|
|
6305
6799
|
layerRef.current = el;
|
|
6306
6800
|
setLayer(el);
|
|
6307
|
-
//
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
|
|
6314
|
-
|
|
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
|
+
}
|
|
6315
6821
|
});
|
|
6316
|
-
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"] });
|
|
6317
6823
|
return () => {
|
|
6318
6824
|
obs.disconnect();
|
|
6319
6825
|
el.remove();
|
|
@@ -6380,7 +6886,7 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
6380
6886
|
const { mission, step, missionNo } = stepInfo;
|
|
6381
6887
|
const kind = stepKind(step);
|
|
6382
6888
|
const bridge = screen.bridge;
|
|
6383
|
-
content = (jsx(StepLayer, { spot: layout.spot, holes: layout.holes, viewport: { width: layout.vw || window.innerWidth, height: layout.vh || window.innerHeight, offsetTop: layout.oy }, phone: phone, showHand: bridge || (kind !== "input" && kind !== "view"), bubble: {
|
|
6889
|
+
content = (jsx(StepLayer, { spot: layout.spot, holes: layout.holes, popovers: layout.popovers, covered: layout.covered, viewport: { width: layout.vw || window.innerWidth, height: layout.vh || window.innerHeight, offsetTop: layout.oy }, phone: phone, showHand: bridge || (kind !== "input" && kind !== "view"), bubble: {
|
|
6384
6890
|
missionNo,
|
|
6385
6891
|
missionTitle: mission.title,
|
|
6386
6892
|
replay: screen.replay,
|
|
@@ -6391,7 +6897,7 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
6391
6897
|
error: screen.error,
|
|
6392
6898
|
showNext: !bridge && (kind === "input" || kind === "view"),
|
|
6393
6899
|
showEndWithoutSave: !bridge && screen.replay && kind === "commit",
|
|
6394
|
-
}, 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" }) }));
|
|
6395
6901
|
}
|
|
6396
6902
|
else if (screen.name === "celebrate" && celebrateShown) {
|
|
6397
6903
|
const mission = findMission(suite, screen.mission);
|