@theseam/ui-common 2.0.1-beta.101 → 2.0.1-beta.104
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/fesm2022/theseam-ui-common-guide.mjs +1192 -0
- package/fesm2022/theseam-ui-common-guide.mjs.map +1 -0
- package/guide/guide-theme.scss +105 -0
- package/guide/index.d.ts +474 -0
- package/guide/package.json +3 -0
- package/guide/styles/_utilities.scss +4 -0
- package/guide/styles/_variables.scss +6 -0
- package/package.json +9 -1
|
@@ -0,0 +1,1192 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, isDevMode, Injectable, inject, ElementRef, input, effect, Directive, ApplicationRef, EnvironmentInjector, createComponent, Injector, makeEnvironmentProviders, signal } from '@angular/core';
|
|
3
|
+
import { Subject, defer, of, throwError, ReplaySubject, EMPTY, isObservable, from } from 'rxjs';
|
|
4
|
+
import { filter, map, take, timeout, switchMap, catchError, tap } from 'rxjs/operators';
|
|
5
|
+
import { driver } from 'driver.js';
|
|
6
|
+
|
|
7
|
+
/** Injected by a component used as popover content. */
|
|
8
|
+
const THE_SEAM_GUIDE_CONTENT = new InjectionToken('THE_SEAM_GUIDE_CONTENT');
|
|
9
|
+
|
|
10
|
+
const THE_SEAM_GUIDE_DEFAULTS = {
|
|
11
|
+
dismissible: true,
|
|
12
|
+
targetTimeout: 3000,
|
|
13
|
+
onMissingTarget: 'skip',
|
|
14
|
+
targetLostGrace: 1000,
|
|
15
|
+
onTargetLost: 'elementless',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
class TheSeamGuideBusyError extends Error {
|
|
19
|
+
constructor() {
|
|
20
|
+
super('TheSeamGuide: a non-dismissible guide is already active. Wait for' +
|
|
21
|
+
' `activeGuide()?.afterClosed$` before starting another guide.');
|
|
22
|
+
this.name = 'TheSeamGuideBusyError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
class TheSeamGuideTargetTimeoutError extends Error {
|
|
26
|
+
targetName;
|
|
27
|
+
constructor(targetName) {
|
|
28
|
+
super(`TheSeamGuide: timed out waiting for target "${targetName}".`);
|
|
29
|
+
this.targetName = targetName;
|
|
30
|
+
this.name = 'TheSeamGuideTargetTimeoutError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Tracks elements registered by `[seamGuideTarget]` so a guide can await a
|
|
36
|
+
* target that does not exist yet, and notice one that disappears.
|
|
37
|
+
*/
|
|
38
|
+
class TheSeamGuideTargetRegistry {
|
|
39
|
+
_targets = new Map();
|
|
40
|
+
_changes = new Subject();
|
|
41
|
+
/** Emits the target name whenever its registrations change. */
|
|
42
|
+
changes$ = this._changes.asObservable();
|
|
43
|
+
register(name, element) {
|
|
44
|
+
const list = this._targets.get(name) ?? [];
|
|
45
|
+
if (!list.includes(element)) {
|
|
46
|
+
list.push(element);
|
|
47
|
+
}
|
|
48
|
+
this._targets.set(name, list);
|
|
49
|
+
if (isDevMode() && list.filter((e) => e.isConnected).length > 1) {
|
|
50
|
+
console.warn(`TheSeamGuideTargetRegistry: more than one connected element is` +
|
|
51
|
+
` registered as guide target "${name}". The most recently registered` +
|
|
52
|
+
` one will be used, which may not be the one you meant.`);
|
|
53
|
+
}
|
|
54
|
+
this._changes.next(name);
|
|
55
|
+
}
|
|
56
|
+
unregister(name, element) {
|
|
57
|
+
const list = this._targets.get(name);
|
|
58
|
+
if (!list) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const index = list.indexOf(element);
|
|
62
|
+
if (index === -1) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
list.splice(index, 1);
|
|
66
|
+
if (list.length === 0) {
|
|
67
|
+
this._targets.delete(name);
|
|
68
|
+
}
|
|
69
|
+
this._changes.next(name);
|
|
70
|
+
}
|
|
71
|
+
/** The most recently registered element for `name` that is still in the DOM. */
|
|
72
|
+
resolve(name) {
|
|
73
|
+
const list = this._targets.get(name);
|
|
74
|
+
if (!list) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
78
|
+
if (list[i].isConnected) {
|
|
79
|
+
return list[i];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
/** Emits as soon as `name` resolves. Errors with a timeout error otherwise. */
|
|
85
|
+
waitFor(name, timeoutMs) {
|
|
86
|
+
return defer(() => {
|
|
87
|
+
const existing = this.resolve(name);
|
|
88
|
+
if (existing !== null) {
|
|
89
|
+
return of(existing);
|
|
90
|
+
}
|
|
91
|
+
return this._changes.pipe(filter((changed) => changed === name), map(() => this.resolve(name)), filter((el) => el !== null), take(1), timeout({
|
|
92
|
+
first: timeoutMs,
|
|
93
|
+
with: () => throwError(() => new TheSeamGuideTargetTimeoutError(name)),
|
|
94
|
+
}));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: TheSeamGuideTargetRegistry, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
98
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: TheSeamGuideTargetRegistry, providedIn: 'root' });
|
|
99
|
+
}
|
|
100
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: TheSeamGuideTargetRegistry, decorators: [{
|
|
101
|
+
type: Injectable,
|
|
102
|
+
args: [{ providedIn: 'root' }]
|
|
103
|
+
}] });
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Marks an element as a named guide target.
|
|
107
|
+
*
|
|
108
|
+
* Registering on init and unregistering on destroy is what lets a guide await a
|
|
109
|
+
* target that has not rendered yet, and recover when one is destroyed and
|
|
110
|
+
* recreated mid-step.
|
|
111
|
+
*/
|
|
112
|
+
class TheSeamGuideTargetDirective {
|
|
113
|
+
_registry = inject(TheSeamGuideTargetRegistry);
|
|
114
|
+
_elementRef = inject(ElementRef);
|
|
115
|
+
seamGuideTarget = input.required(...(ngDevMode ? [{ debugName: "seamGuideTarget" }] : []));
|
|
116
|
+
_registeredName = null;
|
|
117
|
+
constructor() {
|
|
118
|
+
effect(() => {
|
|
119
|
+
const name = this.seamGuideTarget();
|
|
120
|
+
if (this._registeredName === name) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const element = this._elementRef.nativeElement;
|
|
124
|
+
if (this._registeredName !== null) {
|
|
125
|
+
this._registry.unregister(this._registeredName, element);
|
|
126
|
+
}
|
|
127
|
+
this._registry.register(name, element);
|
|
128
|
+
this._registeredName = name;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
ngOnDestroy() {
|
|
132
|
+
if (this._registeredName !== null) {
|
|
133
|
+
this._registry.unregister(this._registeredName, this._elementRef.nativeElement);
|
|
134
|
+
this._registeredName = null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: TheSeamGuideTargetDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
138
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "20.3.15", type: TheSeamGuideTargetDirective, isStandalone: true, selector: "[seamGuideTarget]", inputs: { seamGuideTarget: { classPropertyName: "seamGuideTarget", publicName: "seamGuideTarget", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
|
|
139
|
+
}
|
|
140
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: TheSeamGuideTargetDirective, decorators: [{
|
|
141
|
+
type: Directive,
|
|
142
|
+
args: [{
|
|
143
|
+
selector: '[seamGuideTarget]',
|
|
144
|
+
standalone: true,
|
|
145
|
+
}]
|
|
146
|
+
}], ctorParameters: () => [], propDecorators: { seamGuideTarget: [{ type: i0.Input, args: [{ isSignal: true, alias: "seamGuideTarget", required: true }] }] } });
|
|
147
|
+
|
|
148
|
+
const THE_SEAM_GUIDE_ADAPTER = new InjectionToken('THE_SEAM_GUIDE_ADAPTER');
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Consumer-facing handle to a running guide.
|
|
152
|
+
*
|
|
153
|
+
* The caller owns this ref's lifetime. A guide is not closed automatically
|
|
154
|
+
* when the component that started it is destroyed — `TheSeamGuideService` is
|
|
155
|
+
* `providedIn: 'root'`, so its `ngOnDestroy` only fires when the root
|
|
156
|
+
* injector itself is destroyed, not on ordinary route/component teardown. A
|
|
157
|
+
* component that may be destroyed before its guide naturally ends should tie
|
|
158
|
+
* the ref to its own lifetime:
|
|
159
|
+
*
|
|
160
|
+
* ```ts
|
|
161
|
+
* const ref = this._guide.start(config)
|
|
162
|
+
* inject(DestroyRef).onDestroy(() => ref.close())
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
class TheSeamGuideRef {
|
|
166
|
+
_session;
|
|
167
|
+
constructor(_session) {
|
|
168
|
+
this._session = _session;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Replays every event emitted so far for this guide, from `started`
|
|
172
|
+
* onward — a subscriber attached at any point sees the complete history,
|
|
173
|
+
* not just events emitted after it subscribes. This is why subscribing
|
|
174
|
+
* immediately after `start()` returns still observes `started`: `start()`
|
|
175
|
+
* runs synchronously, but the event is not lost, it is replayed.
|
|
176
|
+
*/
|
|
177
|
+
get events$() {
|
|
178
|
+
return this._session.events$;
|
|
179
|
+
}
|
|
180
|
+
get afterClosed$() {
|
|
181
|
+
return this._session.afterClosed$;
|
|
182
|
+
}
|
|
183
|
+
get activeIndex() {
|
|
184
|
+
return this._session.activeIndex;
|
|
185
|
+
}
|
|
186
|
+
/** Whether the user may dismiss this guide. Read by the service's concurrency rule. */
|
|
187
|
+
get dismissible() {
|
|
188
|
+
return this._session.dismissible;
|
|
189
|
+
}
|
|
190
|
+
next() {
|
|
191
|
+
this._session.next();
|
|
192
|
+
}
|
|
193
|
+
previous() {
|
|
194
|
+
this._session.previous();
|
|
195
|
+
}
|
|
196
|
+
moveTo(index) {
|
|
197
|
+
this._session.moveTo(index);
|
|
198
|
+
}
|
|
199
|
+
refresh() {
|
|
200
|
+
this._session.refresh();
|
|
201
|
+
}
|
|
202
|
+
/** Always works, including when `dismissible` is false. */
|
|
203
|
+
close(reason = 'dismissed') {
|
|
204
|
+
this._session.close(reason);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Creates and destroys the Angular view behind a popover slot.
|
|
210
|
+
*
|
|
211
|
+
* Separate from `TheSeamGuideSession` so session specs can run against a fake
|
|
212
|
+
* and stay free of a real `ApplicationRef`, and so the session stays free of
|
|
213
|
+
* rendering concerns.
|
|
214
|
+
*/
|
|
215
|
+
class TheSeamGuideDomContentRenderer {
|
|
216
|
+
_appRef = inject(ApplicationRef);
|
|
217
|
+
_envInjector = inject(EnvironmentInjector);
|
|
218
|
+
/**
|
|
219
|
+
* Renders `slot` into `host`, which the caller owns.
|
|
220
|
+
*
|
|
221
|
+
* Views are attached to `ApplicationRef` rather than created through a
|
|
222
|
+
* `ViewContainerRef`, because this is a `providedIn: 'root'` service and
|
|
223
|
+
* there is no view container to reach. Attachment is what makes a view
|
|
224
|
+
* change-detected; where its nodes sit in the DOM is independent of it,
|
|
225
|
+
* which is what lets driver.js move `host` around as it rebuilds its
|
|
226
|
+
* popover on every render.
|
|
227
|
+
*/
|
|
228
|
+
render(slot, context, host) {
|
|
229
|
+
if (slot.kind === 'template') {
|
|
230
|
+
const view = slot.template.createEmbeddedView(context);
|
|
231
|
+
this._appRef.attachView(view);
|
|
232
|
+
host.append(...view.rootNodes);
|
|
233
|
+
return {
|
|
234
|
+
destroy: () => {
|
|
235
|
+
this._appRef.detachView(view);
|
|
236
|
+
view.destroy();
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const ref = createComponent(slot.component, {
|
|
241
|
+
environmentInjector: this._envInjector,
|
|
242
|
+
// DI rather than `setInput`: `data` is shallow-merged across three
|
|
243
|
+
// layers, so it routinely carries keys a given component never declared
|
|
244
|
+
// as an input, and `setInput` throws NG0303 for those.
|
|
245
|
+
elementInjector: Injector.create({
|
|
246
|
+
parent: this._envInjector,
|
|
247
|
+
providers: [
|
|
248
|
+
{ provide: THE_SEAM_GUIDE_CONTENT, useValue: context },
|
|
249
|
+
{ provide: TheSeamGuideRef, useValue: context.guide },
|
|
250
|
+
],
|
|
251
|
+
}),
|
|
252
|
+
});
|
|
253
|
+
this._appRef.attachView(ref.hostView);
|
|
254
|
+
host.append(ref.location.nativeElement);
|
|
255
|
+
return {
|
|
256
|
+
destroy: () => {
|
|
257
|
+
this._appRef.detachView(ref.hostView);
|
|
258
|
+
ref.destroy();
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: TheSeamGuideDomContentRenderer, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
263
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: TheSeamGuideDomContentRenderer, providedIn: 'root' });
|
|
264
|
+
}
|
|
265
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: TheSeamGuideDomContentRenderer, decorators: [{
|
|
266
|
+
type: Injectable,
|
|
267
|
+
args: [{ providedIn: 'root' }]
|
|
268
|
+
}] });
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* driver.js implementation of the guide adapter.
|
|
272
|
+
*
|
|
273
|
+
* The whole step array is handed to driver.js so its buttons, progress
|
|
274
|
+
* indicator, and keyboard handling are preserved, but every navigation click is
|
|
275
|
+
* intercepted and reported instead of acted on. The session decides what
|
|
276
|
+
* happens next.
|
|
277
|
+
*/
|
|
278
|
+
class DriverJsGuideAdapter {
|
|
279
|
+
_driver = null;
|
|
280
|
+
start(config, callbacks) {
|
|
281
|
+
this.destroy();
|
|
282
|
+
const driverConfig = {
|
|
283
|
+
steps: config.steps.map((step) => this._toDriveStep(step)),
|
|
284
|
+
allowClose: config.allowUserDismiss,
|
|
285
|
+
showButtons: config.allowUserDismiss
|
|
286
|
+
? ['next', 'previous', 'close']
|
|
287
|
+
: ['next', 'previous'],
|
|
288
|
+
// Intercept every navigation: driver.js must never advance itself,
|
|
289
|
+
// because the session owns sequencing.
|
|
290
|
+
onNextClick: () => callbacks.onNextRequested(),
|
|
291
|
+
onPrevClick: () => callbacks.onPreviousRequested(),
|
|
292
|
+
onCloseClick: () => callbacks.onCloseRequested(),
|
|
293
|
+
onDestroyStarted: () => callbacks.onCloseRequested(),
|
|
294
|
+
};
|
|
295
|
+
this._driver = driver(driverConfig);
|
|
296
|
+
}
|
|
297
|
+
next() {
|
|
298
|
+
this._driver?.moveNext();
|
|
299
|
+
}
|
|
300
|
+
previous() {
|
|
301
|
+
this._driver?.movePrevious();
|
|
302
|
+
}
|
|
303
|
+
moveTo(index) {
|
|
304
|
+
if (this._driver === null) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (!this._driver.isActive()) {
|
|
308
|
+
this._driver.drive(index);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
this._driver.moveTo(index);
|
|
312
|
+
}
|
|
313
|
+
refresh() {
|
|
314
|
+
// driver.js's own refresh() only repositions the overlay/popover around
|
|
315
|
+
// the cached active element — it never re-invokes the step's element
|
|
316
|
+
// resolver. Re-driving the current index is what forces re-resolution,
|
|
317
|
+
// which is the whole point of `refresh()` for mid-step recovery.
|
|
318
|
+
//
|
|
319
|
+
// This re-drive rebuilds the popover DOM every time, producing a visible
|
|
320
|
+
// flash even when the resolved element hasn't changed — driver.js has no
|
|
321
|
+
// primitive for "re-resolve without re-render". Callers should treat this
|
|
322
|
+
// as a recovery operation, not something to invoke speculatively.
|
|
323
|
+
const index = this._driver?.getActiveIndex();
|
|
324
|
+
if (index !== undefined) {
|
|
325
|
+
this._driver?.moveTo(index);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
destroy() {
|
|
329
|
+
if (this._driver === null) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const instance = this._driver;
|
|
333
|
+
// Null first, out of caution: if driver.js's public destroy() ever starts
|
|
334
|
+
// invoking onDestroyStarted synchronously, we don't want that callback
|
|
335
|
+
// re-entering this adapter through a non-null _driver. Today it doesn't —
|
|
336
|
+
// destroy() tears down with confirm=false and always skips
|
|
337
|
+
// onDestroyStarted — so this ordering isn't load-bearing for the current
|
|
338
|
+
// wiring, just defensive against a future driver.js change.
|
|
339
|
+
this._driver = null;
|
|
340
|
+
instance.destroy();
|
|
341
|
+
}
|
|
342
|
+
isActive() {
|
|
343
|
+
return this._driver?.isActive() ?? false;
|
|
344
|
+
}
|
|
345
|
+
_toDriveStep(step) {
|
|
346
|
+
// Typed honestly as `Element | undefined`, matching what the resolver
|
|
347
|
+
// can actually return — `undefined` is a real outcome, not an absent
|
|
348
|
+
// one, since the elementless path depends on it.
|
|
349
|
+
const resolveElement = step.element === undefined ? undefined : () => step.element?.();
|
|
350
|
+
return {
|
|
351
|
+
// driver.js's own public type only declares `() => Element`, but its
|
|
352
|
+
// runtime falls back to a centered popover when the resolver returns
|
|
353
|
+
// `undefined`. This cast crosses that documentation gap at the one
|
|
354
|
+
// point it matters; `resolveElement` above keeps `undefined` visible
|
|
355
|
+
// everywhere else in this method.
|
|
356
|
+
element: resolveElement,
|
|
357
|
+
popover: step.popover === undefined
|
|
358
|
+
? undefined
|
|
359
|
+
: this._toDrivePopover(step.popover),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* `ExhaustiveMap` makes every key of `TheSeamGuideAdapterPopover` required
|
|
364
|
+
* in `mapped`, so adding a field to the boundary is a compile error here
|
|
365
|
+
* until it is carried through.
|
|
366
|
+
*/
|
|
367
|
+
_toDrivePopover(popover) {
|
|
368
|
+
const mapped = {
|
|
369
|
+
title: popover.title,
|
|
370
|
+
description: popover.description,
|
|
371
|
+
side: popover.side,
|
|
372
|
+
align: popover.align,
|
|
373
|
+
};
|
|
374
|
+
const { title, description, side, align } = mapped;
|
|
375
|
+
const hasNode = title instanceof HTMLElement || description instanceof HTMLElement;
|
|
376
|
+
return {
|
|
377
|
+
title: typeof title === 'string' ? title : undefined,
|
|
378
|
+
description: typeof description === 'string' ? description : undefined,
|
|
379
|
+
side,
|
|
380
|
+
align,
|
|
381
|
+
// driver.js hides a slot whose string is falsy, so a slot filled with a
|
|
382
|
+
// node must be un-hidden as well as populated. It also rebuilds the
|
|
383
|
+
// whole popover on every render, so this runs again on each re-drive
|
|
384
|
+
// and simply re-adopts the same host node.
|
|
385
|
+
onPopoverRender: hasNode
|
|
386
|
+
? (dom) => {
|
|
387
|
+
if (title instanceof HTMLElement) {
|
|
388
|
+
dom.title.replaceChildren(title);
|
|
389
|
+
dom.title.style.display = 'block';
|
|
390
|
+
}
|
|
391
|
+
if (description instanceof HTMLElement) {
|
|
392
|
+
dom.description.replaceChildren(description);
|
|
393
|
+
dom.description.style.display = 'block';
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
: undefined,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: DriverJsGuideAdapter, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
400
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: DriverJsGuideAdapter });
|
|
401
|
+
}
|
|
402
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: DriverJsGuideAdapter, decorators: [{
|
|
403
|
+
type: Injectable
|
|
404
|
+
}] });
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Application-wide popover defaults — the outermost of the three content
|
|
408
|
+
* layers. Always provided by {@link provideTheSeamGuide}, defaulting to `{}`.
|
|
409
|
+
*/
|
|
410
|
+
const THE_SEAM_GUIDE_POPOVER_DEFAULTS = new InjectionToken('THE_SEAM_GUIDE_POPOVER_DEFAULTS');
|
|
411
|
+
/**
|
|
412
|
+
* Wires the guide's presentation engine.
|
|
413
|
+
*
|
|
414
|
+
* The engine is named only here — no consumer imports driver.js — so replacing
|
|
415
|
+
* it is a change to this call, not to application code.
|
|
416
|
+
*/
|
|
417
|
+
function provideTheSeamGuide(options = {}) {
|
|
418
|
+
return makeEnvironmentProviders([
|
|
419
|
+
{
|
|
420
|
+
provide: THE_SEAM_GUIDE_ADAPTER,
|
|
421
|
+
useClass: options.adapter ?? DriverJsGuideAdapter,
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
provide: THE_SEAM_GUIDE_POPOVER_DEFAULTS,
|
|
425
|
+
useValue: options.popover ?? {},
|
|
426
|
+
},
|
|
427
|
+
]);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** A bare string is sugar for `{ text }`. */
|
|
431
|
+
function normalize(value) {
|
|
432
|
+
if (value === undefined || value === null) {
|
|
433
|
+
return value;
|
|
434
|
+
}
|
|
435
|
+
return typeof value === 'string' ? { text: value } : value;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Resolves one popover slot from its three layers. `null` means the slot is
|
|
439
|
+
* absent and nothing is rendered for it.
|
|
440
|
+
*
|
|
441
|
+
* Presence and content are decided separately: only the step and session
|
|
442
|
+
* layers can make a slot present, but once it is, all three layers contribute
|
|
443
|
+
* the renderer, the text, and the data. That is what lets a step say
|
|
444
|
+
* `title: 'Step One'` and still get the application's title component.
|
|
445
|
+
*/
|
|
446
|
+
function resolveGuideContentSlot(layers) {
|
|
447
|
+
const presence = layers.step !== undefined ? layers.step : layers.session;
|
|
448
|
+
if (presence === undefined || presence === null) {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
const provider = normalize(layers.provider);
|
|
452
|
+
const session = normalize(layers.session);
|
|
453
|
+
const step = normalize(layers.step);
|
|
454
|
+
const nearestFirst = [step, session, provider];
|
|
455
|
+
const outermostFirst = [provider, session, step];
|
|
456
|
+
// One search for the renderer, not one per kind: the nearest layer naming
|
|
457
|
+
// either wins outright, so a step's template beats a session's component.
|
|
458
|
+
const renderer = nearestFirst.find((layer) => layer?.template != null || layer?.component != null);
|
|
459
|
+
const text = nearestFirst.find((layer) => layer?.text !== undefined)?.text;
|
|
460
|
+
const data = {};
|
|
461
|
+
for (const layer of outermostFirst) {
|
|
462
|
+
if (layer?.data !== undefined) {
|
|
463
|
+
Object.assign(data, layer.data);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (renderer?.template != null) {
|
|
467
|
+
return { kind: 'template', template: renderer.template, text, data };
|
|
468
|
+
}
|
|
469
|
+
if (renderer?.component != null) {
|
|
470
|
+
return { kind: 'component', component: renderer.component, text, data };
|
|
471
|
+
}
|
|
472
|
+
return text === undefined ? null : { kind: 'text', text };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
class TheSeamGuideSession {
|
|
476
|
+
// ReplaySubject, not Subject: `start()` runs synchronously (including its
|
|
477
|
+
// `'started'` emit) before the service hands the ref back to the caller, so
|
|
478
|
+
// a subscriber that attaches right after `start()` returns is necessarily
|
|
479
|
+
// late. Replaying history is what lets it still observe events already
|
|
480
|
+
// emitted during that synchronous start, matching `_afterClosed` below.
|
|
481
|
+
_events = new ReplaySubject();
|
|
482
|
+
_afterClosed = new ReplaySubject(1);
|
|
483
|
+
_activeIndex = signal(-1, ...(ngDevMode ? [{ debugName: "_activeIndex" }] : []));
|
|
484
|
+
_transitions = new Subject();
|
|
485
|
+
_transitionSub = null;
|
|
486
|
+
_recoverySub = null;
|
|
487
|
+
// Bumped by every `_disarmRecovery()` call. A re-arm queued on a microtask
|
|
488
|
+
// captures the generation in effect when it was queued and checks it before
|
|
489
|
+
// acting, so a re-arm for a step the session has since moved past (a
|
|
490
|
+
// transition disarmed-and-rearmed for a *different* step in between) is a
|
|
491
|
+
// no-op instead of clobbering the newer arming.
|
|
492
|
+
_recoveryGeneration = 0;
|
|
493
|
+
_closed = false;
|
|
494
|
+
// Whether the currently active step's `afterStep` has already fired for
|
|
495
|
+
// this departure. A skip or a cancellation re-requests a transition
|
|
496
|
+
// without changing `_activeIndex` (the departed-from step never painted
|
|
497
|
+
// anything new), so without this guard the same step's `afterStep` would
|
|
498
|
+
// re-run on every retry. Reset to `false` whenever a step actually paints.
|
|
499
|
+
_afterStepFired = false;
|
|
500
|
+
// Index whose `beforeStep` has already been fired while its transition is
|
|
501
|
+
// still in flight (target not yet resolved), or `null`. driver.js leaves
|
|
502
|
+
// the Next/Previous buttons live during that wait, so a second click
|
|
503
|
+
// re-requests the same index; without this guard `switchMap` would restart
|
|
504
|
+
// the transition and run `beforeStep` a second time before the first
|
|
505
|
+
// attempt ever settles. Reset to `null` whenever a step actually paints —
|
|
506
|
+
// mirroring `_afterStepFired` — so a later, genuine re-entry to the same
|
|
507
|
+
// index still runs its `beforeStep`.
|
|
508
|
+
_beforeStepFiredFor = null;
|
|
509
|
+
steps;
|
|
510
|
+
options;
|
|
511
|
+
events$ = this._events.asObservable();
|
|
512
|
+
afterClosed$ = this._afterClosed.asObservable();
|
|
513
|
+
activeIndex = this._activeIndex.asReadonly();
|
|
514
|
+
_adapter;
|
|
515
|
+
_registry;
|
|
516
|
+
_contentRenderer;
|
|
517
|
+
_getRef;
|
|
518
|
+
_slots = [];
|
|
519
|
+
_popoverDefaults;
|
|
520
|
+
_sessionPopover;
|
|
521
|
+
_onClosed;
|
|
522
|
+
constructor(config, deps) {
|
|
523
|
+
this._adapter = deps.adapter;
|
|
524
|
+
this._registry = deps.registry;
|
|
525
|
+
this._contentRenderer = deps.contentRenderer;
|
|
526
|
+
this._getRef = deps.getRef;
|
|
527
|
+
this._popoverDefaults = deps.popoverDefaults;
|
|
528
|
+
this._sessionPopover = config.popover;
|
|
529
|
+
this._onClosed = deps.onClosed;
|
|
530
|
+
this.steps = config.steps;
|
|
531
|
+
this.options = { ...THE_SEAM_GUIDE_DEFAULTS, ...stripUndefined(config) };
|
|
532
|
+
this._transitionSub = this._transitions
|
|
533
|
+
.pipe(switchMap((request) => this._runTransition(request.index, request.direction)))
|
|
534
|
+
.subscribe();
|
|
535
|
+
}
|
|
536
|
+
get dismissible() {
|
|
537
|
+
return this.options.dismissible;
|
|
538
|
+
}
|
|
539
|
+
start() {
|
|
540
|
+
this._buildSlots();
|
|
541
|
+
this._adapter.start({
|
|
542
|
+
steps: this.steps.map((step, index) => this._toAdapterStep(step, index)),
|
|
543
|
+
allowUserDismiss: this.options.dismissible,
|
|
544
|
+
}, {
|
|
545
|
+
onNextRequested: () => this.next(),
|
|
546
|
+
onPreviousRequested: () => this.previous(),
|
|
547
|
+
onCloseRequested: () => this.close('dismissed'),
|
|
548
|
+
});
|
|
549
|
+
this._emit({ type: 'started' });
|
|
550
|
+
this.moveTo(0);
|
|
551
|
+
}
|
|
552
|
+
next() {
|
|
553
|
+
this._request(this._activeIndex() + 1, 1);
|
|
554
|
+
}
|
|
555
|
+
previous() {
|
|
556
|
+
this._request(this._activeIndex() - 1, -1);
|
|
557
|
+
}
|
|
558
|
+
moveTo(index) {
|
|
559
|
+
this._request(index, index >= this._activeIndex() ? 1 : -1);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Requests a transition.
|
|
563
|
+
*
|
|
564
|
+
* The emission is deferred to a microtask because `_applyMissPolicy` calls
|
|
565
|
+
* this from *inside* the `switchMap` projection. Emitting synchronously there
|
|
566
|
+
* would make the transition cancel itself mid-flight. `fakeAsync`'s `tick()`
|
|
567
|
+
* flushes microtasks, so specs are unaffected.
|
|
568
|
+
*/
|
|
569
|
+
_request(index, direction) {
|
|
570
|
+
if (this._closed) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
queueMicrotask(() => {
|
|
574
|
+
if (this._closed) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
this._transitions.next({ index, direction });
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
refresh() {
|
|
581
|
+
this._adapter.refresh();
|
|
582
|
+
}
|
|
583
|
+
close(reason) {
|
|
584
|
+
if (this._closed) {
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
this._closed = true;
|
|
588
|
+
this._transitionSub?.unsubscribe();
|
|
589
|
+
this._transitionSub = null;
|
|
590
|
+
this._disarmRecovery();
|
|
591
|
+
const result = {
|
|
592
|
+
reason,
|
|
593
|
+
lastIndex: this._activeIndex(),
|
|
594
|
+
};
|
|
595
|
+
this._adapter.destroy();
|
|
596
|
+
this._destroyAllSlots();
|
|
597
|
+
this._emit({ type: 'closed', result });
|
|
598
|
+
this._afterClosed.next(result);
|
|
599
|
+
this._afterClosed.complete();
|
|
600
|
+
this._events.complete();
|
|
601
|
+
this._onClosed(this);
|
|
602
|
+
}
|
|
603
|
+
_emit(event) {
|
|
604
|
+
this._events.next(event);
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* The one sequence every transition runs. Cancellable: a new request causes
|
|
608
|
+
* `switchMap` to unsubscribe from this, so nothing paints after teardown.
|
|
609
|
+
*
|
|
610
|
+
* A `catchError` wraps the whole sequence: a hook that throws or rejects
|
|
611
|
+
* must not escape to the outer `_transitions` subscriber, because that
|
|
612
|
+
* subscriber has no error handler of its own and an uncaught error there
|
|
613
|
+
* would unsubscribe it permanently, silently wedging the session (`next`,
|
|
614
|
+
* `previous`, and `moveTo` would become no-ops forever). A caught failure
|
|
615
|
+
* closes the guide rather than running the miss policy: the miss policy
|
|
616
|
+
* exists for a target that never appeared, not an arbitrary thrown error,
|
|
617
|
+
* and reusing it would (a) misreport the close reason as `'targetMissing'`
|
|
618
|
+
* for an `'end'`-policy step that never had a target problem, and (b) run
|
|
619
|
+
* `_applyMissPolicy` unprotected as the last step of an error handler,
|
|
620
|
+
* where a further throw (e.g. from `_paint` → `_onStepPainted`, which
|
|
621
|
+
* Task 7 turns into real logic) would escape to the same unhandled outer
|
|
622
|
+
* subscriber this whole `catchError` exists to protect.
|
|
623
|
+
*/
|
|
624
|
+
_runTransition(index, direction) {
|
|
625
|
+
if (this._closed) {
|
|
626
|
+
return EMPTY;
|
|
627
|
+
}
|
|
628
|
+
this._disarmRecovery();
|
|
629
|
+
if (index >= this.steps.length) {
|
|
630
|
+
this.close('completed');
|
|
631
|
+
return EMPTY;
|
|
632
|
+
}
|
|
633
|
+
if (index < 0) {
|
|
634
|
+
return EMPTY;
|
|
635
|
+
}
|
|
636
|
+
const outgoing = this._activeIndex() >= 0 ? this.steps[this._activeIndex()] : undefined;
|
|
637
|
+
const incoming = this.steps[index];
|
|
638
|
+
const afterStep$ = this._afterStepFired
|
|
639
|
+
? of(null)
|
|
640
|
+
: defer(() => {
|
|
641
|
+
this._afterStepFired = true;
|
|
642
|
+
return this._runHook(outgoing?.afterStep);
|
|
643
|
+
});
|
|
644
|
+
const beforeStep$ = this._beforeStepFiredFor === index
|
|
645
|
+
? of(null)
|
|
646
|
+
: defer(() => {
|
|
647
|
+
this._beforeStepFiredFor = index;
|
|
648
|
+
return this._runHook(incoming.beforeStep);
|
|
649
|
+
});
|
|
650
|
+
return afterStep$.pipe(switchMap(() => beforeStep$), switchMap(() => this._resolveTarget(incoming)), switchMap((outcome) => {
|
|
651
|
+
if (this._closed) {
|
|
652
|
+
return EMPTY;
|
|
653
|
+
}
|
|
654
|
+
if (outcome === 'missing') {
|
|
655
|
+
return this._applyMissPolicy(index, incoming, direction);
|
|
656
|
+
}
|
|
657
|
+
this._paint(index, incoming);
|
|
658
|
+
return EMPTY;
|
|
659
|
+
}), catchError((err) => {
|
|
660
|
+
// Deliberately no call-out to `_applyMissPolicy` or anything else
|
|
661
|
+
// that could itself throw: this handler is the last backstop before
|
|
662
|
+
// the outer, handler-less `_transitions` subscription, so the whole
|
|
663
|
+
// body is wrapped in `try/catch` and swallows on failure — there is
|
|
664
|
+
// nothing left to recover to.
|
|
665
|
+
try {
|
|
666
|
+
if (this._closed) {
|
|
667
|
+
return EMPTY;
|
|
668
|
+
}
|
|
669
|
+
if (isDevMode()) {
|
|
670
|
+
console.warn(`TheSeamGuideSession: step ${index} threw during its` +
|
|
671
|
+
` transition (${String(err)}); closing the guide.`);
|
|
672
|
+
}
|
|
673
|
+
this.close('destroyed');
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
// Nothing left to recover from.
|
|
677
|
+
}
|
|
678
|
+
return EMPTY;
|
|
679
|
+
}));
|
|
680
|
+
}
|
|
681
|
+
/** Paints a step that is actually entering: real target or elementless. */
|
|
682
|
+
_paint(index, step) {
|
|
683
|
+
const outgoing = this._activeIndex();
|
|
684
|
+
// Before `moveTo`: driver.js calls `onPopoverRender` synchronously from
|
|
685
|
+
// there, so the host must already hold its view.
|
|
686
|
+
this._renderSlots(index);
|
|
687
|
+
// A content component is given `TheSeamGuideRef` on its element injector,
|
|
688
|
+
// so its constructor runs synchronously inside `_renderSlots` above and
|
|
689
|
+
// can call `ref.close()` re-entrantly. `close()` cannot destroy the view
|
|
690
|
+
// `_renderSlots` just created — it is only assigned into `binding.view`
|
|
691
|
+
// after `render()` returns — so this must destroy it here instead, and
|
|
692
|
+
// must not touch the (already-destroyed) adapter or emit any further
|
|
693
|
+
// events for a session that is no longer open.
|
|
694
|
+
if (this._closed) {
|
|
695
|
+
this._destroySlots(index);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
this._activeIndex.set(index);
|
|
699
|
+
this._afterStepFired = false;
|
|
700
|
+
this._beforeStepFiredFor = null;
|
|
701
|
+
this._adapter.moveTo(index);
|
|
702
|
+
if (outgoing !== index) {
|
|
703
|
+
this._destroySlots(outgoing);
|
|
704
|
+
}
|
|
705
|
+
this._emit({ type: 'stepChanged', index, step });
|
|
706
|
+
this._onStepPainted(index, step);
|
|
707
|
+
}
|
|
708
|
+
_buildSlots() {
|
|
709
|
+
for (const step of this.steps) {
|
|
710
|
+
this._slots.push({
|
|
711
|
+
title: this._bindSlot(step, 'title'),
|
|
712
|
+
description: this._bindSlot(step, 'description'),
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
_bindSlot(step, name) {
|
|
717
|
+
const resolved = this._resolveSlot(step, name);
|
|
718
|
+
if (resolved === null) {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
if (resolved.kind === 'text') {
|
|
722
|
+
return { kind: 'text', text: resolved.text };
|
|
723
|
+
}
|
|
724
|
+
return {
|
|
725
|
+
kind: 'view',
|
|
726
|
+
slot: resolved,
|
|
727
|
+
host: document.createElement('div'),
|
|
728
|
+
view: null,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
_renderSlots(index) {
|
|
732
|
+
const slots = this._slots[index];
|
|
733
|
+
if (slots === undefined) {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
for (const binding of [slots.title, slots.description]) {
|
|
737
|
+
if (binding === null ||
|
|
738
|
+
binding.kind !== 'view' ||
|
|
739
|
+
binding.view !== null) {
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
binding.view = this._contentRenderer.render(binding.slot, this._contentContext(index, binding.slot), binding.host);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
_destroySlots(index) {
|
|
746
|
+
const slots = this._slots[index];
|
|
747
|
+
if (slots === undefined) {
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
for (const binding of [slots.title, slots.description]) {
|
|
751
|
+
if (binding === null ||
|
|
752
|
+
binding.kind !== 'view' ||
|
|
753
|
+
binding.view === null) {
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
binding.view.destroy();
|
|
757
|
+
binding.view = null;
|
|
758
|
+
binding.host.replaceChildren();
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
_destroyAllSlots() {
|
|
762
|
+
for (let index = 0; index < this._slots.length; index++) {
|
|
763
|
+
this._destroySlots(index);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/** `data` is per slot, so the context is built per slot rather than per step. */
|
|
767
|
+
_contentContext(index, slot) {
|
|
768
|
+
return {
|
|
769
|
+
$implicit: slot.data,
|
|
770
|
+
data: slot.data,
|
|
771
|
+
text: slot.text,
|
|
772
|
+
step: this.steps[index],
|
|
773
|
+
index,
|
|
774
|
+
total: this.steps.length,
|
|
775
|
+
guide: this._getRef(),
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Arms mid-step loss detection for a painted step.
|
|
780
|
+
*
|
|
781
|
+
* Only named targets are watched — a selector or `Element` has no
|
|
782
|
+
* notification channel, so recovery does not apply to them in v1.
|
|
783
|
+
*/
|
|
784
|
+
_onStepPainted(index, step) {
|
|
785
|
+
// A re-entrant `close()` from inside `_renderSlots` (see `_paint`) can
|
|
786
|
+
// reach here via the same synchronous call stack that `close()` itself
|
|
787
|
+
// triggered — `_paint` returns before calling this when it detects that
|
|
788
|
+
// case, but a defensive check here is what stops `TheSeamGuideTargetRegistry`
|
|
789
|
+
// (`providedIn: 'root'`) from ever seeing a subscription arm for a session
|
|
790
|
+
// that no longer exists to disarm it.
|
|
791
|
+
if (this._closed) {
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
this._disarmRecovery();
|
|
795
|
+
const generation = this._recoveryGeneration;
|
|
796
|
+
const name = typeof step.element === 'string' ? step.element : null;
|
|
797
|
+
if (name === null) {
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
// `_resolveNow` — not the registry's own `resolve` — is the same lookup
|
|
801
|
+
// that feeds the popover's resolver closure (registry, then selector
|
|
802
|
+
// fallback). Arming and the loss check below must agree with it: a name
|
|
803
|
+
// that also happens to be a live selector (e.g. `'nav'`) must not be
|
|
804
|
+
// treated as lost while the selector still finds it on screen.
|
|
805
|
+
if (this._resolveNow(step) === null) {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
this._recoverySub = this._registry.changes$
|
|
809
|
+
.pipe(filter((changed) => changed === name), filter(() => this._resolveNow(step) === null), take(1), switchMap(() => {
|
|
810
|
+
this._emit({ type: 'targetLost', index, step });
|
|
811
|
+
const grace = this.options.targetLostGrace;
|
|
812
|
+
return this._registry.waitFor(name, grace).pipe(map(() => 'recovered'), catchError(() => of('lost')));
|
|
813
|
+
}), tap((outcome) => {
|
|
814
|
+
if (this._closed) {
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (outcome === 'recovered') {
|
|
818
|
+
this._adapter.refresh();
|
|
819
|
+
this._emit({ type: 'targetRecovered', index, step });
|
|
820
|
+
// Re-arm on a microtask: `_onStepPainted` calls `_disarmRecovery`,
|
|
821
|
+
// which would otherwise unsubscribe this subscription from inside
|
|
822
|
+
// its own `tap`. Guarded by generation: if the session has moved
|
|
823
|
+
// on (another `_disarmRecovery()` ran in between — a transition,
|
|
824
|
+
// or a fresher arm for this same step), this queued re-arm must
|
|
825
|
+
// not resurrect detection for a step that is no longer active.
|
|
826
|
+
queueMicrotask(() => {
|
|
827
|
+
if (!this._closed && this._recoveryGeneration === generation) {
|
|
828
|
+
this._onStepPainted(index, step);
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
this._applyTargetLostPolicy(index, step);
|
|
834
|
+
}), catchError((err) => {
|
|
835
|
+
// Swallowed deliberately, not silently: a broken recovery pipeline
|
|
836
|
+
// must not close a guide the user is actively reading, but it
|
|
837
|
+
// should still be visible to whoever is developing against it.
|
|
838
|
+
if (isDevMode()) {
|
|
839
|
+
console.warn(`TheSeamGuideSession: mid-step recovery for step ${index}` +
|
|
840
|
+
` threw (${String(err)}); recovery detection for this step` +
|
|
841
|
+
' is now disarmed.');
|
|
842
|
+
}
|
|
843
|
+
return EMPTY;
|
|
844
|
+
}))
|
|
845
|
+
.subscribe();
|
|
846
|
+
}
|
|
847
|
+
_applyTargetLostPolicy(index, step) {
|
|
848
|
+
const policy = step.onTargetLost ?? this.options.onTargetLost;
|
|
849
|
+
if (policy === 'end') {
|
|
850
|
+
this.close('targetMissing');
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (policy === 'skip') {
|
|
854
|
+
this._request(index + 1, 1);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
// 'elementless' — the resolver now returns undefined, so a refresh collapses
|
|
858
|
+
// the popover to centered without a step transition. This is terminal:
|
|
859
|
+
// recovery detection was already disarmed on entry to this branch (the
|
|
860
|
+
// `take(1)` above), and nothing re-arms it afterwards, so if the target
|
|
861
|
+
// returns later the popover stays centered until the next transition.
|
|
862
|
+
this._adapter.refresh();
|
|
863
|
+
}
|
|
864
|
+
_disarmRecovery() {
|
|
865
|
+
this._recoveryGeneration++;
|
|
866
|
+
this._recoverySub?.unsubscribe();
|
|
867
|
+
this._recoverySub = null;
|
|
868
|
+
}
|
|
869
|
+
_runHook(hook) {
|
|
870
|
+
if (hook === undefined) {
|
|
871
|
+
return of(null);
|
|
872
|
+
}
|
|
873
|
+
return defer(() => {
|
|
874
|
+
const result = hook();
|
|
875
|
+
if (result === undefined || result === null) {
|
|
876
|
+
return of(null);
|
|
877
|
+
}
|
|
878
|
+
if (isObservable(result)) {
|
|
879
|
+
return result.pipe(take(1));
|
|
880
|
+
}
|
|
881
|
+
return from(result);
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
/** Resolves `'resolved'` or `'missing'`. Never throws. */
|
|
885
|
+
_resolveTarget(step) {
|
|
886
|
+
const target = step.element;
|
|
887
|
+
if (target === undefined) {
|
|
888
|
+
return of('resolved');
|
|
889
|
+
}
|
|
890
|
+
if (typeof target !== 'string') {
|
|
891
|
+
const el = target instanceof Element ? target : target.nativeElement;
|
|
892
|
+
return of(el?.isConnected ? 'resolved' : 'missing');
|
|
893
|
+
}
|
|
894
|
+
const direct = this._registry.resolve(target);
|
|
895
|
+
if (direct !== null) {
|
|
896
|
+
return of('resolved');
|
|
897
|
+
}
|
|
898
|
+
const selectorMatch = safeQuerySelector(target);
|
|
899
|
+
if (selectorMatch !== null) {
|
|
900
|
+
return of('resolved');
|
|
901
|
+
}
|
|
902
|
+
const timeoutMs = step.targetTimeout ?? this.options.targetTimeout;
|
|
903
|
+
return this._registry.waitFor(target, timeoutMs).pipe(map(() => 'resolved'), catchError(() => of('missing')));
|
|
904
|
+
}
|
|
905
|
+
_applyMissPolicy(index, step, direction) {
|
|
906
|
+
const policy = step.onMissingTarget ?? this.options.onMissingTarget;
|
|
907
|
+
if (policy === 'end') {
|
|
908
|
+
this.close('targetMissing');
|
|
909
|
+
return EMPTY;
|
|
910
|
+
}
|
|
911
|
+
if (policy === 'elementless') {
|
|
912
|
+
this._paint(index, step);
|
|
913
|
+
return EMPTY;
|
|
914
|
+
}
|
|
915
|
+
if (isDevMode()) {
|
|
916
|
+
console.warn(`TheSeamGuideSession: skipping step ${index} because its target` +
|
|
917
|
+
` "${String(step.element)}" never appeared.`);
|
|
918
|
+
}
|
|
919
|
+
this._emit({ type: 'stepSkipped', index, step });
|
|
920
|
+
const nextIndex = index + direction;
|
|
921
|
+
if (nextIndex < 0 || nextIndex >= this.steps.length) {
|
|
922
|
+
this.close(direction === 1 ? 'completed' : 'dismissed');
|
|
923
|
+
return EMPTY;
|
|
924
|
+
}
|
|
925
|
+
this._request(nextIndex, direction);
|
|
926
|
+
return EMPTY;
|
|
927
|
+
}
|
|
928
|
+
/** Element is a resolver function so the engine re-resolves at paint time. */
|
|
929
|
+
_toAdapterStep(step, index) {
|
|
930
|
+
return {
|
|
931
|
+
element: step.element === undefined
|
|
932
|
+
? undefined
|
|
933
|
+
: () => this._resolveNow(step) ?? undefined,
|
|
934
|
+
popover: this._toAdapterPopover(step, index),
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* `ExhaustiveMap` makes every key of `TheSeamGuidePopover` required in
|
|
939
|
+
* `mapped`, so adding a field to the popover is a compile error here until
|
|
940
|
+
* it is carried through. This is the exact hop on which `side` and `align`
|
|
941
|
+
* were once silently dropped by a spread.
|
|
942
|
+
*/
|
|
943
|
+
_toAdapterPopover(step, index) {
|
|
944
|
+
const slots = this._slots[index];
|
|
945
|
+
const mapped = {
|
|
946
|
+
title: slotValue(slots?.title),
|
|
947
|
+
description: slotValue(slots?.description),
|
|
948
|
+
side: this._nearestScalar(step, 'side'),
|
|
949
|
+
align: this._nearestScalar(step, 'align'),
|
|
950
|
+
};
|
|
951
|
+
return Object.values(mapped).every((value) => value === undefined)
|
|
952
|
+
? undefined
|
|
953
|
+
: mapped;
|
|
954
|
+
}
|
|
955
|
+
_resolveSlot(step, name) {
|
|
956
|
+
return resolveGuideContentSlot({
|
|
957
|
+
provider: this._popoverDefaults[name],
|
|
958
|
+
session: this._sessionPopover?.[name],
|
|
959
|
+
step: step.popover?.[name],
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
_nearestScalar(step, key) {
|
|
963
|
+
return (step.popover?.[key] ??
|
|
964
|
+
this._sessionPopover?.[key] ??
|
|
965
|
+
this._popoverDefaults[key]);
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Synchronous best-effort resolution, used by the adapter's live element
|
|
969
|
+
* resolver at paint time. `_resolveTarget` is the awaiting version used to
|
|
970
|
+
* gate transitions.
|
|
971
|
+
*/
|
|
972
|
+
_resolveNow(step) {
|
|
973
|
+
const target = step.element;
|
|
974
|
+
if (target === undefined) {
|
|
975
|
+
return null;
|
|
976
|
+
}
|
|
977
|
+
if (typeof target === 'string') {
|
|
978
|
+
return this._registry.resolve(target) ?? safeQuerySelector(target);
|
|
979
|
+
}
|
|
980
|
+
if (target instanceof Element) {
|
|
981
|
+
return target;
|
|
982
|
+
}
|
|
983
|
+
return target.nativeElement;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
function stripUndefined(config) {
|
|
987
|
+
const { steps: _steps, popover: _popover, ...rest } = config;
|
|
988
|
+
const out = {};
|
|
989
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
990
|
+
if (value !== undefined) {
|
|
991
|
+
out[key] = value;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return out;
|
|
995
|
+
}
|
|
996
|
+
/** A text slot goes to the engine as a string; a view slot as its host node. */
|
|
997
|
+
function slotValue(binding) {
|
|
998
|
+
if (binding === null || binding === undefined) {
|
|
999
|
+
return undefined;
|
|
1000
|
+
}
|
|
1001
|
+
return binding.kind === 'text' ? binding.text : binding.host;
|
|
1002
|
+
}
|
|
1003
|
+
/** `querySelector` throws on an invalid selector; a registry name often is one. */
|
|
1004
|
+
function safeQuerySelector(selector) {
|
|
1005
|
+
try {
|
|
1006
|
+
return document.querySelector(selector);
|
|
1007
|
+
}
|
|
1008
|
+
catch {
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
class TheSeamGuideService {
|
|
1014
|
+
_adapter = inject(THE_SEAM_GUIDE_ADAPTER);
|
|
1015
|
+
_registry = inject(TheSeamGuideTargetRegistry);
|
|
1016
|
+
_popoverDefaults = inject(THE_SEAM_GUIDE_POPOVER_DEFAULTS, { optional: true }) ?? {};
|
|
1017
|
+
_contentRenderer = inject(TheSeamGuideDomContentRenderer);
|
|
1018
|
+
_activeGuide = signal(null, ...(ngDevMode ? [{ debugName: "_activeGuide" }] : []));
|
|
1019
|
+
/**
|
|
1020
|
+
* The running guide, or null. Exposed so a caller can queue itself:
|
|
1021
|
+
* `activeGuide()?.afterClosed$.subscribe(() => start(next))`.
|
|
1022
|
+
*/
|
|
1023
|
+
activeGuide = this._activeGuide.asReadonly();
|
|
1024
|
+
/**
|
|
1025
|
+
* Starts a guide. One runs at a time: a dismissible active guide is
|
|
1026
|
+
* superseded, a non-dismissible one throws `TheSeamGuideBusyError`.
|
|
1027
|
+
*
|
|
1028
|
+
* The caller owns the returned ref's lifetime. A guide is **not** closed
|
|
1029
|
+
* automatically when the component that started it is destroyed — only
|
|
1030
|
+
* when the root injector is (this service is `providedIn: 'root'`), which
|
|
1031
|
+
* does not happen on ordinary route/component teardown. A component that
|
|
1032
|
+
* starts a guide and may be destroyed before it naturally ends should tie
|
|
1033
|
+
* the ref to its own lifetime:
|
|
1034
|
+
*
|
|
1035
|
+
* ```ts
|
|
1036
|
+
* const ref = this._guide.start(config)
|
|
1037
|
+
* inject(DestroyRef).onDestroy(() => ref.close())
|
|
1038
|
+
* ```
|
|
1039
|
+
*/
|
|
1040
|
+
start(config) {
|
|
1041
|
+
const active = this._activeGuide();
|
|
1042
|
+
if (active !== null) {
|
|
1043
|
+
if (!active.dismissible) {
|
|
1044
|
+
throw new TheSeamGuideBusyError();
|
|
1045
|
+
}
|
|
1046
|
+
active.close('superseded');
|
|
1047
|
+
}
|
|
1048
|
+
if (isDevMode() &&
|
|
1049
|
+
config.dismissible === false &&
|
|
1050
|
+
(config.onMissingTarget ?? THE_SEAM_GUIDE_DEFAULTS.onMissingTarget) ===
|
|
1051
|
+
'skip') {
|
|
1052
|
+
console.warn('TheSeamGuideService: this guide sets `dismissible: false` with' +
|
|
1053
|
+
" `onMissingTarget: 'skip'`, so the user is forced through a guide" +
|
|
1054
|
+
' that may silently drop its own steps. Consider onMissingTarget:' +
|
|
1055
|
+
" 'end' or 'elementless'.");
|
|
1056
|
+
}
|
|
1057
|
+
// eslint-disable-next-line prefer-const -- captured by the closure below before assignment
|
|
1058
|
+
let ref;
|
|
1059
|
+
const session = new TheSeamGuideSession(config, {
|
|
1060
|
+
adapter: this._adapter,
|
|
1061
|
+
registry: this._registry,
|
|
1062
|
+
contentRenderer: this._contentRenderer,
|
|
1063
|
+
popoverDefaults: this._popoverDefaults,
|
|
1064
|
+
getRef: () => ref,
|
|
1065
|
+
onClosed: () => this._clearIfCurrent(ref),
|
|
1066
|
+
});
|
|
1067
|
+
ref = new TheSeamGuideRef(session);
|
|
1068
|
+
this._activeGuide.set(ref);
|
|
1069
|
+
session.start();
|
|
1070
|
+
return ref;
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Highlights a single element. A one-step guide.
|
|
1074
|
+
*
|
|
1075
|
+
* As with {@link start}, the caller owns the returned ref's lifetime: it is
|
|
1076
|
+
* not closed automatically when the component that requested it is
|
|
1077
|
+
* destroyed. See {@link start}'s doc comment for the `DestroyRef` pattern.
|
|
1078
|
+
*/
|
|
1079
|
+
highlight(step) {
|
|
1080
|
+
return this.start({ steps: [step] });
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Closes any active guide when the owning injector is destroyed —
|
|
1084
|
+
* otherwise driver.js's overlay is left in the DOM, and its
|
|
1085
|
+
* `pointer-events: none` blocks every click on the page with no recovery
|
|
1086
|
+
* short of a reload. `close` always works programmatically even when the
|
|
1087
|
+
* guide is `dismissible: false`, which is exactly the case that must not
|
|
1088
|
+
* be left behind.
|
|
1089
|
+
*/
|
|
1090
|
+
ngOnDestroy() {
|
|
1091
|
+
this._activeGuide()?.close('destroyed');
|
|
1092
|
+
}
|
|
1093
|
+
_clearIfCurrent(ref) {
|
|
1094
|
+
if (this._activeGuide() === ref) {
|
|
1095
|
+
this._activeGuide.set(null);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: TheSeamGuideService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1099
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: TheSeamGuideService, providedIn: 'root' });
|
|
1100
|
+
}
|
|
1101
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: TheSeamGuideService, decorators: [{
|
|
1102
|
+
type: Injectable,
|
|
1103
|
+
args: [{ providedIn: 'root' }]
|
|
1104
|
+
}] });
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Angular-free renderer for specs. Records what the session asked to render so
|
|
1108
|
+
* a test can assert view lifetime without a `TestBed`.
|
|
1109
|
+
*/
|
|
1110
|
+
class TheSeamFakeGuideContentRenderer {
|
|
1111
|
+
renders = [];
|
|
1112
|
+
render(slot, context, host) {
|
|
1113
|
+
const record = {
|
|
1114
|
+
slot,
|
|
1115
|
+
context,
|
|
1116
|
+
host,
|
|
1117
|
+
destroyed: false,
|
|
1118
|
+
};
|
|
1119
|
+
this.renders.push(record);
|
|
1120
|
+
host.textContent = context.text ?? '';
|
|
1121
|
+
return {
|
|
1122
|
+
destroy: () => {
|
|
1123
|
+
record.destroyed = true;
|
|
1124
|
+
},
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
/** Renders that have not been destroyed. */
|
|
1128
|
+
get live() {
|
|
1129
|
+
return this.renders.filter((r) => !r.destroyed);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Engine-free adapter for specs. Records what the service asked for and lets a
|
|
1135
|
+
* test simulate user intent without a DOM.
|
|
1136
|
+
*/
|
|
1137
|
+
class TheSeamFakeGuideAdapter {
|
|
1138
|
+
calls = [];
|
|
1139
|
+
startedConfig = null;
|
|
1140
|
+
_callbacks = null;
|
|
1141
|
+
_active = false;
|
|
1142
|
+
start(config, callbacks) {
|
|
1143
|
+
this.startedConfig = config;
|
|
1144
|
+
this._callbacks = callbacks;
|
|
1145
|
+
this._active = true;
|
|
1146
|
+
this.calls.push('start');
|
|
1147
|
+
}
|
|
1148
|
+
next() {
|
|
1149
|
+
this.calls.push('next');
|
|
1150
|
+
}
|
|
1151
|
+
previous() {
|
|
1152
|
+
this.calls.push('previous');
|
|
1153
|
+
}
|
|
1154
|
+
moveTo(index) {
|
|
1155
|
+
this.calls.push(`moveTo:${index}`);
|
|
1156
|
+
}
|
|
1157
|
+
refresh() {
|
|
1158
|
+
this.calls.push('refresh');
|
|
1159
|
+
}
|
|
1160
|
+
destroy() {
|
|
1161
|
+
this._active = false;
|
|
1162
|
+
// Dropped, not merely inert-by-`_active`: `emitNext`/`emitPrevious`/
|
|
1163
|
+
// `emitClose` call through `_callbacks` directly and do not consult
|
|
1164
|
+
// `_active`, so a stale reference here would still fire callbacks a test
|
|
1165
|
+
// simulates after destroy.
|
|
1166
|
+
this._callbacks = null;
|
|
1167
|
+
this.calls.push('destroy');
|
|
1168
|
+
}
|
|
1169
|
+
isActive() {
|
|
1170
|
+
return this._active;
|
|
1171
|
+
}
|
|
1172
|
+
/** Resolves the element for a step, as the engine would at paint time. */
|
|
1173
|
+
resolveStepElement(index) {
|
|
1174
|
+
return this.startedConfig?.steps[index]?.element?.();
|
|
1175
|
+
}
|
|
1176
|
+
emitNext() {
|
|
1177
|
+
this._callbacks?.onNextRequested();
|
|
1178
|
+
}
|
|
1179
|
+
emitPrevious() {
|
|
1180
|
+
this._callbacks?.onPreviousRequested();
|
|
1181
|
+
}
|
|
1182
|
+
emitClose() {
|
|
1183
|
+
this._callbacks?.onCloseRequested();
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Generated bundle index. Do not edit.
|
|
1189
|
+
*/
|
|
1190
|
+
|
|
1191
|
+
export { THE_SEAM_GUIDE_ADAPTER, THE_SEAM_GUIDE_CONTENT, THE_SEAM_GUIDE_DEFAULTS, THE_SEAM_GUIDE_POPOVER_DEFAULTS, TheSeamFakeGuideAdapter, TheSeamFakeGuideContentRenderer, TheSeamGuideBusyError, TheSeamGuideRef, TheSeamGuideService, TheSeamGuideTargetDirective, TheSeamGuideTargetRegistry, TheSeamGuideTargetTimeoutError, provideTheSeamGuide };
|
|
1192
|
+
//# sourceMappingURL=theseam-ui-common-guide.mjs.map
|