@seniorsistemas/angular-components 19.9.1 → 19.9.2
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/esm2022/spotlight/lib/spotlight/spotlight-overlay/rect-stability.mjs +61 -0
- package/esm2022/spotlight/lib/spotlight/spotlight-overlay/spotlight-overlay.component.mjs +91 -21
- package/fesm2022/seniorsistemas-angular-components-spotlight.mjs +150 -20
- package/fesm2022/seniorsistemas-angular-components-spotlight.mjs.map +1 -1
- package/package.json +7 -7
- package/spotlight/README.md +28 -0
- package/spotlight/lib/spotlight/spotlight-overlay/rect-stability.d.ts +32 -0
- package/spotlight/lib/spotlight/spotlight-overlay/spotlight-overlay.component.d.ts +33 -0
|
@@ -197,6 +197,67 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
197
197
|
args: [{ selector: 's-spotlight', standalone: true, imports: [CommonModule, ButtonModule, CheckboxComponent, TranslateModule], template: "<div\n [attr.role]=\"totalSteps() > 1 ? 'dialog' : 'tooltip'\"\n [attr.aria-modal]=\"totalSteps() > 1 ? 'true' : null\"\n [attr.aria-labelledby]=\"titleId\"\n [attr.aria-describedby]=\"descId\"\n class=\"flex max-w-[342px]\"\n [ngClass]=\"isArrowLeft || isArrowRight ? 'flex-row' : 'flex-col'\"\n>\n @if (isArrowTop) {\n <ng-container *ngTemplateOutlet=\"arrowTopTemplate\"></ng-container>\n }\n\n @if (isArrowLeft) {\n <ng-container *ngTemplateOutlet=\"arrowLeftTemplate\"></ng-container>\n }\n\n <div\n class=\"relative flex w-full flex-col overflow-hidden rounded-[4px] bg-grayscale-0\"\n style=\"\n box-shadow:\n 0 3px 5px rgba(0, 0, 0, 0.2),\n 0 1px 18px rgba(0, 0, 0, 0.12),\n 0 6px 10px rgba(0, 0, 0, 0.14);\n \"\n >\n <!-- Regi\u00E3o aria-live para anuncia\u00E7\u00E3o de mudan\u00E7a de passo para leitores de tela -->\n <span\n class=\"sr-only\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n >\n @if (totalSteps() > 1) {\n {{\n 'platform.angular_components.step_progress'\n | translate: { current: currentStep(), total: totalSteps() }\n }}\n }\n </span>\n <div class=\"flex h-[40px] w-full items-center justify-between p-[12px]\">\n <div class=\"flex min-w-0 flex-1 items-center gap-[8px]\">\n <h2\n [id]=\"titleId\"\n class=\"flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap font-open-sans text-[14px] font-bold leading-[1.5] text-grayscale-90\"\n >\n @if (titleTemplate) {\n <ng-container *ngTemplateOutlet=\"titleTemplate\"></ng-container>\n } @else {\n {{ titleString }}\n }\n </h2>\n </div>\n <button\n (click)=\"onClose()\"\n class=\"flex h-[16px] w-[16px] flex-none items-center justify-center text-grayscale-90 transition-colors hover:text-grayscale-100\"\n [attr.aria-label]=\"'platform.angular_components.close' | translate\"\n >\n <i class=\"fas fa-times\"></i>\n </button>\n </div>\n\n @if (content()) {\n <ng-container *ngTemplateOutlet=\"content()\"></ng-container>\n }\n\n <div class=\"flex w-full flex-col items-start gap-[20px] bg-grayscale-0 p-[12px]\">\n <div\n [id]=\"descId\"\n class=\"w-full whitespace-pre-wrap font-open-sans text-[14px] font-normal leading-[1.5] text-grayscale-90\"\n >\n @if (messageTemplate) {\n <ng-container *ngTemplateOutlet=\"messageTemplate\"></ng-container>\n } @else {\n {{ messageString }}\n }\n </div>\n\n <div class=\"flex w-full flex-wrap gap-3\">\n @if (totalSteps() > 1) {\n <p class=\"font-open-sans text-[14px] font-normal leading-[1.5] text-grayscale-90\">\n {{\n 'platform.angular_components.step_counter'\n | translate: { current: currentStep(), total: totalSteps() }\n }}\n </p>\n }\n\n @if (totalSteps() === 1 && showDoNotShowAgain()) {\n <s-checkbox\n [checked]=\"isDoNotShowAgainChecked()\"\n (checkedChange)=\"toggleDoNotShowAgain()\"\n [label]=\"'platform.angular_components.dont_show_again' | translate\"\n ></s-checkbox>\n }\n\n <div\n class=\"flex flex-wrap items-start justify-end gap-[8px]\"\n [ngClass]=\"totalSteps() > 1 ? 'w-full' : ''\"\n >\n @for (action of actions(); track action.label) {\n <s-button\n [label]=\"action.label\"\n (clicked)=\"action.handler()\"\n priority=\"default\"\n size=\"small\"\n class=\"flex-none\"\n ></s-button>\n }\n\n @if (totalSteps() > 1 && currentStep() > 1) {\n <s-button\n [label]=\"'platform.angular_components.back' | translate\"\n (clicked)=\"onPrevious()\"\n priority=\"default\"\n size=\"small\"\n class=\"flex-none\"\n ></s-button>\n }\n\n <s-button\n [label]=\"\n totalSteps() <= 1\n ? ('platform.angular_components.understood' | translate)\n : currentStep() < totalSteps()\n ? ('platform.angular_components.next' | translate)\n : ('platform.angular_components.complete' | translate)\n \"\n (clicked)=\"totalSteps() > 1 ? onNext() : onUnderstand()\"\n priority=\"secondary\"\n size=\"small\"\n class=\"flex-none\"\n ></s-button>\n </div>\n </div>\n </div>\n </div>\n\n @if (isArrowBottom) {\n <ng-container *ngTemplateOutlet=\"arrowBottomTemplate\"></ng-container>\n }\n\n @if (isArrowRight) {\n <ng-container *ngTemplateOutlet=\"arrowRightTemplate\"></ng-container>\n }\n</div>\n\n<!-- Arrow Templates -->\n<ng-template #arrowTopTemplate>\n <div\n class=\"relative z-10 flex h-[8px] w-full px-[12px] py-0\"\n [ngClass]=\"arrowAlignmentClass\"\n [ngStyle]=\"arrowPaddingStyle\"\n >\n <svg\n width=\"16\"\n height=\"8\"\n viewBox=\"0 0 16 8\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\"\n class=\"text-grayscale-0\"\n [ngClass]=\"arrowRotation\"\n >\n <path\n d=\"M0 8L8 0L16 8\"\n fill=\"currentColor\"\n />\n </svg>\n </div>\n</ng-template>\n\n<ng-template #arrowBottomTemplate>\n <div\n class=\"relative z-10 flex h-[8px] w-full px-[12px] py-0\"\n [ngClass]=\"arrowAlignmentClass\"\n [ngStyle]=\"arrowPaddingStyle\"\n >\n <svg\n width=\"16\"\n height=\"8\"\n viewBox=\"0 0 16 8\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\"\n class=\"text-grayscale-0\"\n [ngClass]=\"arrowRotation\"\n >\n <path\n d=\"M0 8L8 0L16 8\"\n fill=\"currentColor\"\n />\n </svg>\n </div>\n</ng-template>\n\n<ng-template #arrowLeftTemplate>\n <div\n class=\"relative z-10 flex w-[8px] flex-col items-center self-stretch px-0\"\n [ngClass]=\"arrowAlignmentClass\"\n [ngStyle]=\"arrowPaddingStyle\"\n >\n <svg\n width=\"16\"\n height=\"8\"\n viewBox=\"0 0 16 8\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\"\n class=\"text-grayscale-0\"\n [ngClass]=\"arrowRotation\"\n >\n <path\n d=\"M0 8L8 0L16 8\"\n fill=\"currentColor\"\n />\n </svg>\n </div>\n</ng-template>\n\n<ng-template #arrowRightTemplate>\n <div\n class=\"relative z-10 flex w-[8px] flex-col items-center self-stretch px-0\"\n [ngClass]=\"arrowAlignmentClass\"\n [ngStyle]=\"arrowPaddingStyle\"\n >\n <svg\n width=\"16\"\n height=\"8\"\n viewBox=\"0 0 16 8\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\"\n class=\"text-grayscale-0\"\n [ngClass]=\"arrowRotation\"\n >\n <path\n d=\"M0 8L8 0L16 8\"\n fill=\"currentColor\"\n />\n </svg>\n </div>\n</ng-template>\n\n" }]
|
|
198
198
|
}] });
|
|
199
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Utilitário puro (sem dependência de Angular/NgZone) para aguardar até que a posição
|
|
202
|
+
* e o tamanho de um elemento parem de mudar — usado para medir corretamente elementos
|
|
203
|
+
* que podem estar em transição/animação (ex: um menu abrindo) no momento em que o
|
|
204
|
+
* Spotlight precisa apontar para eles.
|
|
205
|
+
*/
|
|
206
|
+
const DEFAULT_RECT_STABILITY_OPTIONS = {
|
|
207
|
+
requiredStableSamples: 3,
|
|
208
|
+
tolerancePx: 0.5,
|
|
209
|
+
maxWaitMs: 600,
|
|
210
|
+
};
|
|
211
|
+
function isRectStable(a, b, tolerancePx) {
|
|
212
|
+
return (Math.abs(a.top - b.top) <= tolerancePx &&
|
|
213
|
+
Math.abs(a.left - b.left) <= tolerancePx &&
|
|
214
|
+
Math.abs(a.width - b.width) <= tolerancePx &&
|
|
215
|
+
Math.abs(a.height - b.height) <= tolerancePx);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Faz polling de `el.getBoundingClientRect()` em frames sucessivos até que
|
|
219
|
+
* `requiredStableSamples` amostras consecutivas sejam iguais (dentro de `tolerancePx`),
|
|
220
|
+
* ou até que `maxWaitMs` seja atingido — o que ocorrer primeiro.
|
|
221
|
+
*
|
|
222
|
+
* `scheduleFrame`/`cancelFrame`/`now` são injetáveis para permitir testes determinísticos;
|
|
223
|
+
* por padrão usam as APIs nativas do navegador.
|
|
224
|
+
*/
|
|
225
|
+
function waitForStableRect(el, options = DEFAULT_RECT_STABILITY_OPTIONS, scheduleFrame = requestAnimationFrame, cancelFrame = cancelAnimationFrame, now = () => performance.now()) {
|
|
226
|
+
let cancelled = false;
|
|
227
|
+
let frameHandle = 0;
|
|
228
|
+
let resolveFn;
|
|
229
|
+
const promise = new Promise((resolve) => {
|
|
230
|
+
resolveFn = resolve;
|
|
231
|
+
});
|
|
232
|
+
const startedAt = now();
|
|
233
|
+
let lastRect = el.getBoundingClientRect();
|
|
234
|
+
let stableCount = 1;
|
|
235
|
+
const tick = () => {
|
|
236
|
+
if (cancelled)
|
|
237
|
+
return;
|
|
238
|
+
const rect = el.getBoundingClientRect();
|
|
239
|
+
stableCount = isRectStable(rect, lastRect, options.tolerancePx) ? stableCount + 1 : 1;
|
|
240
|
+
lastRect = rect;
|
|
241
|
+
if (stableCount >= options.requiredStableSamples) {
|
|
242
|
+
resolveFn({ rect, timedOut: false });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (now() - startedAt >= options.maxWaitMs) {
|
|
246
|
+
resolveFn({ rect, timedOut: true });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
frameHandle = scheduleFrame(tick);
|
|
250
|
+
};
|
|
251
|
+
frameHandle = scheduleFrame(tick);
|
|
252
|
+
return {
|
|
253
|
+
promise,
|
|
254
|
+
cancel: () => {
|
|
255
|
+
cancelled = true;
|
|
256
|
+
cancelFrame(frameHandle);
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
200
261
|
const POPOVER_WIDTH = 342;
|
|
201
262
|
const SPOTLIGHT_PADDING = 8;
|
|
202
263
|
const ARROW_HALF_SIZE = 8;
|
|
@@ -206,6 +267,8 @@ const MASK_BG = 'rgba(0,0,0,0.5)';
|
|
|
206
267
|
const MASK_TRANSITION = 'all 0.2s ease';
|
|
207
268
|
const OVERLAY_Z = '10000';
|
|
208
269
|
const POPOVER_Z = '10001';
|
|
270
|
+
/** Tempo máximo de espera pelo registro do elemento-alvo (ex: criado dinamicamente dentro de `beforeNext`) antes de recorrer ao popover centralizado. */
|
|
271
|
+
const ELEMENT_WAIT_TIMEOUT_MS = 500;
|
|
209
272
|
function toConnectedPositions(position) {
|
|
210
273
|
const [direction, alignment] = position.split('-');
|
|
211
274
|
const p = SPOTLIGHT_PADDING + 2;
|
|
@@ -257,6 +320,13 @@ class SpotlightOverlayComponent {
|
|
|
257
320
|
totalSteps = this.tourService.totalSteps;
|
|
258
321
|
/** @internal*/
|
|
259
322
|
targetRect = signal(null);
|
|
323
|
+
/**
|
|
324
|
+
* Controla se a máscara deve animar a transição entre retângulos (reposicionamento
|
|
325
|
+
* em vivo, ex: scroll/resize) ou saltar instantaneamente (chegada/saída de um passo).
|
|
326
|
+
* Ver `setTargetRectImmediate`.
|
|
327
|
+
*/
|
|
328
|
+
maskTransitionEnabled = signal(true);
|
|
329
|
+
maskTransitionStyle = computed(() => this.maskTransitionEnabled() ? MASK_TRANSITION : 'none');
|
|
260
330
|
/** @internal */
|
|
261
331
|
topMaskStyle = computed(() => {
|
|
262
332
|
const rect = this.targetRect();
|
|
@@ -264,7 +334,7 @@ class SpotlightOverlayComponent {
|
|
|
264
334
|
position: 'fixed',
|
|
265
335
|
'z-index': OVERLAY_Z,
|
|
266
336
|
background: MASK_BG,
|
|
267
|
-
transition:
|
|
337
|
+
transition: this.maskTransitionStyle(),
|
|
268
338
|
cursor: 'default',
|
|
269
339
|
};
|
|
270
340
|
if (!rect) {
|
|
@@ -287,7 +357,7 @@ class SpotlightOverlayComponent {
|
|
|
287
357
|
position: 'fixed',
|
|
288
358
|
'z-index': OVERLAY_Z,
|
|
289
359
|
background: MASK_BG,
|
|
290
|
-
transition:
|
|
360
|
+
transition: this.maskTransitionStyle(),
|
|
291
361
|
cursor: 'default',
|
|
292
362
|
top: `${top}px`,
|
|
293
363
|
left: '0',
|
|
@@ -309,7 +379,7 @@ class SpotlightOverlayComponent {
|
|
|
309
379
|
position: 'fixed',
|
|
310
380
|
'z-index': OVERLAY_Z,
|
|
311
381
|
background: MASK_BG,
|
|
312
|
-
transition:
|
|
382
|
+
transition: this.maskTransitionStyle(),
|
|
313
383
|
cursor: 'default',
|
|
314
384
|
top: `${top}px`,
|
|
315
385
|
left: `${right}px`,
|
|
@@ -329,7 +399,7 @@ class SpotlightOverlayComponent {
|
|
|
329
399
|
position: 'fixed',
|
|
330
400
|
'z-index': OVERLAY_Z,
|
|
331
401
|
background: MASK_BG,
|
|
332
|
-
transition:
|
|
402
|
+
transition: this.maskTransitionStyle(),
|
|
333
403
|
cursor: 'default',
|
|
334
404
|
top: `${bottom}px`,
|
|
335
405
|
left: '0',
|
|
@@ -353,6 +423,9 @@ class SpotlightOverlayComponent {
|
|
|
353
423
|
popoverSubs = [];
|
|
354
424
|
focusTrap = null;
|
|
355
425
|
previouslyFocusedElement = null;
|
|
426
|
+
pendingArrival = null;
|
|
427
|
+
elementWaitTimerId = null;
|
|
428
|
+
stabilizeGeneration = 0;
|
|
356
429
|
constructor() {
|
|
357
430
|
combineLatest([
|
|
358
431
|
toObservable(this.currentStep),
|
|
@@ -362,34 +435,74 @@ class SpotlightOverlayComponent {
|
|
|
362
435
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
363
436
|
.subscribe(([step, active]) => {
|
|
364
437
|
this.disconnectResizeObserver();
|
|
438
|
+
this.cancelPendingArrival();
|
|
439
|
+
const generation = ++this.stabilizeGeneration;
|
|
365
440
|
if (!step || !active) {
|
|
366
|
-
this.
|
|
441
|
+
this.setTargetRectImmediate(null);
|
|
367
442
|
this.destroyPopoverOverlay();
|
|
368
443
|
return;
|
|
369
444
|
}
|
|
370
445
|
const el = this.tourService.getElement(step.stepId);
|
|
371
446
|
if (el?.nativeElement) {
|
|
372
447
|
el.nativeElement.scrollIntoView({ block: 'nearest' });
|
|
373
|
-
|
|
374
|
-
this.zone.runOutsideAngular(() => {
|
|
375
|
-
requestAnimationFrame(() => {
|
|
376
|
-
this.zone.run(() => {
|
|
377
|
-
if (!this.isActive()) {
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
this.updateTargetRect(target);
|
|
381
|
-
this.observeElement(target);
|
|
382
|
-
this.updatePopoverOverlay(target, step);
|
|
383
|
-
});
|
|
384
|
-
});
|
|
385
|
-
});
|
|
448
|
+
this.beginArrivalSequence(el.nativeElement, step, generation);
|
|
386
449
|
}
|
|
387
450
|
else {
|
|
388
|
-
this.
|
|
389
|
-
this.updatePopoverOverlay(null, step);
|
|
451
|
+
this.awaitElementRegistration(step, generation);
|
|
390
452
|
}
|
|
391
453
|
});
|
|
392
454
|
}
|
|
455
|
+
/**
|
|
456
|
+
* Aguarda o layout do elemento-alvo estabilizar (ex: animação de abertura de um menu
|
|
457
|
+
* disparada pelo `beforeNext` do passo anterior) antes de medir sua posição definitiva
|
|
458
|
+
* e posicionar a máscara/popover. Substitui a antiga espera de um único frame, que
|
|
459
|
+
* capturava o retângulo no meio de animações de terceiros na tela.
|
|
460
|
+
*/
|
|
461
|
+
beginArrivalSequence(target, step, generation) {
|
|
462
|
+
this.zone.runOutsideAngular(() => {
|
|
463
|
+
const handle = waitForStableRect(target);
|
|
464
|
+
this.pendingArrival = handle;
|
|
465
|
+
handle.promise.then(({ rect, timedOut }) => {
|
|
466
|
+
this.zone.run(() => {
|
|
467
|
+
if (generation !== this.stabilizeGeneration || !this.isActive()) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (timedOut) {
|
|
471
|
+
console.warn(`[Spotlight] O layout do elemento do passo "${step.stepId}" não estabilizou a tempo; usando a última posição medida.`);
|
|
472
|
+
}
|
|
473
|
+
this.setTargetRectImmediate(rect);
|
|
474
|
+
this.observeElement(target);
|
|
475
|
+
this.updatePopoverOverlay(target, step);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Aguarda até `ELEMENT_WAIT_TIMEOUT_MS` pelo registro do elemento-alvo via
|
|
482
|
+
* `[sSpotlightStep]` (útil quando o `beforeNext` do passo anterior cria o elemento
|
|
483
|
+
* dinamicamente, ex: abrindo uma aba) antes de recorrer ao popover centralizado.
|
|
484
|
+
* Mantém o último quadro renderizado durante a espera para evitar um flash.
|
|
485
|
+
*/
|
|
486
|
+
awaitElementRegistration(step, generation) {
|
|
487
|
+
this.elementWaitTimerId = setTimeout(() => {
|
|
488
|
+
this.zone.run(() => {
|
|
489
|
+
if (generation !== this.stabilizeGeneration) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
console.warn(`[Spotlight] Elemento do passo "${step.stepId}" não foi registrado a tempo; exibindo popover centralizado sem destaque.`);
|
|
493
|
+
this.setTargetRectImmediate(null);
|
|
494
|
+
this.updatePopoverOverlay(null, step);
|
|
495
|
+
});
|
|
496
|
+
}, ELEMENT_WAIT_TIMEOUT_MS);
|
|
497
|
+
}
|
|
498
|
+
cancelPendingArrival() {
|
|
499
|
+
this.pendingArrival?.cancel();
|
|
500
|
+
this.pendingArrival = null;
|
|
501
|
+
if (this.elementWaitTimerId !== null) {
|
|
502
|
+
clearTimeout(this.elementWaitTimerId);
|
|
503
|
+
this.elementWaitTimerId = null;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
393
506
|
ngOnInit() {
|
|
394
507
|
this.zone.runOutsideAngular(() => {
|
|
395
508
|
const scrollOpts = { capture: true, passive: true };
|
|
@@ -417,6 +530,7 @@ class SpotlightOverlayComponent {
|
|
|
417
530
|
});
|
|
418
531
|
}
|
|
419
532
|
ngOnDestroy() {
|
|
533
|
+
this.cancelPendingArrival();
|
|
420
534
|
this.destroyPopoverOverlay();
|
|
421
535
|
}
|
|
422
536
|
/** @internal */
|
|
@@ -631,6 +745,22 @@ class SpotlightOverlayComponent {
|
|
|
631
745
|
updateTargetRect(el) {
|
|
632
746
|
this.targetRect.set(el.getBoundingClientRect());
|
|
633
747
|
}
|
|
748
|
+
/**
|
|
749
|
+
* Aplica um novo `targetRect` sem animação de transição na máscara — usado ao
|
|
750
|
+
* chegar/saltar para um alvo novo, onde animar a partir da posição do passo
|
|
751
|
+
* anterior criaria a falsa impressão de "apontou errado e corrigiu depois".
|
|
752
|
+
* A transição é reabilitada no frame seguinte para reposicionamentos legítimos
|
|
753
|
+
* (scroll/resize de um alvo já visível).
|
|
754
|
+
*/
|
|
755
|
+
setTargetRectImmediate(rect) {
|
|
756
|
+
this.maskTransitionEnabled.set(false);
|
|
757
|
+
this.targetRect.set(rect);
|
|
758
|
+
this.zone.runOutsideAngular(() => {
|
|
759
|
+
requestAnimationFrame(() => {
|
|
760
|
+
this.zone.run(() => this.maskTransitionEnabled.set(true));
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
}
|
|
634
764
|
refreshRect() {
|
|
635
765
|
const step = this.currentStep();
|
|
636
766
|
if (!step || !this.isActive()) {
|