// (C) Copyright 2015 Moodle Pty Ltd. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { Constructor } from '@/core/utils/types'; import { Injectable } from '@angular/core'; import { NavigationStart } from '@angular/router'; import { CoreModalComponent } from '@classes/modal-component'; import { CoreModalLateralTransitionEnter, CoreModalLateralTransitionLeave } from '@classes/modal-lateral-transition'; import { CoreSheetModalComponent } from '@components/sheet-modal/sheet-modal'; import { AngularFrameworkDelegate, makeSingleton, ModalController, Router } from '@singletons'; import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { Subscription, filter } from 'rxjs'; import { Md5 } from 'ts-md5'; import { fixOverlayAriaHidden } from '../utils/fix-aria-hidden'; import { ModalOptions } from '@ionic/angular'; import { CoreCanceledError } from '@classes/errors/cancelederror'; import { CoreWSError } from '@classes/errors/wserror'; import { CorePasswordModalResponse, CorePasswordModalParams } from '@components/password-modal/password-modal'; /** * Handles application modals. */ @Injectable({ providedIn: 'root' }) export class CoreModalsService { protected displayedModals: Record = {}; // To prevent duplicated modals. /** * Get index of the overlay on top of the stack. * * @returns Z-index of the overlay on top. */ getTopOverlayIndex(): number { // This has to be done manually because Ionic's overlay mechanisms are not exposed externally, thus making it more difficult // to implement custom overlays. // // eslint-disable-next-line max-len // See https://github.com/ionic-team/ionic-framework/blob/a9b12a5aa4c150a1f8a80a826dda0df350bc0092/core/src/utils/overlays.ts#L39 const overlays = document.body.querySelectorAll( 'ion-action-sheet, ion-alert, ion-loading, ion-modal, ion-picker, ion-popover, ion-toast', ); return Array.from(overlays).reduce((maxIndex, element) => { const index = parseInt(element.style.zIndex); if (isNaN(index)) { return maxIndex; } return Math.max(maxIndex, index % 10000); }, 0); } /** * Open a sheet modal component. * * @param component Component to render inside the modal. * @param componentProps Component to render inside the modal. * @param backdropDismiss Dismiss on backdrop click. * * @returns Modal result once it's been closed. */ async openSheet( component: Constructor>, componentProps: Record = {}, backdropDismiss = false, ): Promise { const container = document.querySelector('ion-app') ?? document.body; const viewContainer = container.querySelector('ion-router-outlet, ion-nav, #ion-view-container-root'); const element = await AngularFrameworkDelegate.attachViewToDom( container, CoreSheetModalComponent, { component, componentProps }, ); const sheetModal = CoreDirectivesRegistry.require>>( element, CoreSheetModalComponent, ); const modal = await sheetModal.show(); viewContainer?.setAttribute('aria-hidden', 'true'); if (backdropDismiss) { const backdrop = element.querySelector('ion-backdrop'); backdrop?.addEventListener('ionBackdropTap', () => modal.close(new Error('Backdrop clicked')), { once: true }); } modal.result.finally(async () => { await sheetModal.hide(); await AngularFrameworkDelegate.removeViewFromDom(container, element); viewContainer?.removeAttribute('aria-hidden'); }); return modal.result; } /** * Opens a Modal. * * @param options Modal Options. * @returns The modal data when the modal closes. */ async openModal( options: OpenModalOptions, ): Promise { const { waitForDismissCompleted, closeOnNavigate, ...modalOptions } = options; const listenCloseEvents = closeOnNavigate ?? true; // Default to true. // TODO: Improve this if we need two modals with same component open at the same time. const modalId = Md5.hashAsciiStr(options.component?.toString() || ''); const alreadyDisplayed = !!this.displayedModals[modalId]; const modal = alreadyDisplayed ? this.displayedModals[modalId] : await ModalController.create(modalOptions); let navSubscription: Subscription | undefined; // Get the promise before presenting to get result if modal is suddenly hidden. const resultPromise = waitForDismissCompleted ? modal.onDidDismiss() : modal.onWillDismiss(); if (!this.displayedModals[modalId]) { // Store the modal and remove it when dismissed. this.displayedModals[modalId] = modal; if (listenCloseEvents) { // Listen navigation events to close modals. navSubscription = Router.events .pipe(filter(event => event instanceof NavigationStart)) .subscribe(async () => { modal.dismiss(); }); } await modal.present(); } if (!alreadyDisplayed) { fixOverlayAriaHidden(modal); } const result = await resultPromise; navSubscription?.unsubscribe(); delete this.displayedModals[modalId]; if (result?.data) { return result?.data; } } /** * Opens a side Modal. * * @param options Modal Options. * @returns The modal data when the modal closes. */ async openSideModal( options: OpenModalOptions, ): Promise { options = Object.assign({ cssClass: 'core-modal-lateral', showBackdrop: true, backdropDismiss: true, enterAnimation: CoreModalLateralTransitionEnter, leaveAnimation: CoreModalLateralTransitionLeave, }, options); return this.openModal(options); } /** * Prompts password to the user and returns the entered text. * * @param passwordParams Params to show the modal. * @returns Entered password, error and validation. */ async promptPassword(passwordParams?: CorePasswordModalParams): Promise { const { CorePasswordModalComponent } = await import('@/core/components/password-modal/password-modal.module'); const modalData = await CoreModals.openModal( { cssClass: 'core-password-modal', showBackdrop: true, backdropDismiss: true, component: CorePasswordModalComponent, componentProps: passwordParams, }, ); if (modalData === undefined) { throw new CoreCanceledError(); } else if (modalData instanceof CoreWSError) { throw modalData; } return modalData; } } export const CoreModals = makeSingleton(CoreModalsService); /** * Options for the openModal function. */ export type OpenModalOptions = ModalOptions & { waitForDismissCompleted?: boolean; closeOnNavigate?: boolean; // Default true. };