MOBILE-3833 usertours: Watch elements visibility

main
Noel De Martin 2022-03-30 14:33:07 +02:00
parent 771ee90d6a
commit 35433615f3
12 changed files with 312 additions and 140 deletions

View File

@ -32,8 +32,8 @@ import { CoreSwipeNavigationDirective } from './swipe-navigation';
import { CoreCollapsibleItemDirective } from './collapsible-item'; import { CoreCollapsibleItemDirective } from './collapsible-item';
import { CoreCollapsibleFooterDirective } from './collapsible-footer'; import { CoreCollapsibleFooterDirective } from './collapsible-footer';
import { CoreContentDirective } from './content'; import { CoreContentDirective } from './content';
import { CoreOnAppearDirective } from './on-appear';
import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-attributes'; import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-attributes';
import { CoreUserTourDirective } from './user-tour';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -48,7 +48,6 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
CoreSupressEventsDirective, CoreSupressEventsDirective,
CoreUserLinkDirective, CoreUserLinkDirective,
CoreAriaButtonClickDirective, CoreAriaButtonClickDirective,
CoreOnAppearDirective,
CoreOnResizeDirective, CoreOnResizeDirective,
CoreDownloadFileDirective, CoreDownloadFileDirective,
CoreCollapsibleHeaderDirective, CoreCollapsibleHeaderDirective,
@ -57,6 +56,7 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
CoreCollapsibleFooterDirective, CoreCollapsibleFooterDirective,
CoreContentDirective, CoreContentDirective,
CoreUpdateNonReactiveAttributesDirective, CoreUpdateNonReactiveAttributesDirective,
CoreUserTourDirective,
], ],
exports: [ exports: [
CoreAutoFocusDirective, CoreAutoFocusDirective,
@ -70,7 +70,6 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
CoreSupressEventsDirective, CoreSupressEventsDirective,
CoreUserLinkDirective, CoreUserLinkDirective,
CoreAriaButtonClickDirective, CoreAriaButtonClickDirective,
CoreOnAppearDirective,
CoreOnResizeDirective, CoreOnResizeDirective,
CoreDownloadFileDirective, CoreDownloadFileDirective,
CoreCollapsibleHeaderDirective, CoreCollapsibleHeaderDirective,
@ -79,6 +78,7 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
CoreCollapsibleFooterDirective, CoreCollapsibleFooterDirective,
CoreContentDirective, CoreContentDirective,
CoreUpdateNonReactiveAttributesDirective, CoreUpdateNonReactiveAttributesDirective,
CoreUserTourDirective,
], ],
}) })
export class CoreDirectivesModule {} export class CoreDirectivesModule {}

View File

@ -93,6 +93,7 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy {
await CoreUserTours.showIfPending({ await CoreUserTours.showIfPending({
id: 'swipe-navigation', id: 'swipe-navigation',
component: CoreSwipeNavigationTourComponent, component: CoreSwipeNavigationTourComponent,
watch: this.element,
}); });
} }

View File

@ -12,20 +12,22 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { Directive, ElementRef, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core';
import { CoreCancellablePromise } from '@classes/cancellable-promise'; import { CoreCancellablePromise } from '@classes/cancellable-promise';
import { CoreUserTours, CoreUserToursFocusedOptions, CoreUserToursUserTour } from '@features/usertours/services/user-tours';
import { CoreDom } from '@singletons/dom'; import { CoreDom } from '@singletons/dom';
/** /**
* Directive to listen when an element becomes visible. * Directive to control a User Tour linked to the lifecycle of the element where it's defined.
*/ */
@Directive({ @Directive({
selector: '[onAppear]', selector: '[userTour]',
}) })
export class CoreOnAppearDirective implements OnInit, OnDestroy { export class CoreUserTourDirective implements OnInit, OnDestroy {
@Output() onAppear = new EventEmitter(); @Input() userTour!: CoreUserTourDirectiveOptions;
private tour?: CoreUserToursUserTour | null;
private element: HTMLElement; private element: HTMLElement;
protected visiblePromise?: CoreCancellablePromise<void>; protected visiblePromise?: CoreCancellablePromise<void>;
@ -41,14 +43,35 @@ export class CoreOnAppearDirective implements OnInit, OnDestroy {
await this.visiblePromise; await this.visiblePromise;
this.onAppear.emit(); const { getFocusedElement, ...options } = this.userTour;
this.tour = await CoreUserTours.showIfPending({
...options,
focus: getFocusedElement?.(this.element) ?? this.element,
});
} }
/** /**
* @inheritdoc * @inheritdoc
*/ */
ngOnDestroy(): void { ngOnDestroy(): void {
this.tour?.cancel();
this.visiblePromise?.cancel(); this.visiblePromise?.cancel();
} }
} }
/**
* User Tour options to control with this directive.
*/
export type CoreUserTourDirectiveOptions = Omit<CoreUserToursFocusedOptions, 'focus'> & {
/**
* Getter to obtain element to focus in the User Tour. If this isn't provided, the element where the
* directive is defined will be used.
*
* @param element Element where the directive is defined.
*/
getFocusedElement?(element: HTMLElement): HTMLElement;
};

View File

@ -1,4 +1,4 @@
<ion-button (click)="openBlocks()" (onAppear)="showTour()" [attr.aria-label]="'core.block.opendrawerblocks' | translate" color="secondary" <ion-button (click)="openBlocks()" [userTour]="userTour" [attr.aria-label]="'core.block.opendrawerblocks' | translate" color="secondary"
#button> #button>
<ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon> <ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button> </ion-button>

View File

@ -14,7 +14,8 @@
import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { CoreCancellablePromise } from '@classes/cancellable-promise'; import { CoreCancellablePromise } from '@classes/cancellable-promise';
import { CoreUserTours, CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours'; import { CoreUserTourDirectiveOptions } from '@directives/user-tour';
import { CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreDom } from '@singletons/dom'; import { CoreDom } from '@singletons/dom';
import { CoreBlockSideBlocksTourComponent } from '../side-blocks-tour/side-blocks-tour'; import { CoreBlockSideBlocksTourComponent } from '../side-blocks-tour/side-blocks-tour';
@ -34,6 +35,14 @@ export class CoreBlockSideBlocksButtonComponent implements OnInit, OnDestroy {
@Input() instanceId!: number; @Input() instanceId!: number;
@ViewChild('button', { read: ElementRef }) button?: ElementRef<HTMLElement>; @ViewChild('button', { read: ElementRef }) button?: ElementRef<HTMLElement>;
userTour: CoreUserTourDirectiveOptions = {
id: 'side-blocks-button',
component: CoreBlockSideBlocksTourComponent,
side: CoreUserToursSide.Start,
alignment: CoreUserToursAlignment.Center,
getFocusedElement: nativeButton => nativeButton.shadowRoot?.children[0] as HTMLElement,
};
protected element: HTMLElement; protected element: HTMLElement;
protected slotPromise?: CoreCancellablePromise<void>; protected slotPromise?: CoreCancellablePromise<void>;
@ -61,25 +70,6 @@ export class CoreBlockSideBlocksButtonComponent implements OnInit, OnDestroy {
}); });
} }
/**
* Show User Tour.
*/
async showTour(): Promise<void> {
const nativeButton = this.button?.nativeElement.shadowRoot?.children[0] as HTMLElement;
if (!nativeButton) {
return;
}
await CoreUserTours.showIfPending({
id: 'side-blocks-button',
component: CoreBlockSideBlocksTourComponent,
focus: nativeButton,
side: CoreUserToursSide.Start,
alignment: CoreUserToursAlignment.Center,
});
}
/** /**
* @inheritdoc * @inheritdoc
*/ */

View File

@ -55,7 +55,7 @@
<!-- Course Index button. --> <!-- Course Index button. -->
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="loaded && displayCourseIndex"> <ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="loaded && displayCourseIndex">
<ion-fab-button (click)="openCourseIndex()" (onAppear)="showCourseIndexTour()" [attr.aria-label]="'core.course.courseindex' | translate" <ion-fab-button (click)="openCourseIndex()" [userTour]="courseIndexTour" [attr.aria-label]="'core.course.courseindex' | translate"
color="secondary" #courseIndexFab> color="secondary" #courseIndexFab>
<ion-icon name="fas-list-ul" aria-hidden="true"></ion-icon> <ion-icon name="fas-list-ul" aria-hidden="true"></ion-icon>
<span class="sr-only">{{'core.course.courseindex' | translate }}</span> <span class="sr-only">{{'core.course.courseindex' | translate }}</span>

View File

@ -46,9 +46,10 @@ import { CoreBlockHelper } from '@features/block/services/block-helper';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
import { CoreCourseViewedModulesDBRecord } from '@features/course/services/database/course'; import { CoreCourseViewedModulesDBRecord } from '@features/course/services/database/course';
import { CoreUserTours, CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours'; import { CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours';
import { CoreCourseCourseIndexTourComponent } from '../course-index-tour/course-index-tour'; import { CoreCourseCourseIndexTourComponent } from '../course-index-tour/course-index-tour';
import { CoreDom } from '@singletons/dom'; import { CoreDom } from '@singletons/dom';
import { CoreUserTourDirectiveOptions } from '@directives/user-tour';
/** /**
* Component to display course contents using a certain format. If the format isn't found, use default one. * Component to display course contents using a certain format. If the format isn't found, use default one.
@ -87,6 +88,13 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
canLoadMore = false; canLoadMore = false;
showSectionId = 0; showSectionId = 0;
data: Record<string, unknown> = {}; // Data to pass to the components. data: Record<string, unknown> = {}; // Data to pass to the components.
courseIndexTour: CoreUserTourDirectiveOptions = {
id: 'course-index',
component: CoreCourseCourseIndexTourComponent,
side: CoreUserToursSide.Top,
alignment: CoreUserToursAlignment.End,
getFocusedElement: nativeButton => nativeButton.shadowRoot?.children[0] as HTMLElement,
};
displayCourseIndex = false; displayCourseIndex = false;
displayBlocks = false; displayBlocks = false;
@ -166,25 +174,6 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
}); });
} }
/**
* Show Course Index User Tour.
*/
async showCourseIndexTour(): Promise<void> {
const nativeButton = this.courseIndexFab?.nativeElement.shadowRoot?.children[0] as HTMLElement;
if (!nativeButton) {
return;
}
await CoreUserTours.showIfPending({
id: 'course-index',
component: CoreCourseCourseIndexTourComponent,
focus: nativeButton,
side: CoreUserToursSide.Top,
alignment: CoreUserToursAlignment.End,
});
}
/** /**
* Detect changes on input properties. * Detect changes on input properties.
*/ */

View File

@ -1,4 +1,4 @@
<core-user-avatar *ngIf="isMainScreen && siteInfo" [user]="siteInfo" class="core-bar-button-image clickable" [linkProfile]="false" <core-user-avatar *ngIf="isMainScreen && siteInfo" [user]="siteInfo" class="core-bar-button-image clickable" [linkProfile]="false"
(ariaButtonClick)="openUserMenu($event)" (onAppear)="showTour()" role="button" tabindex="0" (ariaButtonClick)="openUserMenu($event)" [userTour]="userTour" role="button" tabindex="0"
[attr.aria-label]="'core.user.useraccount' | translate" #avatar> [attr.aria-label]="'core.user.useraccount' | translate" #avatar>
</core-user-avatar> </core-user-avatar>

View File

@ -14,7 +14,8 @@
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import { CoreSiteInfo } from '@classes/site'; import { CoreSiteInfo } from '@classes/site';
import { CoreUserTours, CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours'; import { CoreUserTourDirectiveOptions } from '@directives/user-tour';
import { CoreUserToursAlignment, CoreUserToursSide } from '@features/usertours/services/user-tours';
import { IonRouterOutlet } from '@ionic/angular'; import { IonRouterOutlet } from '@ionic/angular';
import { CoreScreen } from '@services/screen'; import { CoreScreen } from '@services/screen';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
@ -36,6 +37,12 @@ export class CoreMainMenuUserButtonComponent implements OnInit {
siteInfo?: CoreSiteInfo; siteInfo?: CoreSiteInfo;
isMainScreen = false; isMainScreen = false;
userTour: CoreUserTourDirectiveOptions = {
id: 'user-menu',
component: CoreMainMenuUserMenuTourComponent,
alignment: CoreUserToursAlignment.Start,
side: CoreScreen.isMobile ? CoreUserToursSide.Start : CoreUserToursSide.End,
};
@ViewChild('avatar', { read: ElementRef }) avatar?: ElementRef<HTMLElement>; @ViewChild('avatar', { read: ElementRef }) avatar?: ElementRef<HTMLElement>;
@ -66,21 +73,4 @@ export class CoreMainMenuUserButtonComponent implements OnInit {
}); });
} }
/**
* Show User Tour.
*/
async showTour(): Promise<void> {
if (!this.avatar) {
return;
}
await CoreUserTours.showIfPending({
id: 'user-menu',
component: CoreMainMenuUserMenuTourComponent,
focus: this.avatar.nativeElement,
alignment: CoreUserToursAlignment.Start,
side: CoreScreen.isMobile ? CoreUserToursSide.Start : CoreUserToursSide.End,
});
}
} }

View File

@ -55,6 +55,7 @@ export class CoreUserToursUserTourComponent implements AfterViewInit {
popoverWrapperStyles?: string; popoverWrapperStyles?: string;
popoverWrapperArrowStyles?: string; popoverWrapperArrowStyles?: string;
private element: HTMLElement; private element: HTMLElement;
private tour?: HTMLElement;
private wrapperTransform = ''; private wrapperTransform = '';
private wrapperElement = new CorePromisedValue<HTMLElement>(); private wrapperElement = new CorePromisedValue<HTMLElement>();
private backButtonListener?: (event: BackButtonEvent) => void; private backButtonListener?: (event: BackButtonEvent) => void;
@ -77,14 +78,18 @@ export class CoreUserToursUserTourComponent implements AfterViewInit {
} }
/** /**
* Present User Tour. * Show User Tour.
*/ */
async present(): Promise<void> { async show(): Promise<void> {
// Insert tour component and wait until it's ready. // Insert tour component and wait until it's ready.
const wrapper = await this.wrapperElement; const wrapper = await this.wrapperElement;
const tour = await AngularFrameworkDelegate.attachViewToDom(wrapper, this.component, this.componentProps ?? {}); this.tour = await AngularFrameworkDelegate.attachViewToDom(wrapper, this.component, this.componentProps ?? {});
await CoreDomUtils.waitForImages(tour); if (!this.tour) {
return;
}
await CoreDomUtils.waitForImages(this.tour);
// Calculate focus styles or dismiss if the element is gone. // Calculate focus styles or dismiss if the element is gone.
if (this.focus && !CoreDom.isElementVisible(this.focus)) { if (this.focus && !CoreDom.isElementVisible(this.focus)) {
@ -111,6 +116,19 @@ export class CoreUserToursUserTourComponent implements AfterViewInit {
await this.playEnterAnimation(); await this.playEnterAnimation();
} }
/**
* Hide User Tour temporarily.
*/
async hide(): Promise<void> {
const wrapper = await this.wrapperElement;
await this.playLeaveAnimation();
await AngularFrameworkDelegate.removeViewFromDom(wrapper, this.tour);
this.active = false;
this.backButtonListener && document.removeEventListener('ionBackButton', this.backButtonListener);
}
/** /**
* Dismiss User Tour. * Dismiss User Tour.
* *
@ -119,13 +137,15 @@ export class CoreUserToursUserTourComponent implements AfterViewInit {
async dismiss(acknowledge: boolean = true): Promise<void> { async dismiss(acknowledge: boolean = true): Promise<void> {
this.beforeDismiss.emit(); this.beforeDismiss.emit();
await this.playLeaveAnimation(); if (this.active) {
await Promise.all<unknown>([ await this.playLeaveAnimation();
AngularFrameworkDelegate.removeViewFromDom(this.container, this.element), await AngularFrameworkDelegate.removeViewFromDom(this.container, this.element);
acknowledge && CoreUserTours.acknowledge(this.id),
]); this.backButtonListener && document.removeEventListener('ionBackButton', this.backButtonListener);
}
acknowledge && await CoreUserTours.acknowledge(this.id);
this.backButtonListener && document.removeEventListener('ionBackButton', this.backButtonListener);
this.afterDismiss.emit(); this.afterDismiss.emit();
} }

View File

@ -15,12 +15,14 @@
import { CoreConstants } from '@/core/constants'; import { CoreConstants } from '@/core/constants';
import { asyncInstance } from '@/core/utils/async-instance'; import { asyncInstance } from '@/core/utils/async-instance';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CoreCancellablePromise } from '@classes/cancellable-promise';
import { CoreDatabaseTable } from '@classes/database/database-table'; import { CoreDatabaseTable } from '@classes/database/database-table';
import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy'; import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy';
import { CoreApp } from '@services/app'; import { CoreApp } from '@services/app';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { AngularFrameworkDelegate, makeSingleton } from '@singletons'; import { AngularFrameworkDelegate, makeSingleton } from '@singletons';
import { CoreComponentsRegistry } from '@singletons/components-registry'; import { CoreComponentsRegistry } from '@singletons/components-registry';
import { CoreDom } from '@singletons/dom';
import { CoreSubscriptions } from '@singletons/subscriptions'; import { CoreSubscriptions } from '@singletons/subscriptions';
import { CoreUserToursUserTourComponent } from '../components/user-tour/user-tour'; import { CoreUserToursUserTourComponent } from '../components/user-tour/user-tour';
import { APP_SCHEMA, CoreUserToursDBEntry, USER_TOURS_TABLE_NAME } from './database/user-tours'; import { APP_SCHEMA, CoreUserToursDBEntry, USER_TOURS_TABLE_NAME } from './database/user-tours';
@ -32,8 +34,7 @@ import { APP_SCHEMA, CoreUserToursDBEntry, USER_TOURS_TABLE_NAME } from './datab
export class CoreUserToursService { export class CoreUserToursService {
protected table = asyncInstance<CoreDatabaseTable<CoreUserToursDBEntry>>(); protected table = asyncInstance<CoreDatabaseTable<CoreUserToursDBEntry>>();
protected tours: CoreUserToursUserTourComponent[] = []; protected tours: { component: CoreUserToursUserTourComponent; visible: boolean }[] = [];
protected tourReadyCallbacks = new WeakMap<CoreUserToursUserTourComponent, () => void>();
/** /**
* Initialize database. * Initialize database.
@ -84,14 +85,17 @@ export class CoreUserToursService {
* Show a User Tour if it's pending. * Show a User Tour if it's pending.
* *
* @param options User Tour options. * @param options User Tour options.
* @returns User tour controller, if any.
*/ */
async showIfPending(options: CoreUserToursBasicOptions): Promise<void>; async showIfPending(options: CoreUserToursBasicOptions): Promise<CoreUserToursUserTour | null>;
async showIfPending(options: CoreUserToursFocusedOptions): Promise<void>; async showIfPending(options: CoreUserToursFocusedOptions): Promise<CoreUserToursUserTour | null>;
async showIfPending(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise<void> { async showIfPending(
options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions,
): Promise<CoreUserToursUserTour | null> {
const isPending = await CoreUserTours.isPending(options.id); const isPending = await CoreUserTours.isPending(options.id);
if (!isPending) { if (!isPending) {
return; return null;
} }
return this.show(options); return this.show(options);
@ -101,16 +105,15 @@ export class CoreUserToursService {
* Show a User Tour. * Show a User Tour.
* *
* @param options User Tour options. * @param options User Tour options.
* @returns User tour controller.
*/ */
protected async show(options: CoreUserToursBasicOptions): Promise<void>; protected async show(options: CoreUserToursBasicOptions): Promise<CoreUserToursUserTour>;
protected async show(options: CoreUserToursFocusedOptions): Promise<void>; protected async show(options: CoreUserToursFocusedOptions): Promise<CoreUserToursUserTour>;
protected async show(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise<void> { protected async show(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise<CoreUserToursUserTour> {
const { delay, ...componentOptions } = options; const { delay, ...componentOptions } = options;
// Delay start.
await CoreUtils.wait(delay ?? 200); await CoreUtils.wait(delay ?? 200);
// Create tour.
const container = document.querySelector('ion-app') ?? document.body; const container = document.querySelector('ion-app') ?? document.body;
const element = await AngularFrameworkDelegate.attachViewToDom( const element = await AngularFrameworkDelegate.attachViewToDom(
container, container,
@ -119,26 +122,7 @@ export class CoreUserToursService {
); );
const tour = CoreComponentsRegistry.require(element, CoreUserToursUserTourComponent); const tour = CoreComponentsRegistry.require(element, CoreUserToursUserTourComponent);
this.tours.push(tour); return this.startTour(tour, options.watch ?? (options as CoreUserToursFocusedOptions).focus);
// Handle present/dismiss lifecycle.
CoreSubscriptions.once(tour.beforeDismiss, () => {
const index = this.tours.indexOf(tour);
if (index === -1) {
return;
}
this.tours.splice(index, 1);
const nextTour = this.tours[0] as CoreUserToursUserTourComponent | undefined;
nextTour?.present().then(() => this.tourReadyCallbacks.get(nextTour)?.());
});
this.tours.length > 1
? await new Promise<void>(resolve => this.tourReadyCallbacks.set(tour, resolve))
: await tour.present();
} }
/** /**
@ -147,13 +131,144 @@ export class CoreUserToursService {
* @param acknowledge Whether to acknowledge that the user has seen this User Tour or not. * @param acknowledge Whether to acknowledge that the user has seen this User Tour or not.
*/ */
async dismiss(acknowledge: boolean = true): Promise<void> { async dismiss(acknowledge: boolean = true): Promise<void> {
await this.tours[0]?.dismiss(acknowledge); await this.tours.find(({ visible }) => visible)?.component.dismiss(acknowledge);
}
/**
* Activate a tour component and bind its lifecycle to an element if provided.
*
* @param tour User tour.
* @param watchElement Element to watch in order to update tour lifecycle.
* @returns User tour controller.
*/
protected startTour(tour: CoreUserToursUserTourComponent, watchElement?: HTMLElement | false): CoreUserToursUserTour {
if (!watchElement) {
this.activateTour(tour);
return {
cancel: () => tour.dismiss(false),
};
}
let unsubscribeVisible: (() => void) | undefined;
let visiblePromise: CoreCancellablePromise | undefined = CoreDom.waitToBeInViewport(watchElement);
// eslint-disable-next-line promise/catch-or-return, promise/always-return
visiblePromise.then(() => {
visiblePromise = undefined;
this.activateTour(tour);
unsubscribeVisible = CoreDom.watchElementInViewport(
watchElement,
visible => visible ? this.activateTour(tour) : this.deactivateTour(tour),
);
CoreSubscriptions.once(tour.beforeDismiss, () => {
unsubscribeVisible?.();
visiblePromise = undefined;
unsubscribeVisible = undefined;
});
});
return {
cancel: async () => {
visiblePromise?.cancel();
if (!unsubscribeVisible) {
return;
}
unsubscribeVisible();
await tour.dismiss(false);
},
};
}
/**
* Activate the given user tour.
*
* @param tour User tour.
*/
protected activateTour(tour: CoreUserToursUserTourComponent): void {
// Handle show/dismiss lifecycle.
CoreSubscriptions.once(tour.beforeDismiss, () => {
const index = this.tours.findIndex(({ component }) => component === tour);
if (index === -1) {
return;
}
this.tours.splice(index, 1);
const foregroundTour = this.tours.find(({ visible }) => visible);
foregroundTour?.component.show();
});
// Add to existing tours and show it if it's on top.
const index = this.tours.findIndex(({ component }) => component === tour);
const foregroundTour = this.tours.find(({ visible }) => visible);
if (index !== -1) {
this.tours[index].visible = true;
} else {
this.tours.push({
visible: true,
component: tour,
});
}
if (this.tours.find(({ visible }) => visible)?.component !== tour) {
return;
}
foregroundTour?.component.hide();
tour.show();
}
/**
* Hide User Tour if visible.
*
* @param tour User tour.
*/
protected deactivateTour(tour: CoreUserToursUserTourComponent): void {
const index = this.tours.findIndex(({ component }) => component === tour);
const foregroundTourIndex = this.tours.findIndex(({ visible }) => visible);
if (index === -1) {
return;
}
this.tours[index].visible = false;
if (index === foregroundTourIndex) {
tour.hide();
this.tours.find(({ visible }) => visible)?.component.show();
}
} }
} }
export const CoreUserTours = makeSingleton(CoreUserToursService); export const CoreUserTours = makeSingleton(CoreUserToursService);
/**
* User Tour controller.
*/
export interface CoreUserToursUserTour {
/**
* Cancelling a User Tours removed it from the queue if it was pending or dimisses it without
* acknowledging if it existed.
*/
cancel(): Promise<void>;
}
/** /**
* User Tour side. * User Tour side.
*/ */
@ -202,6 +317,13 @@ export interface CoreUserToursBasicOptions {
*/ */
delay?: number; delay?: number;
/**
* Whether to watch an element to bind the User Tour lifecycle. Whenever this element appears or
* leaves the screen, the user tour will do it as well. Focused user tours do it by default with
* the focused element, but it can be disabled by explicitly using `false` here.
*/
watch?: HTMLElement | false;
} }
/** /**

View File

@ -354,6 +354,62 @@ export class CoreDom {
); );
} }
/**
* Watch whenever an elements visibility changes within the viewport.
*
* @param element Element to watch.
* @param intersectionRatio Intersection ratio (From 0 to 1).
* @param callback Callback when visibility changes.
* @return Function to stop watching.
*/
static watchElementInViewport(
element: HTMLElement,
intersectionRatio: number,
callback: (visible: boolean) => void,
): () => void;
/**
* Watch whenever an elements visibility changes within the viewport.
*
* @param element Element to watch.
* @param callback Callback when visibility changes.
* @return Function to stop watching.
*/
static watchElementInViewport(element: HTMLElement, callback: (visible: boolean) => void): () => void;
static watchElementInViewport(
element: HTMLElement,
intersectionRatioOrCallback: number | ((visible: boolean) => void),
callback?: (visible: boolean) => void,
): () => void {
const visibleCallback = callback ?? intersectionRatioOrCallback as (visible: boolean) => void;
const intersectionRatio = typeof intersectionRatioOrCallback === 'number' ? intersectionRatioOrCallback : 1;
let visible = CoreDom.isElementInViewport(element, intersectionRatio);
const setVisible = (newValue: boolean) => {
if (visible === newValue) {
return;
}
visible = newValue;
visibleCallback(visible);
};
if (!('IntersectionObserver' in window)) {
const interval = setInterval(() => setVisible(CoreDom.isElementInViewport(element, intersectionRatio)), 50);
return () => clearInterval(interval);
}
const observer = new IntersectionObserver(([{ isIntersecting, intersectionRatio }]) => {
setVisible(isIntersecting && intersectionRatio >= intersectionRatio);
});
observer.observe(element);
return () => observer.disconnect();
}
/** /**
* Wait an element to be in dom and visible. * Wait an element to be in dom and visible.
* *
@ -362,48 +418,29 @@ export class CoreDom {
* @return Cancellable promise. * @return Cancellable promise.
*/ */
static waitToBeInViewport(element: HTMLElement, intersectionRatio = 1): CoreCancellablePromise<void> { static waitToBeInViewport(element: HTMLElement, intersectionRatio = 1): CoreCancellablePromise<void> {
let unsubscribe: (() => void) | undefined;
const visiblePromise = CoreDom.waitToBeVisible(element); const visiblePromise = CoreDom.waitToBeVisible(element);
let intersectionObserver: IntersectionObserver;
let interval: number | undefined;
return new CoreCancellablePromise<void>( return new CoreCancellablePromise<void>(
async (resolve) => { async (resolve) => {
await visiblePromise; await visiblePromise;
if (CoreDom.isElementInViewport(element, intersectionRatio)) { if (CoreDom.isElementInViewport(element, intersectionRatio)) {
return resolve(); return resolve();
} }
if ('IntersectionObserver' in window) { unsubscribe = this.watchElementInViewport(element, intersectionRatio, inViewport => {
intersectionObserver = new IntersectionObserver((observerEntries) => { if (!inViewport) {
const isIntersecting = observerEntries return;
.some((entry) => entry.isIntersecting && entry.intersectionRatio >= intersectionRatio); }
if (!isIntersecting) {
return;
}
resolve(); resolve();
intersectionObserver?.disconnect(); unsubscribe?.();
}); });
intersectionObserver.observe(element);
} else {
interval = window.setInterval(() => {
if (!CoreDom.isElementInViewport(element, intersectionRatio)) {
return;
}
resolve();
window.clearInterval(interval);
}, 50);
}
}, },
() => { () => {
visiblePromise.cancel(); visiblePromise.cancel();
intersectionObserver?.disconnect(); unsubscribe?.();
window.clearInterval(interval);
}, },
); );
} }