diff --git a/projects/ion/src/lib/core/types/index.ts b/projects/ion/src/lib/core/types/index.ts index 1de7e4cc3..1efcd9a73 100644 --- a/projects/ion/src/lib/core/types/index.ts +++ b/projects/ion/src/lib/core/types/index.ts @@ -35,4 +35,5 @@ export * from './tab-group'; export * from './table'; export * from './tag'; export * from './tooltip'; +export * from './tour'; export * from './triple-toggle'; diff --git a/projects/ion/src/lib/core/types/tour.ts b/projects/ion/src/lib/core/types/tour.ts new file mode 100644 index 000000000..0ab773f4d --- /dev/null +++ b/projects/ion/src/lib/core/types/tour.ts @@ -0,0 +1,27 @@ +import { EventEmitter } from '@angular/core'; + +import { PopoverButtonsProps, PopoverPosition, PopoverProps } from './popover'; + +export interface IonTourStepProps { + ionStepId: string; + ionTourId: string; + ionStepTitle?: PopoverProps['ionPopoverTitle']; + ionStepBody?: PopoverProps['ionPopoverBody']; + ionPrevStepId?: IonTourStepProps['ionStepId']; + ionNextStepId?: IonTourStepProps['ionStepId']; + ionPrevStepBtn?: PopoverButtonsProps; + ionNextStepBtn?: PopoverButtonsProps; + ionStepPosition?: PopoverPosition; + ionStepMarginToContent?: number; + ionStepBackdropPadding?: number; + ionStepCustomClass?: string; + ionStepBackdropCustomClass?: string; + ionOnPrevStep?: EventEmitter; + ionOnNextStep?: EventEmitter; + ionOnFinishTour?: EventEmitter; + target?: DOMRect; +} + +export interface IonStartTourProps { + tourId?: string; +} diff --git a/projects/ion/src/lib/position/position.service.ts b/projects/ion/src/lib/position/position.service.ts index 2a4186409..77543ae58 100644 --- a/projects/ion/src/lib/position/position.service.ts +++ b/projects/ion/src/lib/position/position.service.ts @@ -55,6 +55,10 @@ export class IonPositionService { private currentPosition: IonPositions; private pointAtCenter = true; + public setElementPadding(padding: number): void { + this.elementPadding = padding; + } + public setHostPosition(position: DOMRect): void { this.hostPosition = position; } diff --git a/projects/ion/src/lib/tour/index.ts b/projects/ion/src/lib/tour/index.ts new file mode 100644 index 000000000..2472fb9bd --- /dev/null +++ b/projects/ion/src/lib/tour/index.ts @@ -0,0 +1,3 @@ +export * from './tour-step.directive'; +export * from './tour.module'; +export * from './tour.service'; 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 new file mode 100644 index 000000000..0142c85e6 --- /dev/null +++ b/projects/ion/src/lib/tour/mocks/tour-basic-demo.component.ts @@ -0,0 +1,135 @@ +import { Component } from '@angular/core'; + +import { IonTourService } from '../tour.service'; +import { IonTourStepProps, PopoverPosition } from '../../core/types'; + +export enum DemoSteps { + UPLOAD = 'upload', + SAVE = 'save', + MORE_OPTIONS = 'more_options', +} + +export const STEP1_MOCK: IonTourStepProps = { + ionStepTitle: 'Upload Action', + ionTourId: 'basic-demo', + ionStepId: DemoSteps.UPLOAD, + ionNextStepId: DemoSteps.SAVE, + ionStepPosition: PopoverPosition.BOTTOM_CENTER, + ionPrevStepBtn: { label: 'Close' }, +}; + +export const STEP2_MOCK: IonTourStepProps = { + ionStepTitle: 'Save Action', + ionTourId: 'basic-demo', + ionStepId: DemoSteps.SAVE, + ionPrevStepId: DemoSteps.UPLOAD, + ionNextStepId: DemoSteps.MORE_OPTIONS, + ionStepPosition: PopoverPosition.BOTTOM_CENTER, +}; + +export const STEP3_MOCK: IonTourStepProps = { + ionStepTitle: 'More Options', + ionTourId: 'basic-demo', + ionStepId: DemoSteps.MORE_OPTIONS, + ionPrevStepId: DemoSteps.SAVE, + ionStepPosition: PopoverPosition.RIGHT_CENTER, + ionNextStepBtn: { label: 'Finish' }, +}; + +@Component({ + template: ` + + +
+ + +
+ + + +
+
+ + + Here is a random image: + Random Image + + + + Save your changes. + + + + Click to see other actions. + + `, +}) +export class TourBasicDemoComponent { + public tourId = 'basic-demo'; + + public step1 = STEP1_MOCK; + public step2 = STEP2_MOCK; + public step3 = STEP3_MOCK; + + constructor(private readonly ionTourService: IonTourService) {} + + public startTour(): void { + this.ionTourService.start(); + } +} diff --git a/projects/ion/src/lib/tour/mocks/tour-step-props.component.ts b/projects/ion/src/lib/tour/mocks/tour-step-props.component.ts new file mode 100644 index 000000000..e3d1944e3 --- /dev/null +++ b/projects/ion/src/lib/tour/mocks/tour-step-props.component.ts @@ -0,0 +1,74 @@ +import { AfterViewInit, Component, Input, OnChanges } from '@angular/core'; + +import { PopoverButtonsProps, PopoverPosition } from '../../core/types'; +import { IonTourService } from '../tour.service'; + +@Component({ + selector: 'tour-step-props', + template: ` + + +
+ +
+ + +

Step body content

+
+ `, +}) +export class TourStepDemoComponent implements AfterViewInit, OnChanges { + @Input() ionStepId = 'demo-step'; + @Input() ionTourId = 'demo-tour'; + @Input() ionStepTitle: string; + @Input() ionPrevStepBtn: PopoverButtonsProps; + @Input() ionNextStepBtn: PopoverButtonsProps; + @Input() ionPrevStepId: string; + @Input() ionNextStepId: string; + @Input() ionStepPosition: PopoverPosition; + @Input() ionStepMarginToContent: number; + @Input() ionStepBackdropPadding: number; + @Input() ionStepCustomClass: string; + @Input() ionStepBackdropCustomClass: string; + + constructor(private readonly ionTourService: IonTourService) {} + + public ngAfterViewInit(): void { + this.ionTourService.start(); + } + + public ngOnChanges(): void { + this.restartTour(); + } + + public restartTour(): void { + this.ionTourService.finish(); + this.ionTourService.start(); + } +} diff --git a/projects/ion/src/lib/tour/tour-backdrop/index.ts b/projects/ion/src/lib/tour/tour-backdrop/index.ts new file mode 100644 index 000000000..34877ac76 --- /dev/null +++ b/projects/ion/src/lib/tour/tour-backdrop/index.ts @@ -0,0 +1 @@ +export * from './tour-backdrop.component'; diff --git a/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.html b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.html new file mode 100644 index 000000000..f4b2a4b3f --- /dev/null +++ b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.html @@ -0,0 +1,10 @@ + diff --git a/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.scss b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.scss new file mode 100644 index 000000000..415f6cc95 --- /dev/null +++ b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.scss @@ -0,0 +1,37 @@ +@import '../../../styles/index.scss'; + +.ion-tour-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: $black-transparence-45; + transition: all 0.4s ease; + z-index: $zIndexMid; + + &-transition { + background-color: #00000000; + clip-path: polygon( + 0px 0px, + 0px 100%, + 0px 100%, + 0px 0, + 100% 0, + 100% 100%, + 0px 100%, + 0px 100%, + 100% 100%, + 100% 0px + ) !important; + } +} + +::ng-deep .ion-tour-popover { + position: absolute !important; + transform: translate(0) !important; + + .ion-popover__content-body { + max-height: unset !important; + } +} diff --git a/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.spec.ts b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.spec.ts new file mode 100644 index 000000000..5cf0bdb85 --- /dev/null +++ b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.spec.ts @@ -0,0 +1,74 @@ +import { render, RenderResult, screen } from '@testing-library/angular'; + +import { IonTourStepProps } from '../../core/types'; +import { IonTourBackdropComponent } from './tour-backdrop.component'; + +const sut = async ( + props: Partial = {} +): Promise> => { + return render(IonTourBackdropComponent, { + declarations: [IonTourBackdropComponent], + componentProperties: { + ...props, + }, + }); +}; + +const STEP_MOCK = { + target: { + x: 300, + y: 300, + width: 100, + height: 100, + bottom: 400, + right: 400, + left: 300, + top: 300, + } as DOMRect, +} as IonTourStepProps; + +describe('IonTourBackdropComponent', () => { + it('should render', async () => { + await sut(); + expect(screen.queryByTestId('ion-tour-backdrop')).toBeInTheDocument(); + }); + + it('should render with custom class', async () => { + const ionStepBackdropCustomClass = 'custom-class'; + + await sut({ + currentStep: { ...STEP_MOCK, ionStepBackdropCustomClass }, + }); + + expect(screen.queryByTestId('ion-tour-backdrop')).toHaveClass( + ionStepBackdropCustomClass + ); + }); + + describe('transitions', () => { + it('should render with transition', async () => { + await sut({ inTransition: true }); + expect(screen.queryByTestId('ion-tour-backdrop')).toHaveClass( + 'ion-tour-backdrop-transition' + ); + }); + + it('should stop rendering when the transition ends and the tour remains inactive', async () => { + const { rerender } = await sut({ inTransition: true, isActive: false }); + rerender({ inTransition: false }); + expect(screen.queryByTestId('ion-tour-backdrop')).not.toBeInTheDocument(); + }); + + it('should stop rendering when performFinalTransition is called', async () => { + jest.useFakeTimers(); + const { fixture } = await sut({ inTransition: true }); + const callback = jest.fn(); + + fixture.componentInstance.performFinalTransition(callback); + + jest.runAllTimers(); + expect(screen.queryByTestId('ion-tour-backdrop')).not.toBeInTheDocument(); + expect(callback).toHaveBeenCalled(); + }); + }); +}); 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 new file mode 100644 index 000000000..b1a2452da --- /dev/null +++ b/projects/ion/src/lib/tour/tour-backdrop/tour-backdrop.component.ts @@ -0,0 +1,54 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { DomSanitizer, SafeStyle } from '@angular/platform-browser'; + +import { IonTourStepProps } from '../../core/types'; + +@Component({ + selector: 'ion-tour-backdrop', + templateUrl: './tour-backdrop.component.html', + styleUrls: ['./tour-backdrop.component.scss'], +}) +export class IonTourBackdropComponent implements OnInit { + @Input() currentStep: IonTourStepProps | null = null; + @Input() isActive = false; + + public inTransition = true; + + public get clipPath(): SafeStyle { + if (!this.currentStep) { + return ''; + } + + const { target, ionStepBackdropPadding: padding } = this.currentStep; + const { top, left, bottom, right } = target; + + return this.sanitizer.bypassSecurityTrustStyle(`polygon( + 0 0, + 0 100%, + ${left - padding}px 100%, + ${left - padding}px ${top - padding}px, + ${right + padding}px ${top - padding}px, + ${right + padding}px ${bottom + padding}px, + ${left - padding}px ${bottom + padding}px, + ${left - padding}px 100%, + 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); + } +} diff --git a/projects/ion/src/lib/tour/tour-position.calculator.ts b/projects/ion/src/lib/tour/tour-position.calculator.ts new file mode 100644 index 000000000..feab5c7af --- /dev/null +++ b/projects/ion/src/lib/tour/tour-position.calculator.ts @@ -0,0 +1,113 @@ +import { PopoverPosition } from '../core/types'; +import { GetPositionsCallbackProps } from '../position/position.service'; + +type PopoverPositions = { + [key in PopoverPosition]: Pick; +}; + +type GetPositionsCallback = ( + props: GetPositionsCallbackProps +) => PopoverPositions; + +interface PositionParams { + host: DOMRect; + popover: DOMRect; + arrowAtCenter: boolean; + hostHorizontalCenter: number; + hostVerticalCenter: number; + marginToContent: number; +} + +export function generatePositionCallback( + contentPadding: number, + marginToContent: number +): GetPositionsCallback { + return (props) => getPositionsPopover(props, contentPadding, marginToContent); +} + +export function getPositionsPopover( + props: GetPositionsCallbackProps, + contentPadding: number, + marginToContent: number +): PopoverPositions { + const { host, element: popover } = props; + const hostHorizontalCenter = Math.round(host.width / 2 + host.left); + const hostVerticalCenter = Math.round(host.height / 2 + host.top); + const calculatePositionProps = { + host: { + top: host.top - contentPadding, + bottom: host.bottom + contentPadding, + left: host.left - contentPadding, + right: host.right + contentPadding, + width: host.width + contentPadding * 2, + height: host.height + contentPadding * 2, + }, + popover, + hostHorizontalCenter, + hostVerticalCenter, + marginToContent, + } as PositionParams; + + return { + ...calculateTopPositions(calculatePositionProps), + ...calculateBottomPositions(calculatePositionProps), + ...calculateLeftPositions(calculatePositionProps), + ...calculateRightPositions(calculatePositionProps), + } as PopoverPositions; +} + +function calculateTopPositions({ + host, + popover, + marginToContent, + hostHorizontalCenter, +}: PositionParams): Partial { + const top = host.top - popover.height - marginToContent; + return { + topRight: { left: host.right - popover.width, top }, + topCenter: { left: hostHorizontalCenter - popover.width / 2, top }, + topLeft: { left: host.left, top }, + }; +} + +function calculateBottomPositions({ + host, + popover, + marginToContent, + hostHorizontalCenter, +}: PositionParams): Partial { + const top = host.bottom + marginToContent; + return { + bottomRight: { left: host.right - popover.width, top }, + bottomCenter: { left: hostHorizontalCenter - popover.width / 2, top }, + bottomLeft: { left: host.left, top }, + }; +} + +function calculateLeftPositions({ + host, + popover, + marginToContent, + hostVerticalCenter, +}: PositionParams): Partial { + const left = host.left - popover.width - marginToContent; + return { + leftBottom: { left, top: host.bottom - popover.height }, + leftCenter: { left, top: hostVerticalCenter - popover.height / 2 }, + leftTop: { left, top: host.top }, + }; +} + +function calculateRightPositions({ + host, + popover, + marginToContent, + hostVerticalCenter, +}: PositionParams): Partial { + const left = host.right + marginToContent; + return { + rightBottom: { left, top: host.bottom - popover.height }, + rightCenter: { left, top: hostVerticalCenter - popover.height / 2 }, + rightTop: { left, top: host.top }, + }; +} diff --git a/projects/ion/src/lib/tour/tour-step.directive.spec.ts b/projects/ion/src/lib/tour/tour-step.directive.spec.ts new file mode 100644 index 000000000..3f53bf16f --- /dev/null +++ b/projects/ion/src/lib/tour/tour-step.directive.spec.ts @@ -0,0 +1,180 @@ +import { + fireEvent, + render, + RenderResult, + screen, +} from '@testing-library/angular'; +import { cloneDeep } from 'lodash'; +import { EMPTY, of } from 'rxjs'; + +import { IonButtonModule } from '../button/button.module'; +import { IonTourStepProps } from '../core/types'; +import { TourStepDemoComponent } from './mocks/tour-step-props.component'; +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', + ionStepId: 'demo-step', + ionStepTitle: 'Test Title', + ionPrevStepBtn: { label: 'Test Prev' }, + ionNextStepBtn: { label: 'Test Next' }, +}; + +const tourServiceMock: Partial = { + saveStep: jest.fn(), + removeStep: jest.fn(), + start: jest.fn(), + finish: jest.fn(), + prevStep: jest.fn(), + nextStep: jest.fn(), + activeTour$: EMPTY, + currentStep$: EMPTY, +}; + +function setActiveTour(tourId: string): void { + Object.defineProperty(tourServiceMock, 'activeTour$', { value: of(tourId) }); +} + +function setCurrentStep(step: Partial): void { + Object.defineProperty(tourServiceMock, 'currentStep$', { value: of(step) }); +} + +jest.useFakeTimers(); + +const sut = async ( + props: Partial = {} +): Promise> => { + const result = await render(TourStepDemoComponent, { + imports: [IonButtonModule, IonTourModule, IonPopoverModule], + providers: [{ provide: IonTourService, useValue: tourServiceMock }], + componentProperties: { + ...DEFAULT_PROPS, + ...props, + }, + }); + jest.runAllTimers(); + result.fixture.detectChanges(); + return result; +}; + +describe('IonTourStepDirective', () => { + afterEach(() => { + jest.clearAllMocks(); + document.body.innerHTML = ''; + }); + + it('should save step on init', async () => { + const spy = jest.spyOn(tourServiceMock, 'saveStep'); + await sut(); + expect(spy).toHaveBeenCalled(); + }); + + it('should save step onchanges', async () => { + const { rerender } = await sut(); + const spy = jest.spyOn(tourServiceMock, 'saveStep'); + rerender({ ionStepTitle: 'New Title' }); + expect(spy).toHaveBeenCalled(); + }); + + it('should create the popover element when tourService says it is active', async () => { + const step = cloneDeep(DEFAULT_PROPS) as unknown as IonTourStepProps; + + setActiveTour(step.ionTourId); + setCurrentStep(step); + + await sut(); + expect(screen.queryByTestId('ion-popover')).toBeInTheDocument(); + }); + + it('should update popover when it is active and some prop changes', async () => { + const step = cloneDeep(DEFAULT_PROPS) as unknown as IonTourStepProps; + + setActiveTour(step.ionTourId); + setCurrentStep(step); + + const { rerender, fixture } = await sut(); + const newlabel = 'Next button new label'; + rerender({ ionPrevStepBtn: { label: newlabel } }); + fixture.detectChanges(); + + expect(screen.queryByText(newlabel)).toBeInTheDocument(); + }); + + describe('popover actions', () => { + it('should render a default previous button', async () => { + const step = cloneDeep(DEFAULT_PROPS) as unknown as IonTourStepProps; + + setActiveTour(step.ionTourId); + setCurrentStep(step); + + await sut({ ionPrevStepBtn: undefined }); + + const defaultPrevButtonlabel = 'Voltar'; + const [prevButton] = screen.getAllByTestId( + `btn-${defaultPrevButtonlabel}` + ); + expect(prevButton).toBeInTheDocument(); + }); + + it('should render a default next button', async () => { + const step = cloneDeep(DEFAULT_PROPS) as unknown as IonTourStepProps; + + setActiveTour(step.ionTourId); + setCurrentStep(step); + + await sut({ ionNextStepBtn: undefined }); + + const defaultNextButtonlabel = 'Continuar'; + const [nextButton] = screen.getAllByTestId( + `btn-${defaultNextButtonlabel}` + ); + expect(nextButton).toBeInTheDocument(); + }); + + it('should call prevStep when prev button is clicked', async () => { + const step = cloneDeep(DEFAULT_PROPS) as unknown as IonTourStepProps; + + setActiveTour(step.ionTourId); + setCurrentStep(step); + + await sut(); + const [prevButton] = screen.getAllByTestId( + `btn-${step.ionPrevStepBtn.label}` + ); + userEvent.click(prevButton); + + expect(tourServiceMock.prevStep).toHaveBeenCalled(); + }); + + it('should call nextStep when next button is clicked', async () => { + const step = cloneDeep(DEFAULT_PROPS) as unknown as IonTourStepProps; + + setActiveTour(step.ionTourId); + setCurrentStep(step); + + await sut(); + const [nextButton] = screen.getAllByTestId( + `btn-${step.ionNextStepBtn.label}` + ); + userEvent.click(nextButton); + + expect(tourServiceMock.nextStep).toHaveBeenCalled(); + }); + + it('should call finish when the close button is clicked', async () => { + const step = cloneDeep(DEFAULT_PROPS) as unknown as IonTourStepProps; + + setActiveTour(step.ionTourId); + setCurrentStep(step); + + await sut(); + const [closeButton] = screen.getAllByTestId('btn-popover-close-button'); + fireEvent.click(closeButton); + + expect(tourServiceMock.finish).toHaveBeenCalled(); + }); + }); +}); diff --git a/projects/ion/src/lib/tour/tour-step.directive.ts b/projects/ion/src/lib/tour/tour-step.directive.ts new file mode 100644 index 000000000..66192870d --- /dev/null +++ b/projects/ion/src/lib/tour/tour-step.directive.ts @@ -0,0 +1,226 @@ +import { DOCUMENT } from '@angular/common'; +import { + ApplicationRef, + ChangeDetectorRef, + ComponentFactoryResolver, + ComponentRef, + Directive, + ElementRef, + EventEmitter, + HostListener, + Inject, + Injector, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + ViewContainerRef, +} from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { PopoverPosition } from '../core/types'; +import { IonTourStepProps as StepProps } from '../core/types/tour'; +import { IonPopoverComponent } from '../popover/component/popover.component'; +import { IonPositionService } from '../position/position.service'; +import { SafeAny } from '../utils/safe-any'; +import { generatePositionCallback } from './tour-position.calculator'; +import { IonTourService } from './tour.service'; + +@Directive({ selector: '[ionTourStep]' }) +export class IonTourStepDirective implements OnInit, OnChanges, OnDestroy { + @Input() ionTourId!: StepProps['ionTourId']; + @Input() ionStepId!: StepProps['ionStepId']; + @Input() ionStepTitle?: StepProps['ionStepTitle']; + @Input() ionStepBody?: StepProps['ionStepBody']; + @Input() ionPrevStepBtn?: StepProps['ionPrevStepBtn']; + @Input() ionNextStepBtn?: StepProps['ionNextStepBtn']; + @Input() ionPrevStepId?: StepProps['ionPrevStepId']; + @Input() ionNextStepId?: StepProps['ionNextStepId']; + @Input() ionStepPosition?: StepProps['ionStepPosition'] = + PopoverPosition.BOTTOM_CENTER; + @Input() ionStepMarginToContent?: StepProps['ionStepMarginToContent'] = 0; + @Input() ionStepBackdropPadding?: StepProps['ionStepBackdropPadding'] = 8; + @Input() ionStepCustomClass?: StepProps['ionStepBackdropCustomClass']; + @Input() ionStepBackdropCustomClass?: StepProps['ionStepBackdropCustomClass']; + + @Output() ionOnPrevStep: StepProps['ionOnPrevStep'] = new EventEmitter(); + @Output() ionOnNextStep: StepProps['ionOnNextStep'] = new EventEmitter(); + @Output() ionOnFinishTour: StepProps['ionOnFinishTour'] = new EventEmitter(); + + private popoverRef: ComponentRef | null = null; + + private isStepSelected = false; + private isTourActive = false; + private destroy$ = new Subject(); + + constructor( + @Inject(DOCUMENT) private document: SafeAny, + private componentFactoryResolver: ComponentFactoryResolver, + private appRef: ApplicationRef, + private readonly viewRef: ViewContainerRef, + private elementRef: ElementRef, + private injector: Injector, + private cdr: ChangeDetectorRef, + private tourService: IonTourService, + private positionService: IonPositionService + ) {} + + public ngOnInit(): void { + this.tourService.saveStep(this.toJSON()); + + this.tourService.activeTour$ + .pipe(takeUntil(this.destroy$)) + .subscribe((isActive) => { + this.isTourActive = isActive === this.ionTourId; + this.checkPopoverVisibility(); + }); + + this.tourService.currentStep$ + .pipe(takeUntil(this.destroy$)) + .subscribe((step) => { + this.isStepSelected = step && step.ionStepId === this.ionStepId; + this.checkPopoverVisibility(); + }); + } + + public ngOnChanges(): void { + if (this.popoverRef) { + this.updatePopoverProps(); + } + } + + public ngOnDestroy(): void { + this.viewRef.clear(); + this.destroyPopoverElement(); + this.tourService.removeStep(this.ionStepId); + this.destroy$.next(); + this.destroy$.complete(); + } + + @HostListener('window:resize', ['$event']) + private repositionPopover(): void { + if (this.ionStepId && this.popoverRef) { + const contentRect = + this.popoverRef.instance.popover.nativeElement.getBoundingClientRect(); + + this.positionService.setHostPosition( + this.elementRef.nativeElement.getBoundingClientRect() + ); + this.positionService.setChoosedPosition(this.ionStepPosition); + this.positionService.setElementPadding(this.ionStepMarginToContent); + this.positionService.setcomponentCoordinates(contentRect); + + const position = this.positionService.getNewPosition( + generatePositionCallback( + this.ionStepBackdropPadding, + this.ionStepMarginToContent + ) + ); + + this.popoverRef.instance.top = position.top + window.scrollY; + this.popoverRef.instance.left = position.left + window.scrollX; + + this.tourService.saveStep(this.toJSON()); + } + } + + private checkPopoverVisibility(): void { + this.tourService.saveStep(this.toJSON()); + this.destroyPopoverElement(); + + if (this.isTourActive && this.isStepSelected) { + setTimeout(() => this.createPopoverElement()); + } + } + + private createPopoverElement(): void { + this.destroyPopoverElement(); + + this.popoverRef = this.componentFactoryResolver + .resolveComponentFactory(IonPopoverComponent) + .create(this.injector); + + this.appRef.attachView(this.popoverRef.hostView); + + const popoverElement = this.popoverRef.location + .nativeElement as HTMLElement; + + this.document.body.appendChild(popoverElement); + this.popoverRef.changeDetectorRef.detectChanges(); + + this.updatePopoverProps(); + this.listenToPopoverEvents(); + + setTimeout(() => this.repositionPopover()); + } + + private updatePopoverProps(): void { + const ionPopoverActions = [ + this.ionPrevStepBtn || { label: 'Voltar' }, + this.ionNextStepBtn || { label: 'Continuar' }, + ]; + + const popoverProps: Partial = { + ionPopoverTitle: this.ionStepTitle, + ionPopoverBody: this.ionStepBody, + ionPopoverPosition: + this.positionService.getCurrentPosition() as PopoverPosition, + ionPopoverIconClose: true, + ionPopoverKeep: true, + ionPopoverCustomClass: 'ion-tour-popover ' + this.ionStepCustomClass, + ionPopoverActions, + }; + + for (const [key, value] of Object.entries(popoverProps)) { + this.popoverRef.instance[key] = value; + } + + this.cdr.detectChanges(); + } + + private listenToPopoverEvents(): void { + const eventHandlers: [string, () => void][] = [ + ['ionOnFirstAction', this.tourService.prevStep], + ['ionOnSecondAction', this.tourService.nextStep], + ['ionOnClose', this.tourService.finish], + ]; + + eventHandlers.forEach(([event, action]) => { + this.popoverRef.instance[event] + .pipe(takeUntil(this.destroy$)) + .subscribe(action.bind(this.tourService)); + }); + } + + private destroyPopoverElement(): void { + if (this.popoverRef) { + this.appRef.detachView(this.popoverRef.hostView); + this.popoverRef.destroy(); + this.popoverRef = null; + } + } + + private toJSON(): StepProps { + return { + ionStepId: this.ionStepId, + ionTourId: this.ionTourId, + ionStepTitle: this.ionStepTitle, + ionStepBody: this.ionStepBody, + ionPrevStepBtn: this.ionPrevStepBtn, + ionNextStepBtn: this.ionNextStepBtn, + ionPrevStepId: this.ionPrevStepId, + ionNextStepId: this.ionNextStepId, + ionStepPosition: this.ionStepPosition, + ionStepMarginToContent: this.ionStepMarginToContent, + ionStepBackdropPadding: this.ionStepBackdropPadding, + ionStepCustomClass: this.ionStepCustomClass, + ionStepBackdropCustomClass: this.ionStepBackdropCustomClass, + ionOnPrevStep: this.ionOnPrevStep, + ionOnNextStep: this.ionOnNextStep, + ionOnFinishTour: this.ionOnFinishTour, + target: this.elementRef.nativeElement.getBoundingClientRect(), + }; + } +} diff --git a/projects/ion/src/lib/tour/tour.module.ts b/projects/ion/src/lib/tour/tour.module.ts new file mode 100644 index 000000000..dc561f17c --- /dev/null +++ b/projects/ion/src/lib/tour/tour.module.ts @@ -0,0 +1,17 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { IonButtonModule } from '../button/button.module'; +import { IonPopoverModule } from '../popover/popover.module'; +import { IonTourBackdropComponent } from './tour-backdrop'; +import { IonTourStepDirective } from './tour-step.directive'; +import { IonTourService } from './tour.service'; + +@NgModule({ + declarations: [IonTourBackdropComponent, IonTourStepDirective], + entryComponents: [IonTourBackdropComponent], + providers: [IonTourService], + imports: [CommonModule, IonPopoverModule, IonButtonModule], + exports: [IonTourStepDirective], +}) +export class IonTourModule {} diff --git a/projects/ion/src/lib/tour/tour.service.spec.ts b/projects/ion/src/lib/tour/tour.service.spec.ts new file mode 100644 index 000000000..bd9d6b483 --- /dev/null +++ b/projects/ion/src/lib/tour/tour.service.spec.ts @@ -0,0 +1,327 @@ +import { + ApplicationRef, + ComponentFactory, + ComponentFactoryResolver, + EventEmitter, + Injector, +} from '@angular/core'; + +import { IonTourStepProps } from '../core/types/tour'; +import { IonTourBackdropComponent } from './tour-backdrop'; +import { IonTourService } from './tour.service'; + +jest.useFakeTimers(); + +function performFinalTransition(callback: () => void): void { + callback(); +} + +function resolveComponentFactory(): ComponentFactory { + return { + create: () => backdropComponentMock, + } as unknown as ComponentFactory; +} + +const backdropComponentMock = { + hostView: {}, + location: { nativeElement: document.createElement('div') }, + changeDetectorRef: { detectChanges: jest.fn() }, + instance: { performFinalTransition }, + destroy: jest.fn(), +}; + +const componentFactoryResolverMock = { + resolveComponentFactory, +} as unknown as ComponentFactoryResolver; + +const applicationRefMock = { + attachView: jest.fn(), + detachView: jest.fn(), +} as unknown as ApplicationRef; + +const TARGET_MOCK = { + x: 300, + y: 300, + width: 100, + height: 100, + bottom: 400, + right: 400, + left: 300, + top: 300, + toJSON: () => ({ x: 300, y: 300, width: 100, height: 100 }), +} as DOMRect; + +const stepsMock: IonTourStepProps[] = [ + { + ionTourId: 'tour1', + ionStepId: 'step1', + target: TARGET_MOCK, + ionStepTitle: 'Step 1', + ionNextStepId: 'step2', + ionOnPrevStep: new EventEmitter(), + ionOnNextStep: new EventEmitter(), + ionOnFinishTour: new EventEmitter(), + }, + { + ionTourId: 'tour1', + ionStepId: 'step2', + target: TARGET_MOCK, + ionStepTitle: 'Step 2', + ionPrevStepId: 'step1', + ionOnPrevStep: new EventEmitter(), + ionOnNextStep: new EventEmitter(), + ionOnFinishTour: new EventEmitter(), + }, +]; + +describe('IonTourService', () => { + let service: IonTourService; + + beforeEach(() => { + service = new IonTourService( + document, + componentFactoryResolverMock, + {} as Injector, + applicationRefMock + ); + }); + + afterEach(jest.clearAllMocks); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('steps control', () => { + it('should save a step', () => { + const [step] = stepsMock; + + service.saveStep(step); + + expect(service.steps).toEqual([step]); + }); + + it('should remove a step', () => { + const [step] = stepsMock; + + service.saveStep(step); + service.removeStep(step.ionStepId); + + expect(service.steps).toEqual([]); + }); + + it('should update a step', () => { + const [step1, step2] = stepsMock; + const updatedStep = { ...step2, ionStepId: step1.ionStepId }; + + service.saveStep(step1); + service.saveStep(updatedStep); + + expect(service.steps).toEqual([updatedStep]); + }); + + it('should update the current step data if the host position changes while tour is running', () => { + const [step1] = stepsMock; + + const updatedStep = { + ...step1, + target: { toJSON: () => ({ ...TARGET_MOCK, x: 400 }) }, + } as IonTourStepProps; + + service.saveStep(step1); + service.start(); + jest.runAllTimers(); + + expect(service.currentStep.value).toEqual(step1); + service.saveStep(updatedStep); + expect(service.currentStep.value).toEqual(updatedStep); + }); + }); + + describe('start tour', () => { + it('should start a tour', () => { + const [step] = stepsMock; + + service.saveStep(step); + service.start({ tourId: step.ionTourId }); + jest.runAllTimers(); + + expect(service.activeTour.value).toBe(step.ionTourId); + expect(service.currentStep.value).toEqual(step); + }); + + it('should start the first tour if no tourId is provided', () => { + const [step1, step2] = stepsMock; + + service.saveStep(step1); + service.saveStep(step2); + service.start(); + jest.runAllTimers(); + + expect(service.activeTour.value).toBe(step1.ionTourId); + }); + + it('should not start a tour if no steps are found', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(); + service.start(); + jest.runAllTimers(); + + expect(service.activeTour.value).toBeNull(); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith('No steps found!'); + }); + + it('should not start a tour if the tourId is not found', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(); + service.saveStep(stepsMock[0]); + service.start({ tourId: 'invalidTourId' }); + jest.runAllTimers(); + + expect(service.activeTour.value).toBeNull(); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith('Tour not found!'); + }); + + it('should set the first step as the current step even when they were registered out of order', () => { + const [step1, step2] = stepsMock; + + service.saveStep(step2); + service.saveStep(step1); + service.start({ tourId: step1.ionTourId }); + jest.runAllTimers(); + + expect(service.currentStep.value).toEqual(step1); + }); + + it('should create de backdrop when starting a tour', () => { + const [step] = stepsMock; + + service.saveStep(step); + service.start({ tourId: step.ionTourId }); + jest.runAllTimers(); + + expect(service['backdropRef']).toBeTruthy(); + }); + + it('should destroy a previous backdrop when starting a new tour', () => { + const [step1, step2] = stepsMock; + + service.saveStep(step1); + service.saveStep(step2); + + service.start({ tourId: step1.ionTourId }); + jest.runAllTimers(); + service.start({ tourId: step2.ionTourId }); + + const backdropRef = service['backdropRef']; + + service.start({ tourId: step2.ionTourId }); + jest.runAllTimers(); + + expect(backdropRef.destroy).toHaveBeenCalled(); + }); + }); + + describe('steps navigation', () => { + it('should navigate to the next step', () => { + const ionTourId = 'tour1'; + + const [step1, step2] = stepsMock; + + service.saveStep(step1); + service.saveStep(step2); + service.start({ tourId: ionTourId }); + jest.runAllTimers(); + + expect(service.currentStep.value).toEqual(step1); + + const spy = jest.spyOn(step1.ionOnNextStep, 'emit'); + service.nextStep(); + + expect(service.currentStep.value).toEqual(step2); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should navigate to the previous step', () => { + const [step1, step2] = stepsMock; + + service.saveStep(step1); + service.saveStep(step2); + service.start({ tourId: step1.ionTourId }); + jest.runAllTimers(); + + service.nextStep(); + + expect(service.currentStep.value).toEqual(step2); + + const spy = jest.spyOn(step2.ionOnPrevStep, 'emit'); + service.prevStep(); + + 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; + + service.saveStep(step); + service.start({ tourId: step.ionTourId }); + jest.runAllTimers(); + + const spyNext = jest.spyOn(step.ionOnNextStep, 'emit'); + const spyFinish = jest.spyOn(step.ionOnFinishTour, 'emit'); + service.nextStep(); + + expect(service.currentStep.value).toBeNull(); + expect(service.activeTour.value).toBeNull(); + expect(spyNext).toHaveBeenCalledTimes(1); + expect(spyFinish).toHaveBeenCalledTimes(1); + }); + + it('should finish the tour if there is no previous step and prevStep is called', () => { + const [step] = stepsMock; + + service.saveStep(step); + service.start({ tourId: step.ionTourId }); + jest.runAllTimers(); + + const spyPrev = jest.spyOn(step.ionOnPrevStep, 'emit'); + const spyFinish = jest.spyOn(step.ionOnFinishTour, 'emit'); + service.prevStep(); + + expect(service.currentStep.value).toBeNull(); + expect(service.activeTour.value).toBeNull(); + expect(spyPrev).toHaveBeenCalledTimes(1); + expect(spyFinish).toHaveBeenCalledTimes(1); + }); + }); + + describe('finish tour', () => { + it('should finish the tour', () => { + const spy = jest.spyOn(stepsMock[0].ionOnFinishTour, 'emit'); + + const [step] = stepsMock; + service.saveStep(step); + service.start(); + jest.runAllTimers(); + + service.finish(); + + expect(service.currentStep.value).toBeNull(); + expect(service.activeTour.value).toBeNull(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should destroy the backdrop when finishing the tour', () => { + const [step] = stepsMock; + + service.saveStep(step); + service.start({ tourId: step.ionTourId }); + jest.runAllTimers(); + + service.finish(); + + expect(service['backdropRef']).toBeNull(); + }); + }); +}); diff --git a/projects/ion/src/lib/tour/tour.service.ts b/projects/ion/src/lib/tour/tour.service.ts new file mode 100644 index 000000000..237e19374 --- /dev/null +++ b/projects/ion/src/lib/tour/tour.service.ts @@ -0,0 +1,205 @@ +import { DOCUMENT } from '@angular/common'; +import { + ApplicationRef, + ComponentFactoryResolver, + ComponentRef, + Inject, + Injectable, + Injector, +} from '@angular/core'; +import { isEmpty, isEqual, isNull } from 'lodash'; +import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { IonStartTourProps, IonTourStepProps } from '../core/types/tour'; +import { SafeAny } from '../utils/safe-any'; +import { IonTourBackdropComponent } from './tour-backdrop'; + +type StepsMap = Map; + +@Injectable() +export class IonTourService { + public currentStep = new BehaviorSubject(null); + public activeTour = new BehaviorSubject(null); + + private _tours: Record = {}; + private backdropRef: ComponentRef | null = null; + private destroyBackdrop$ = new Subject(); + + public get currentStep$(): Observable { + return this.currentStep.asObservable(); + } + + public get activeTour$(): Observable { + return this.activeTour.asObservable(); + } + + public get steps(): IonTourStepProps[] { + return Array.from(this._tours[this.activeTourId].values()); + } + + private get activeTourId(): string { + return this.activeTour.value || Object.keys(this._tours)[0]; + } + + constructor( + @Inject(DOCUMENT) private document: SafeAny, + private componentFactoryResolver: ComponentFactoryResolver, + private injector: Injector, + private appRef: ApplicationRef + ) {} + + public saveStep(step: IonTourStepProps): void { + if (!this._tours[step.ionTourId]) { + this._tours[step.ionTourId] = new Map(); + } + + const current = this.currentStep.value; + + if ( + current && + current.ionStepId === step.ionStepId && + !isEqual(step.target.toJSON(), current.target.toJSON()) + ) { + this.navigateToStep(step); + } + + this._tours[step.ionTourId].set(step.ionStepId, step); + } + + public removeStep(stepId: IonTourStepProps['ionStepId']): void { + this._tours[this.activeTourId].delete(stepId); + } + + public start(props: IonStartTourProps = {}): void { + setTimeout(() => { + if (isEmpty(this._tours)) { + // eslint-disable-next-line no-console + console.error('No steps found!'); + return; + } + + const tour = props.tourId || Object.keys(this._tours)[0]; + if (!(tour in this._tours)) { + // eslint-disable-next-line no-console + console.error('Tour not found!'); + return; + } + + this.activeTour.next(tour); + this.navigateToStep(this.getFirstStep()); + this.createBackdrop(); + }); + } + + public finish(): void { + if (this.currentStep.value) { + this.currentStep.value.ionOnFinishTour.emit(); + } + this.activeTour.next(null); + this.currentStep.next(null); + this.closeBackdrop(); + } + + public prevStep(): void { + const currentStep = this.currentStep.getValue(); + currentStep.ionOnPrevStep.emit(); + + const prevStep = this._tours[this.activeTourId].get( + currentStep.ionPrevStepId + ); + + if (prevStep) { + this.navigateToStep(prevStep); + } else { + this.finish(); + } + } + + 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 { + this.finish(); + } + } + + private getFirstStep( + step: IonTourStepProps = this.steps[0] + ): IonTourStepProps { + if (step.ionPrevStepId) { + return this.getFirstStep( + this._tours[this.activeTourId].get(step.ionPrevStepId) + ); + } + return step; + } + + private navigateToStep(step: IonTourStepProps): void { + this.currentStep.next(step); + } + + private createBackdrop(): void { + if (this.backdropRef) { + this.destroyBackdrop(); + } + + this.backdropRef = this.componentFactoryResolver + .resolveComponentFactory(IonTourBackdropComponent) + .create(this.injector); + + this.appRef.attachView(this.backdropRef.hostView); + + const popoverElement = this.backdropRef.location + .nativeElement as HTMLElement; + + this.document.body.appendChild(popoverElement); + this.backdropRef.changeDetectorRef.detectChanges(); + this.updateBackdropProps(); + } + + private updateBackdropProps(): void { + this.currentStep$ + .pipe(takeUntil(this.destroyBackdrop$)) + .subscribe((step) => { + if (this.backdropRef) { + this.backdropRef.instance.currentStep = step; + } + }); + + this.activeTour$ + .pipe(takeUntil(this.destroyBackdrop$)) + .subscribe((activeTour) => { + if (this.backdropRef) { + this.backdropRef.instance.isActive = !isNull(activeTour); + } + }); + } + + private closeBackdrop(): void { + if (this.backdropRef) { + this.backdropRef.instance.performFinalTransition(() => { + if (this.backdropRef && !this.activeTour.value) { + this.destroyBackdrop(); + } + }); + } + } + + private destroyBackdrop(): void { + if (this.backdropRef) { + this.appRef.detachView(this.backdropRef.hostView); + this.backdropRef.destroy(); + this.backdropRef = null; + this.destroyBackdrop$.next(); + this.destroyBackdrop$.complete(); + } + } +} diff --git a/projects/ion/src/public-api.ts b/projects/ion/src/public-api.ts index 99929a064..e00d42655 100644 --- a/projects/ion/src/public-api.ts +++ b/projects/ion/src/public-api.ts @@ -36,6 +36,7 @@ export * from './lib/pagination/pagination.module'; export * from './lib/picker/date-picker/date-picker.module'; export * from './lib/popconfirm/popconfirm.module'; export * from './lib/popover/popover.module'; +export * from './lib/position/position.service'; export * from './lib/radio-group/radio-group.module'; export * from './lib/radio/radio.module'; export * from './lib/row/row.module'; @@ -54,6 +55,7 @@ export * from './lib/table/table.module'; export * from './lib/table/utilsTable'; export * from './lib/tag/tag.module'; export * from './lib/tooltip/tooltip.module'; +export * from './lib/tour'; export * from './lib/triple-toggle/triple-toggle.module'; export * from './lib/typography/'; export { default as debounce } from './lib/utils/debounce'; diff --git a/stories/Tour.stories.ts b/stories/Tour.stories.ts new file mode 100644 index 000000000..4cd7e5a31 --- /dev/null +++ b/stories/Tour.stories.ts @@ -0,0 +1,22 @@ +import { CommonModule } from '@angular/common'; +import { Meta, Story } from '@storybook/angular'; + +import { IonTourModule } from '../projects/ion/src/lib/tour'; +import { TourBasicDemoComponent } from '../projects/ion/src/lib/tour/mocks/tour-basic-demo.component'; +import { IonSharedModule } from '../projects/ion/src/public-api'; + +const Template: Story = () => ({ + component: TourBasicDemoComponent, + moduleMetadata: { + declarations: [TourBasicDemoComponent], + imports: [CommonModule, IonSharedModule, IonTourModule], + entryComponents: [TourBasicDemoComponent], + }, +}); + +export const BasicTour = Template.bind({}); + +export default { + title: 'Ion/Data Display/Tour', + component: TourBasicDemoComponent, +} as Meta; diff --git a/stories/TourDocs.stories.mdx b/stories/TourDocs.stories.mdx new file mode 100644 index 000000000..71fecdc1a --- /dev/null +++ b/stories/TourDocs.stories.mdx @@ -0,0 +1,71 @@ +import { Canvas, Meta, Story } from '@storybook/addon-docs'; +import { TourStepDemoComponent } from '../projects/ion/src/lib/tour/mocks/tour-step-props.component.ts'; + + + +export const Template = (args) => ; + +## Tour + +Passos para usar o tour em uma tela: + +1. No HTML do seu componente, coloque a diretiva `ionTourStep` no elemento que deseja destacar: + +```html + + Olá, tudo bem? + + + +

Este é o conteúdo da etapa.

+
+``` + +**Parâmetros da diretiva:** + +- `ionStepId` é o identificador de uma etapa. Cada etapa de um tour deve ter um identificador único, ele servirá para referenciar as etapas para a navegação do tour; +- `ionTourId` é o identificador de um tour. Toda etapa de um tour deve ter o mesmo identificador; +- `ionStepTitle` é o título que será exibido na etapa do tour; +- `ionStepBody` é o conteúdo que será exibido na etapa do tour. Pode ser um texto ou um template customizado. +- `ionPrevStepBtn` é o texto do botão de voltar para a etapa anterior; +- `ionNextStepBtn` é o texto do botão de avançar para a próxima etapa; +- `ionPrevStepId` é o `ionStepId` da etapa anterior. Deve ser preenchido se houver uma etapa anterior; +- `ionNextStepId` é o `ionStepId` da próxima etapa. Deve ser preenchido se houver uma etapa seguinte; +- `ionStepPosition` é a posição do balão de fala da etapa. Pode assumir 12 valores definidos pelo enum `PopoverPosition` ; +- `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; +- `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: + +```ts +import { IonTourService } from '@brisanet/ion'; +import { AfterViewInit, Component, OnDestroy } from '@angular/core'; + +@Component({ + selector: 'app-tour-demo', + templateUrl: './tour-demo.component.html', +}) +export class TourDemoComponent implements AfterViewInit, OnDestroy { + constructor(private ionTourService: IonTourService) {} + + public ngAfterViewInit(): void { + this.ionTourService.start({ tourId: 'tour-demo' }); + } + + public ngOnDestroy(): void { + this.tourService.finish(); + } +} +``` + +3. Pronto! Agora, ao acessar a tela que contém o componente, o tour será exibido automaticamente. diff --git a/stories/TourStep.stories.ts b/stories/TourStep.stories.ts new file mode 100644 index 000000000..988354606 --- /dev/null +++ b/stories/TourStep.stories.ts @@ -0,0 +1,55 @@ +import { CommonModule } from '@angular/common'; +import { Meta, Story } from '@storybook/angular'; + +import { IonTourModule } from '../projects/ion/src/lib/tour'; +import { TourStepDemoComponent } from '../projects/ion/src/lib/tour/mocks/tour-step-props.component'; +import { + IonSharedModule, + PopoverPosition, +} from '../projects/ion/src/public-api'; + +const Template: Story = ( + args: TourStepDemoComponent +) => ({ + component: TourStepDemoComponent, + props: args, + moduleMetadata: { + declarations: [TourStepDemoComponent], + imports: [CommonModule, IonSharedModule, IonTourModule], + entryComponents: [TourStepDemoComponent], + }, +}); + +export const TourStep = Template.bind({}); +TourStep.args = { + ionStepTitle: 'Title Example', + ionStepBody: 'You can change the props of this step in Storybook controls', + ionPrevStepBtn: { label: 'Close' }, + ionNextStepBtn: { label: 'Finish' }, + ionStepPosition: PopoverPosition.TOP_CENTER, + ionStepMarginToContent: 5, + ionStepBackdropPadding: 5, +}; + +export default { + title: 'Ion/Data Display/Tour', + component: TourStepDemoComponent, + argTypes: { + ionStepTitle: { control: 'text' }, + ionStepBody: { control: 'text' }, + ionPrevStepBtn: { control: 'object' }, + ionNextStepBtn: { control: 'object' }, + ionPrevStepId: { control: 'text' }, + ionNextStepId: { control: 'text' }, + ionStepPosition: { + control: { + type: 'select', + options: Object.values(PopoverPosition), + }, + }, + ionStepMarginToContent: { control: 'number' }, + ionStepBackdropPadding: { control: 'number' }, + ionStepCustomClass: { control: 'text' }, + ionStepBackdropCustomClass: { control: 'text' }, + }, +} as Meta;