diff --git a/src/core/classes/modal-component.ts b/src/core/classes/modal-component.ts new file mode 100644 index 000000000..207c8a9bb --- /dev/null +++ b/src/core/classes/modal-component.ts @@ -0,0 +1,45 @@ +// (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 { ElementRef } from '@angular/core'; +import { CorePromisedValue } from '@classes/promised-value'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; + +/** + * Helper class to build modals. + */ +export class CoreModalComponent { + + result: CorePromisedValue = new CorePromisedValue(); + + constructor({ nativeElement: element }: ElementRef) { + CoreDirectivesRegistry.register(element, this); + } + + /** + * Close the modal. + * + * @param result Result data, or error instance if the modal was closed with a failure. + */ + async close(result: T | Error): Promise { + if (result instanceof Error) { + this.result.reject(result); + + return; + } + + this.result.resolve(result); + } + +} diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index 846aee4e6..f0cfb2cef 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -64,6 +64,7 @@ import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe- import { CoreMessageComponent } from './message/message'; import { CoreGroupSelectorComponent } from './group-selector/group-selector'; import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh-button-modal'; +import { CoreSheetModalComponent } from '@components/sheet-modal/sheet-modal'; @NgModule({ declarations: [ @@ -110,6 +111,7 @@ import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh- CoreHorizontalScrollControlsComponent, CoreSwipeNavigationTourComponent, CoreRefreshButtonModalComponent, + CoreSheetModalComponent, ], imports: [ CommonModule, @@ -163,6 +165,7 @@ import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh- CoreHorizontalScrollControlsComponent, CoreSwipeNavigationTourComponent, CoreRefreshButtonModalComponent, + CoreSheetModalComponent, ], }) export class CoreComponentsModule {} diff --git a/src/core/components/sheet-modal/sheet-modal.html b/src/core/components/sheet-modal/sheet-modal.html new file mode 100644 index 000000000..e5a79c2c8 --- /dev/null +++ b/src/core/components/sheet-modal/sheet-modal.html @@ -0,0 +1,2 @@ + +
diff --git a/src/core/components/sheet-modal/sheet-modal.scss b/src/core/components/sheet-modal/sheet-modal.scss new file mode 100644 index 000000000..b5f43ef96 --- /dev/null +++ b/src/core/components/sheet-modal/sheet-modal.scss @@ -0,0 +1,40 @@ +@import "~theme/globals"; + +:host { + --backdrop-opacity: var(--ion-backdrop-opacity, 0.4); + + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: flex-end; + isolation: isolate; + + ion-backdrop { + opacity: 0; + transition: opacity 300ms ease-in; + } + + .sheet-modal--wrapper { + border-radius: var(--big-radius) var(--big-radius) 0 0; + @include padding(24px, 16px, 24px, 16px); + + background-color: var(--ion-overlay-background-color, var(--ion-background-color, #fff)); + z-index: 3; // ion-backdrop has z-index 2 + transform: translateY(100%); + transition: transform 300ms ease-in; + } + + &.active { + + ion-backdrop { + opacity: var(--backdrop-opacity); + } + + .sheet-modal--wrapper { + transform: translateY(0%); + } + + } + +} diff --git a/src/core/components/sheet-modal/sheet-modal.ts b/src/core/components/sheet-modal/sheet-modal.ts new file mode 100644 index 000000000..4216224b4 --- /dev/null +++ b/src/core/components/sheet-modal/sheet-modal.ts @@ -0,0 +1,93 @@ +// (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 { AfterViewInit, Component, ElementRef, Input, ViewChild } from '@angular/core'; +import { CoreModalComponent } from '@classes/modal-component'; +import { CorePromisedValue } from '@classes/promised-value'; +import { CoreModals } from '@services/modals'; +import { CoreUtils } from '@services/utils/utils'; +import { AngularFrameworkDelegate } from '@singletons'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; + +@Component({ + selector: 'core-sheet-modal', + templateUrl: 'sheet-modal.html', + styleUrls: ['sheet-modal.scss'], +}) +export class CoreSheetModalComponent implements AfterViewInit { + + @Input() component!: Constructor; + @Input() componentProps?: Record; + @ViewChild('wrapper') wrapper?: ElementRef; + + private element: HTMLElement; + private wrapperElement = new CorePromisedValue(); + + constructor({ nativeElement: element }: ElementRef) { + this.element = element; + + CoreDirectivesRegistry.register(element, this); + } + + /** + * @inheritdoc + */ + ngAfterViewInit(): void { + if (!this.wrapper) { + this.wrapperElement.reject(new Error('CoreSheetModalComponent wasn\'t mounted properly')); + + return; + } + + this.wrapperElement.resolve(this.wrapper.nativeElement); + } + + /** + * Show modal. + * + * @returns Component instance. + */ + async show(): Promise { + const wrapper = await this.wrapperElement; + const element = await AngularFrameworkDelegate.attachViewToDom(wrapper, this.component, this.componentProps ?? {}); + + await CoreUtils.nextTick(); + + this.element.classList.add('active'); + this.element.style.zIndex = `${20000 + CoreModals.getTopOverlayIndex()}`; + + await CoreUtils.nextTick(); + await CoreUtils.wait(300); + + const instance = CoreDirectivesRegistry.resolve(element, this.component); + + if (!instance) { + throw new Error('Modal not mounted properly'); + } + + return instance; + } + + /** + * Hide modal. + */ + async hide(): Promise { + this.element.classList.remove('active'); + + await CoreUtils.nextTick(); + await CoreUtils.wait(300); + } + +} diff --git a/src/core/services/modals.ts b/src/core/services/modals.ts new file mode 100644 index 000000000..7201505e5 --- /dev/null +++ b/src/core/services/modals.ts @@ -0,0 +1,89 @@ +// (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 { CoreModalComponent } from '@classes/modal-component'; +import { CoreSheetModalComponent } from '@components/sheet-modal/sheet-modal'; +import { AngularFrameworkDelegate, makeSingleton } from '@singletons'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; + +/** + * Handles application modals. + */ +@Injectable({ providedIn: 'root' }) +export class CoreModalsService { + + /** + * 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.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. + * @returns Modal result once it's been closed. + */ + async openSheet(component: Constructor>): 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 }, + ); + const sheetModal = CoreDirectivesRegistry.require>>( + element, + CoreSheetModalComponent, + ); + const modal = await sheetModal.show(); + + viewContainer?.setAttribute('aria-hidden', 'true'); + + modal.result.finally(async () => { + await sheetModal.hide(); + + element.remove(); + viewContainer?.removeAttribute('aria-hidden'); + }); + + return modal.result; + } + +} + +export const CoreModals = makeSingleton(CoreModalsService);