diff --git a/projects/ion/src/lib/core/types/tour.ts b/projects/ion/src/lib/core/types/tour.ts index 0ab773f4d..a5825e59e 100644 --- a/projects/ion/src/lib/core/types/tour.ts +++ b/projects/ion/src/lib/core/types/tour.ts @@ -19,7 +19,7 @@ export interface IonTourStepProps { ionOnPrevStep?: EventEmitter; ionOnNextStep?: EventEmitter; ionOnFinishTour?: EventEmitter; - target?: DOMRect; + getTarget?: () => DOMRect; } export interface IonStartTourProps { diff --git a/projects/ion/src/lib/tour/mocks/resizing-host-demo.component.ts b/projects/ion/src/lib/tour/mocks/resizing-host-demo.component.ts new file mode 100644 index 000000000..1b5a53961 --- /dev/null +++ b/projects/ion/src/lib/tour/mocks/resizing-host-demo.component.ts @@ -0,0 +1,80 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; + +import { IonTourService } from '../tour.service'; + +@Component({ + selector: 'tour-resizing-host', + styles: [ + ` + main { + height: 800px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + position: relative; + + ion-button { + position: absolute; + } + } + `, + ], + template: ` +
+ +
+ + +

+ The host is changing its size and position, but the tour should still + work +

+
+ `, +}) +export class TourResizingHostComponent implements OnInit, OnDestroy { + public top = 100; + public left = 50; + public buttonWidth = 100; + + private interval: ReturnType; + + constructor(private readonly ionTourService: IonTourService) {} + + public ngOnInit(): void { + this.ionTourService.start(); + this.animateButtonSize(); + } + + public ngOnDestroy(): void { + if (this.interval) { + clearInterval(this.interval); + } + } + + private animateButtonSize(): void { + this.interval = setInterval(() => { + this.top = this.getRandomNumber(0, 700); + this.left = this.getRandomNumber(0, 500); + this.buttonWidth = this.getRandomNumber(50, 300); + }, 700); + } + + private getRandomNumber(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; + } +} diff --git a/projects/ion/src/lib/tour/mocks/tour-basic-demo.component.ts b/projects/ion/src/lib/tour/mocks/tour-basic-demo.component.ts index 0142c85e6..132a49609 100644 --- a/projects/ion/src/lib/tour/mocks/tour-basic-demo.component.ts +++ b/projects/ion/src/lib/tour/mocks/tour-basic-demo.component.ts @@ -85,8 +85,10 @@ export const STEP3_MOCK: IonTourStepProps = { [ionNextStepId]="step2.ionNextStepId" [ionStepTitle]="step2.ionStepTitle" [ionStepBody]="saveStep" + (ionOnNextStep)="markOptionStepAsVisible()" > ({ x: 300, y: 300, width: 100, @@ -24,7 +24,7 @@ const STEP_MOCK = { right: 400, left: 300, top: 300, - } as DOMRect, + }), } as IonTourStepProps; describe('IonTourBackdropComponent', () => { @@ -36,9 +36,7 @@ describe('IonTourBackdropComponent', () => { it('should render with custom class', async () => { const ionStepBackdropCustomClass = 'custom-class'; - await sut({ - currentStep: { ...STEP_MOCK, ionStepBackdropCustomClass }, - }); + await sut({ currentStep: { ...STEP_MOCK, ionStepBackdropCustomClass } }); expect(screen.queryByTestId('ion-tour-backdrop')).toHaveClass( ionStepBackdropCustomClass @@ -59,6 +57,14 @@ describe('IonTourBackdropComponent', () => { expect(screen.queryByTestId('ion-tour-backdrop')).not.toBeInTheDocument(); }); + it('should backdrop with empty clip-path when the step is not active', async () => { + const { fixture } = await sut(); + fixture.componentInstance.updateStep(null); + expect(screen.queryByTestId('ion-tour-backdrop')).toHaveStyle({ + clipPath: '', + }); + }); + it('should stop rendering when performFinalTransition is called', async () => { jest.useFakeTimers(); const { fixture } = await sut({ inTransition: true }); diff --git a/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.ts b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.ts index b1a2452da..301db42a3 100644 --- a/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.ts +++ b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; import { DomSanitizer, SafeStyle } from '@angular/platform-browser'; import { IonTourStepProps } from '../../core/types'; @@ -9,20 +9,46 @@ import { IonTourStepProps } from '../../core/types'; styleUrls: ['./tour-backdrop.component.scss'], }) export class IonTourBackdropComponent implements OnInit { - @Input() currentStep: IonTourStepProps | null = null; @Input() isActive = false; + public currentStep: IonTourStepProps | null = null; public inTransition = true; + public clipPath: SafeStyle = ''; - public get clipPath(): SafeStyle { + constructor( + private sanitizer: DomSanitizer, + private cdr: ChangeDetectorRef + ) {} + + public ngOnInit(): void { + setTimeout(() => (this.inTransition = false)); + } + + public updateStep(step: IonTourStepProps | null): void { + this.currentStep = step; + this.updateClipPath(); + } + + public performFinalTransition(callback: () => void): void { + const transitionDuration = 400; + this.inTransition = true; + + setTimeout(() => { + this.inTransition = false; + callback(); + }, transitionDuration); + } + + private updateClipPath(): void { if (!this.currentStep) { - return ''; + this.clipPath = ''; + return; } - const { target, ionStepBackdropPadding: padding } = this.currentStep; - const { top, left, bottom, right } = target; + const { getTarget, ionStepBackdropPadding: padding } = this.currentStep; + const { top, left, bottom, right } = getTarget(); - return this.sanitizer.bypassSecurityTrustStyle(`polygon( + this.clipPath = this.sanitizer.bypassSecurityTrustStyle(`polygon( 0 0, 0 100%, ${left - padding}px 100%, @@ -34,21 +60,7 @@ export class IonTourBackdropComponent implements OnInit { 100% 100%, 100% 0 )`); - } - - constructor(private sanitizer: DomSanitizer) {} - - public ngOnInit(): void { - setTimeout(() => (this.inTransition = false)); - } - public performFinalTransition(callback: () => void): void { - const transitionDuration = 400; - this.inTransition = true; - - setTimeout(() => { - this.inTransition = false; - callback(); - }, transitionDuration); + this.cdr.detectChanges(); } } diff --git a/projects/ion/src/lib/tour/tour-step.directive.spec.ts b/projects/ion/src/lib/tour/tour-step.directive.spec.ts index 3f53bf16f..4126d03ee 100644 --- a/projects/ion/src/lib/tour/tour-step.directive.spec.ts +++ b/projects/ion/src/lib/tour/tour-step.directive.spec.ts @@ -1,19 +1,24 @@ +import { ChangeDetectorRef, ElementRef, ViewContainerRef } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; import { fireEvent, render, RenderResult, screen, } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; import { cloneDeep } from 'lodash'; import { EMPTY, of } from 'rxjs'; import { IonButtonModule } from '../button/button.module'; import { IonTourStepProps } from '../core/types'; +import { IonPopoverModule } from '../popover/popover.module'; +import { IonPositionService } from '../position/position.service'; +import { TourResizingHostComponent } from './mocks/resizing-host-demo.component'; import { TourStepDemoComponent } from './mocks/tour-step-props.component'; +import { IonTourStepDirective } from './tour-step.directive'; import { IonTourModule } from './tour.module'; import { IonTourService } from './tour.service'; -import { IonPopoverModule } from '../popover/popover.module'; -import userEvent from '@testing-library/user-event'; const DEFAULT_PROPS: Partial = { ionTourId: 'demo-tour', @@ -23,7 +28,7 @@ const DEFAULT_PROPS: Partial = { ionNextStepBtn: { label: 'Test Next' }, }; -const tourServiceMock: Partial = { +const tourServiceMock = { saveStep: jest.fn(), removeStep: jest.fn(), start: jest.fn(), @@ -34,6 +39,26 @@ const tourServiceMock: Partial = { currentStep$: EMPTY, }; +const generateRandomDOMRect = (): DOMRect => { + const data = { + x: Math.random() * 100, + y: Math.random() * 100, + width: Math.random() * 200 + 50, + height: Math.random() * 200 + 50, + bottom: Math.random() * 100 + 200, + right: Math.random() * 100 + 200, + left: Math.random() * 50, + top: Math.random() * 50, + }; + return { ...data, toJSON: () => data } as DOMRect; +}; + +const elementRefMock = { + nativeElement: { + getBoundingClientRect: jest.fn().mockImplementation(generateRandomDOMRect), + }, +}; + function setActiveTour(tourId: string): void { Object.defineProperty(tourServiceMock, 'activeTour$', { value: of(tourId) }); } @@ -49,17 +74,43 @@ const sut = async ( ): Promise> => { const result = await render(TourStepDemoComponent, { imports: [IonButtonModule, IonTourModule, IonPopoverModule], - providers: [{ provide: IonTourService, useValue: tourServiceMock }], + providers: [ + { provide: ElementRef, useValue: elementRefMock }, + { provide: IonTourService, useValue: tourServiceMock }, + ], componentProperties: { ...DEFAULT_PROPS, ...props, }, }); - jest.runAllTimers(); + jest.runOnlyPendingTimers(); result.fixture.detectChanges(); return result; }; +const sutResizingHostDemo = (): void => { + TestBed.configureTestingModule({ + imports: [IonButtonModule, IonTourModule, IonPopoverModule], + declarations: [TourResizingHostComponent], + providers: [ + IonTourService, + IonTourStepDirective, + IonPositionService, + ViewContainerRef, + ChangeDetectorRef, + { provide: ElementRef, useValue: elementRefMock }, + ], + }); + + TestBed.get(IonTourStepDirective)['hostPositionChanged'] = jest + .fn() + .mockReturnValue(true); + + TestBed.createComponent(TourResizingHostComponent).detectChanges(); + + jest.runOnlyPendingTimers(); +}; + describe('IonTourStepDirective', () => { afterEach(() => { jest.clearAllMocks(); @@ -103,6 +154,18 @@ describe('IonTourStepDirective', () => { expect(screen.queryByText(newlabel)).toBeInTheDocument(); }); + it('should reposition popover when host position changes', async () => { + sutResizingHostDemo(); + + const directive = TestBed.get(IonTourStepDirective); + const spy = jest.spyOn(directive, 'repositionPopover'); + directive.observeHostPosition(); + + jest.runOnlyPendingTimers(); + + expect(spy).toHaveBeenCalled(); + }); + describe('popover actions', () => { it('should render a default previous button', async () => { const step = cloneDeep(DEFAULT_PROPS) as unknown as IonTourStepProps; diff --git a/projects/ion/src/lib/tour/tour-step.directive.ts b/projects/ion/src/lib/tour/tour-step.directive.ts index 66192870d..a35a8fbdf 100644 --- a/projects/ion/src/lib/tour/tour-step.directive.ts +++ b/projects/ion/src/lib/tour/tour-step.directive.ts @@ -11,6 +11,7 @@ import { Inject, Injector, Input, + NgZone, OnChanges, OnDestroy, OnInit, @@ -55,6 +56,9 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { private isTourActive = false; private destroy$ = new Subject(); + private interval: ReturnType; + private hostPosition: DOMRect; + constructor( @Inject(DOCUMENT) private document: SafeAny, private componentFactoryResolver: ComponentFactoryResolver, @@ -64,7 +68,8 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { private injector: Injector, private cdr: ChangeDetectorRef, private tourService: IonTourService, - private positionService: IonPositionService + private positionService: IonPositionService, + private ngZone: NgZone ) {} public ngOnInit(): void { @@ -72,16 +77,23 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { this.tourService.activeTour$ .pipe(takeUntil(this.destroy$)) - .subscribe((isActive) => { - this.isTourActive = isActive === this.ionTourId; + .subscribe((activeTourId) => { + this.isTourActive = activeTourId === this.ionTourId; this.checkPopoverVisibility(); }); this.tourService.currentStep$ .pipe(takeUntil(this.destroy$)) .subscribe((step) => { - this.isStepSelected = step && step.ionStepId === this.ionStepId; - this.checkPopoverVisibility(); + if (step) { + const isSameStep = step.ionStepId === this.ionStepId; + if (this.isStepSelected !== isSameStep) { + this.isStepSelected = isSameStep; + this.checkPopoverVisibility(); + } + } else { + this.isStepSelected = false; + } }); } @@ -97,6 +109,22 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { this.tourService.removeStep(this.ionStepId); this.destroy$.next(); this.destroy$.complete(); + if (this.interval) { + clearInterval(this.interval); + } + } + + private observeHostPosition(): void { + this.ngZone.runOutsideAngular(() => { + const interval30FPSinMs = 1000 / 30; + this.interval = setInterval(() => { + this.ngZone.run(() => { + if (this.hostPositionChanged()) { + this.repositionPopover(); + } + }); + }, interval30FPSinMs); + }); } @HostListener('window:resize', ['$event']) @@ -105,9 +133,8 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { const contentRect = this.popoverRef.instance.popover.nativeElement.getBoundingClientRect(); - this.positionService.setHostPosition( - this.elementRef.nativeElement.getBoundingClientRect() - ); + this.hostPosition = this.elementRef.nativeElement.getBoundingClientRect(); + this.positionService.setHostPosition(this.hostPosition); this.positionService.setChoosedPosition(this.ionStepPosition); this.positionService.setElementPadding(this.ionStepMarginToContent); this.positionService.setcomponentCoordinates(contentRect); @@ -131,7 +158,10 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { this.destroyPopoverElement(); if (this.isTourActive && this.isStepSelected) { - setTimeout(() => this.createPopoverElement()); + setTimeout(() => { + this.createPopoverElement(); + this.observeHostPosition(); + }); } } @@ -178,6 +208,7 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { } this.cdr.detectChanges(); + this.repositionPopover(); } private listenToPopoverEvents(): void { @@ -194,6 +225,18 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { }); } + private hostPositionChanged(): boolean { + const newPosition = this.elementRef.nativeElement.getBoundingClientRect(); + return !( + this.hostPosition && + newPosition && + this.hostPosition.x === newPosition.x && + this.hostPosition.y === newPosition.y && + this.hostPosition.width === newPosition.width && + this.hostPosition.height === newPosition.height + ); + } + private destroyPopoverElement(): void { if (this.popoverRef) { this.appRef.detachView(this.popoverRef.hostView); @@ -220,7 +263,7 @@ export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { ionOnPrevStep: this.ionOnPrevStep, ionOnNextStep: this.ionOnNextStep, ionOnFinishTour: this.ionOnFinishTour, - target: this.elementRef.nativeElement.getBoundingClientRect(), + getTarget: () => this.elementRef.nativeElement.getBoundingClientRect(), }; } } diff --git a/projects/ion/src/lib/tour/tour.service.spec.ts b/projects/ion/src/lib/tour/tour.service.spec.ts index bd9d6b483..b9f0f2644 100644 --- a/projects/ion/src/lib/tour/tour.service.spec.ts +++ b/projects/ion/src/lib/tour/tour.service.spec.ts @@ -26,7 +26,7 @@ const backdropComponentMock = { hostView: {}, location: { nativeElement: document.createElement('div') }, changeDetectorRef: { detectChanges: jest.fn() }, - instance: { performFinalTransition }, + instance: { performFinalTransition, updateStep: jest.fn() }, destroy: jest.fn(), }; @@ -55,7 +55,7 @@ const stepsMock: IonTourStepProps[] = [ { ionTourId: 'tour1', ionStepId: 'step1', - target: TARGET_MOCK, + getTarget: () => TARGET_MOCK, ionStepTitle: 'Step 1', ionNextStepId: 'step2', ionOnPrevStep: new EventEmitter(), @@ -65,7 +65,7 @@ const stepsMock: IonTourStepProps[] = [ { ionTourId: 'tour1', ionStepId: 'step2', - target: TARGET_MOCK, + getTarget: () => TARGET_MOCK, ionStepTitle: 'Step 2', ionPrevStepId: 'step1', ionOnPrevStep: new EventEmitter(), @@ -125,7 +125,9 @@ describe('IonTourService', () => { const updatedStep = { ...step1, - target: { toJSON: () => ({ ...TARGET_MOCK, x: 400 }) }, + getTarget: () => ({ + toJSON: (): DOMRect => ({ ...TARGET_MOCK, x: 400 }), + }), } as IonTourStepProps; service.saveStep(step1); @@ -134,6 +136,7 @@ describe('IonTourService', () => { expect(service.currentStep.value).toEqual(step1); service.saveStep(updatedStep); + jest.runAllTimers(); expect(service.currentStep.value).toEqual(updatedStep); }); }); @@ -237,6 +240,7 @@ describe('IonTourService', () => { const spy = jest.spyOn(step1.ionOnNextStep, 'emit'); service.nextStep(); + jest.runAllTimers(); expect(service.currentStep.value).toEqual(step2); expect(spy).toHaveBeenCalledTimes(1); @@ -251,18 +255,20 @@ describe('IonTourService', () => { jest.runAllTimers(); service.nextStep(); + jest.runAllTimers(); expect(service.currentStep.value).toEqual(step2); const spy = jest.spyOn(step2.ionOnPrevStep, 'emit'); service.prevStep(); + jest.runAllTimers(); expect(service.currentStep.value).toEqual(step1); expect(spy).toHaveBeenCalledTimes(1); }); it('should finish the tour if there is no next step and nextStep is called', () => { - const [step] = stepsMock; + const step = { ...stepsMock[0], ionNextStepId: undefined }; service.saveStep(step); service.start({ tourId: step.ionTourId }); @@ -271,6 +277,7 @@ describe('IonTourService', () => { const spyNext = jest.spyOn(step.ionOnNextStep, 'emit'); const spyFinish = jest.spyOn(step.ionOnFinishTour, 'emit'); service.nextStep(); + jest.runAllTimers(); expect(service.currentStep.value).toBeNull(); expect(service.activeTour.value).toBeNull(); @@ -288,6 +295,7 @@ describe('IonTourService', () => { const spyPrev = jest.spyOn(step.ionOnPrevStep, 'emit'); const spyFinish = jest.spyOn(step.ionOnFinishTour, 'emit'); service.prevStep(); + jest.runAllTimers(); expect(service.currentStep.value).toBeNull(); expect(service.activeTour.value).toBeNull(); diff --git a/projects/ion/src/lib/tour/tour.service.ts b/projects/ion/src/lib/tour/tour.service.ts index 5916582f2..4eb07da6f 100644 --- a/projects/ion/src/lib/tour/tour.service.ts +++ b/projects/ion/src/lib/tour/tour.service.ts @@ -55,12 +55,7 @@ export class IonTourService { } const current = this.currentStep.value; - - if ( - current && - current.ionStepId === step.ionStepId && - !isEqual(step.target.toJSON(), current.target.toJSON()) - ) { + if (current && current.ionStepId === step.ionStepId) { this.navigateToStep(step); } @@ -107,30 +102,38 @@ export class IonTourService { const currentStep = this.currentStep.getValue(); currentStep.ionOnPrevStep.emit(); - const prevStep = this._tours[this.activeTourId].get( - currentStep.ionPrevStepId - ); - - if (prevStep) { - this.navigateToStep(prevStep); - } else { + if (!currentStep.ionPrevStepId) { this.finish(); + return; } + + setTimeout(() => { + const prevStep = this._tours[this.activeTourId].get( + currentStep.ionPrevStepId + ); + + if (prevStep) { + this.navigateToStep(prevStep); + } + }); } public nextStep(): void { const currentStep = this.currentStep.getValue(); currentStep.ionOnNextStep.emit(); - const nextStep = this._tours[this.activeTourId].get( - currentStep.ionNextStepId - ); - - if (nextStep) { - this.navigateToStep(nextStep); - } else { + if (!currentStep.ionNextStepId) { this.finish(); + return; } + + setTimeout(() => { + const nextStep = this._tours[this.activeTourId].get( + currentStep.ionNextStepId + ); + + this.navigateToStep(nextStep); + }); } private getFirstStep( @@ -159,10 +162,10 @@ export class IonTourService { this.appRef.attachView(this.backdropRef.hostView); - const popoverElement = this.backdropRef.location + const backdropElement = this.backdropRef.location .nativeElement as HTMLElement; - this.document.body.appendChild(popoverElement); + this.document.body.appendChild(backdropElement); this.backdropRef.changeDetectorRef.detectChanges(); this.updateBackdropProps(); } @@ -172,7 +175,7 @@ export class IonTourService { .pipe(takeUntil(this.destroyBackdrop$)) .subscribe((step) => { if (this.backdropRef) { - this.backdropRef.instance.currentStep = step; + this.backdropRef.instance.updateStep(step); } }); diff --git a/stories/TourDocs.stories.mdx b/stories/TourDocs.stories.mdx index 71fecdc1a..278323c84 100644 --- a/stories/TourDocs.stories.mdx +++ b/stories/TourDocs.stories.mdx @@ -40,10 +40,9 @@ Passos para usar o tour em uma tela: - `ionStepMarginToContent` é o espaçamento entre o balão de fala e o inicio da borda do backdrop; - `ionStepBackdropPadding` é o espaçamento que fica entre o elemento destacado e o inicio da borda escurecida do backdrop; - `ionStepCustomClass` é uma classe customizada que pode ser aplicada ao balão de fala da etapa; -- `ionStepBackdropCustomClas` é uma classe customizada que pode ser aplicada ao backdrop da etapa; +- `ionStepBackdropCustomClass` é uma classe customizada que pode ser aplicada ao backdrop da etapa; - `ionOnPrevStep` é um evento que será disparado ao clicar no botão de voltar para a etapa anterior; - `ionOnNextStep` é um evento que será disparado ao clicar no botão de avançar para a próxima etapa; -- `ionOnCloseStep` é um evento que será disparado ao clicar no botão de fechar o tour. 2. No arquivo .ts do seu componente, use o serviço `IonTourService` para iniciar o tour: diff --git a/stories/TourResizing.stories.ts b/stories/TourResizing.stories.ts new file mode 100644 index 000000000..3986b6de1 --- /dev/null +++ b/stories/TourResizing.stories.ts @@ -0,0 +1,33 @@ +import { CommonModule } from '@angular/common'; +import { Meta, Story } from '@storybook/angular'; + +import { IonTourModule } from '../projects/ion/src/lib/tour'; +import { IonSharedModule } from '../projects/ion/src/public-api'; +import { TourResizingHostComponent } from '../projects/ion/src/lib/tour/mocks/resizing-host-demo.component'; + +const Template: Story = ( + args: TourResizingHostComponent +) => ({ + component: TourResizingHostComponent, + props: args, + moduleMetadata: { + declarations: [TourResizingHostComponent], + imports: [CommonModule, IonSharedModule, IonTourModule], + entryComponents: [TourResizingHostComponent], + }, +}); + +export const HostResizing = Template.bind({}); +HostResizing.args = { + ionStepTitle: 'Title Example', + ionStepBody: 'You can change the props of this step in Storybook controls', + ionPrevStepBtn: { label: 'Close' }, + ionNextStepBtn: { label: 'Finish' }, + ionStepMarginToContent: 5, + ionStepBackdropPadding: 5, +}; + +export default { + title: 'Ion/Data Display/Tour', + component: TourResizingHostComponent, +} as Meta;