MOBILE-3833 usertours: Watch elements visibility
parent
771ee90d6a
commit
35433615f3
|
@ -32,8 +32,8 @@ import { CoreSwipeNavigationDirective } from './swipe-navigation';
|
|||
import { CoreCollapsibleItemDirective } from './collapsible-item';
|
||||
import { CoreCollapsibleFooterDirective } from './collapsible-footer';
|
||||
import { CoreContentDirective } from './content';
|
||||
import { CoreOnAppearDirective } from './on-appear';
|
||||
import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-attributes';
|
||||
import { CoreUserTourDirective } from './user-tour';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
|
@ -48,7 +48,6 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
|
|||
CoreSupressEventsDirective,
|
||||
CoreUserLinkDirective,
|
||||
CoreAriaButtonClickDirective,
|
||||
CoreOnAppearDirective,
|
||||
CoreOnResizeDirective,
|
||||
CoreDownloadFileDirective,
|
||||
CoreCollapsibleHeaderDirective,
|
||||
|
@ -57,6 +56,7 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
|
|||
CoreCollapsibleFooterDirective,
|
||||
CoreContentDirective,
|
||||
CoreUpdateNonReactiveAttributesDirective,
|
||||
CoreUserTourDirective,
|
||||
],
|
||||
exports: [
|
||||
CoreAutoFocusDirective,
|
||||
|
@ -70,7 +70,6 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
|
|||
CoreSupressEventsDirective,
|
||||
CoreUserLinkDirective,
|
||||
CoreAriaButtonClickDirective,
|
||||
CoreOnAppearDirective,
|
||||
CoreOnResizeDirective,
|
||||
CoreDownloadFileDirective,
|
||||
CoreCollapsibleHeaderDirective,
|
||||
|
@ -79,6 +78,7 @@ import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-
|
|||
CoreCollapsibleFooterDirective,
|
||||
CoreContentDirective,
|
||||
CoreUpdateNonReactiveAttributesDirective,
|
||||
CoreUserTourDirective,
|
||||
],
|
||||
})
|
||||
export class CoreDirectivesModule {}
|
||||
|
|
|
@ -93,6 +93,7 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy {
|
|||
await CoreUserTours.showIfPending({
|
||||
id: 'swipe-navigation',
|
||||
component: CoreSwipeNavigationTourComponent,
|
||||
watch: this.element,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -12,20 +12,22 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// 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 { CoreUserTours, CoreUserToursFocusedOptions, CoreUserToursUserTour } from '@features/usertours/services/user-tours';
|
||||
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({
|
||||
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;
|
||||
protected visiblePromise?: CoreCancellablePromise<void>;
|
||||
|
||||
|
@ -41,14 +43,35 @@ export class CoreOnAppearDirective implements OnInit, OnDestroy {
|
|||
|
||||
await this.visiblePromise;
|
||||
|
||||
this.onAppear.emit();
|
||||
const { getFocusedElement, ...options } = this.userTour;
|
||||
|
||||
this.tour = await CoreUserTours.showIfPending({
|
||||
...options,
|
||||
focus: getFocusedElement?.(this.element) ?? this.element,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.tour?.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;
|
||||
|
||||
};
|
|
@ -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>
|
||||
<ion-icon name="fas-chevron-left" slot="icon-only" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
|
|
|
@ -14,7 +14,8 @@
|
|||
|
||||
import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||
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 { CoreDom } from '@singletons/dom';
|
||||
import { CoreBlockSideBlocksTourComponent } from '../side-blocks-tour/side-blocks-tour';
|
||||
|
@ -34,6 +35,14 @@ export class CoreBlockSideBlocksButtonComponent implements OnInit, OnDestroy {
|
|||
@Input() instanceId!: number;
|
||||
@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 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
|
||||
*/
|
||||
|
|
|
@ -55,7 +55,7 @@
|
|||
|
||||
<!-- Course Index button. -->
|
||||
<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>
|
||||
<ion-icon name="fas-list-ul" aria-hidden="true"></ion-icon>
|
||||
<span class="sr-only">{{'core.course.courseindex' | translate }}</span>
|
||||
|
|
|
@ -46,9 +46,10 @@ import { CoreBlockHelper } from '@features/block/services/block-helper';
|
|||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
|
||||
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 { 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.
|
||||
|
@ -87,6 +88,13 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
|
|||
canLoadMore = false;
|
||||
showSectionId = 0;
|
||||
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;
|
||||
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.
|
||||
*/
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<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>
|
||||
</core-user-avatar>
|
||||
|
|
|
@ -14,7 +14,8 @@
|
|||
|
||||
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
|
||||
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 { CoreScreen } from '@services/screen';
|
||||
import { CoreSites } from '@services/sites';
|
||||
|
@ -36,6 +37,12 @@ export class CoreMainMenuUserButtonComponent implements OnInit {
|
|||
|
||||
siteInfo?: CoreSiteInfo;
|
||||
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>;
|
||||
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -55,6 +55,7 @@ export class CoreUserToursUserTourComponent implements AfterViewInit {
|
|||
popoverWrapperStyles?: string;
|
||||
popoverWrapperArrowStyles?: string;
|
||||
private element: HTMLElement;
|
||||
private tour?: HTMLElement;
|
||||
private wrapperTransform = '';
|
||||
private wrapperElement = new CorePromisedValue<HTMLElement>();
|
||||
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.
|
||||
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.
|
||||
if (this.focus && !CoreDom.isElementVisible(this.focus)) {
|
||||
|
@ -111,6 +116,19 @@ export class CoreUserToursUserTourComponent implements AfterViewInit {
|
|||
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.
|
||||
*
|
||||
|
@ -119,13 +137,15 @@ export class CoreUserToursUserTourComponent implements AfterViewInit {
|
|||
async dismiss(acknowledge: boolean = true): Promise<void> {
|
||||
this.beforeDismiss.emit();
|
||||
|
||||
await this.playLeaveAnimation();
|
||||
await Promise.all<unknown>([
|
||||
AngularFrameworkDelegate.removeViewFromDom(this.container, this.element),
|
||||
acknowledge && CoreUserTours.acknowledge(this.id),
|
||||
]);
|
||||
if (this.active) {
|
||||
await this.playLeaveAnimation();
|
||||
await AngularFrameworkDelegate.removeViewFromDom(this.container, this.element);
|
||||
|
||||
this.backButtonListener && document.removeEventListener('ionBackButton', this.backButtonListener);
|
||||
}
|
||||
|
||||
acknowledge && await CoreUserTours.acknowledge(this.id);
|
||||
|
||||
this.backButtonListener && document.removeEventListener('ionBackButton', this.backButtonListener);
|
||||
this.afterDismiss.emit();
|
||||
}
|
||||
|
||||
|
|
|
@ -15,12 +15,14 @@
|
|||
import { CoreConstants } from '@/core/constants';
|
||||
import { asyncInstance } from '@/core/utils/async-instance';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CoreCancellablePromise } from '@classes/cancellable-promise';
|
||||
import { CoreDatabaseTable } from '@classes/database/database-table';
|
||||
import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy';
|
||||
import { CoreApp } from '@services/app';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { AngularFrameworkDelegate, makeSingleton } from '@singletons';
|
||||
import { CoreComponentsRegistry } from '@singletons/components-registry';
|
||||
import { CoreDom } from '@singletons/dom';
|
||||
import { CoreSubscriptions } from '@singletons/subscriptions';
|
||||
import { CoreUserToursUserTourComponent } from '../components/user-tour/user-tour';
|
||||
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 {
|
||||
|
||||
protected table = asyncInstance<CoreDatabaseTable<CoreUserToursDBEntry>>();
|
||||
protected tours: CoreUserToursUserTourComponent[] = [];
|
||||
protected tourReadyCallbacks = new WeakMap<CoreUserToursUserTourComponent, () => void>();
|
||||
protected tours: { component: CoreUserToursUserTourComponent; visible: boolean }[] = [];
|
||||
|
||||
/**
|
||||
* Initialize database.
|
||||
|
@ -84,14 +85,17 @@ export class CoreUserToursService {
|
|||
* Show a User Tour if it's pending.
|
||||
*
|
||||
* @param options User Tour options.
|
||||
* @returns User tour controller, if any.
|
||||
*/
|
||||
async showIfPending(options: CoreUserToursBasicOptions): Promise<void>;
|
||||
async showIfPending(options: CoreUserToursFocusedOptions): Promise<void>;
|
||||
async showIfPending(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise<void> {
|
||||
async showIfPending(options: CoreUserToursBasicOptions): Promise<CoreUserToursUserTour | null>;
|
||||
async showIfPending(options: CoreUserToursFocusedOptions): Promise<CoreUserToursUserTour | null>;
|
||||
async showIfPending(
|
||||
options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions,
|
||||
): Promise<CoreUserToursUserTour | null> {
|
||||
const isPending = await CoreUserTours.isPending(options.id);
|
||||
|
||||
if (!isPending) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.show(options);
|
||||
|
@ -101,16 +105,15 @@ export class CoreUserToursService {
|
|||
* Show a User Tour.
|
||||
*
|
||||
* @param options User Tour options.
|
||||
* @returns User tour controller.
|
||||
*/
|
||||
protected async show(options: CoreUserToursBasicOptions): Promise<void>;
|
||||
protected async show(options: CoreUserToursFocusedOptions): Promise<void>;
|
||||
protected async show(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise<void> {
|
||||
protected async show(options: CoreUserToursBasicOptions): Promise<CoreUserToursUserTour>;
|
||||
protected async show(options: CoreUserToursFocusedOptions): Promise<CoreUserToursUserTour>;
|
||||
protected async show(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise<CoreUserToursUserTour> {
|
||||
const { delay, ...componentOptions } = options;
|
||||
|
||||
// Delay start.
|
||||
await CoreUtils.wait(delay ?? 200);
|
||||
|
||||
// Create tour.
|
||||
const container = document.querySelector('ion-app') ?? document.body;
|
||||
const element = await AngularFrameworkDelegate.attachViewToDom(
|
||||
container,
|
||||
|
@ -119,26 +122,7 @@ export class CoreUserToursService {
|
|||
);
|
||||
const tour = CoreComponentsRegistry.require(element, CoreUserToursUserTourComponent);
|
||||
|
||||
this.tours.push(tour);
|
||||
|
||||
// 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();
|
||||
return this.startTour(tour, options.watch ?? (options as CoreUserToursFocusedOptions).focus);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -147,13 +131,144 @@ export class CoreUserToursService {
|
|||
* @param acknowledge Whether to acknowledge that the user has seen this User Tour or not.
|
||||
*/
|
||||
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);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
@ -202,6 +317,13 @@ export interface CoreUserToursBasicOptions {
|
|||
*/
|
||||
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;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
@ -362,48 +418,29 @@ export class CoreDom {
|
|||
* @return Cancellable promise.
|
||||
*/
|
||||
static waitToBeInViewport(element: HTMLElement, intersectionRatio = 1): CoreCancellablePromise<void> {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
const visiblePromise = CoreDom.waitToBeVisible(element);
|
||||
|
||||
let intersectionObserver: IntersectionObserver;
|
||||
let interval: number | undefined;
|
||||
|
||||
return new CoreCancellablePromise<void>(
|
||||
async (resolve) => {
|
||||
await visiblePromise;
|
||||
|
||||
if (CoreDom.isElementInViewport(element, intersectionRatio)) {
|
||||
|
||||
return resolve();
|
||||
}
|
||||
|
||||
if ('IntersectionObserver' in window) {
|
||||
intersectionObserver = new IntersectionObserver((observerEntries) => {
|
||||
const isIntersecting = observerEntries
|
||||
.some((entry) => entry.isIntersecting && entry.intersectionRatio >= intersectionRatio);
|
||||
if (!isIntersecting) {
|
||||
return;
|
||||
}
|
||||
unsubscribe = this.watchElementInViewport(element, intersectionRatio, inViewport => {
|
||||
if (!inViewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
intersectionObserver?.disconnect();
|
||||
});
|
||||
|
||||
intersectionObserver.observe(element);
|
||||
} else {
|
||||
interval = window.setInterval(() => {
|
||||
if (!CoreDom.isElementInViewport(element, intersectionRatio)) {
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
window.clearInterval(interval);
|
||||
}, 50);
|
||||
}
|
||||
resolve();
|
||||
unsubscribe?.();
|
||||
});
|
||||
},
|
||||
() => {
|
||||
visiblePromise.cancel();
|
||||
intersectionObserver?.disconnect();
|
||||
window.clearInterval(interval);
|
||||
unsubscribe?.();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue