From 2971f19c4d1f1dbe946477655f362268e3871b2b Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Tue, 4 Sep 2018 14:37:34 +0300 Subject: [PATCH] feat(modal): Component Modal (#30) --- src/lib-dev/modal/module.ts | 190 +++++++ src/lib-dev/modal/styles.scss | 3 + src/lib-dev/modal/template.html | 44 ++ src/lib/core/public-api.ts | 1 + .../services/measure-scrollbar.service.ts | 50 ++ src/lib/core/styles/_variables.scss | 9 + .../styles/typography/_all-typography.scss | 2 + src/lib/core/theming/_all-theme.scss | 2 + src/lib/modal/README.md | 0 src/lib/modal/_modal-animation.scss | 114 ++++ src/lib/modal/_modal-confirm.scss | 47 ++ src/lib/modal/_modal-theme.scss | 72 +++ src/lib/modal/css-unit.pipe.ts | 13 + src/lib/modal/index.ts | 1 + src/lib/modal/modal-control.service.ts | 76 +++ src/lib/modal/modal-ref.class.ts | 50 ++ src/lib/modal/modal-util.ts | 25 + src/lib/modal/modal.component.html | 127 +++++ src/lib/modal/modal.component.ts | 517 ++++++++++++++++++ src/lib/modal/modal.module.ts | 21 + src/lib/modal/modal.scss | 112 ++++ src/lib/modal/modal.service.ts | 146 +++++ src/lib/modal/modal.spec.ts | 215 ++++++++ src/lib/modal/modal.type.ts | 69 +++ src/lib/modal/public-api.ts | 5 + src/lib/modal/tsconfig.build.json | 13 + src/lib/public-api.ts | 1 + tests/karma-system-config.js | 3 +- 28 files changed, 1927 insertions(+), 1 deletion(-) create mode 100644 src/lib-dev/modal/module.ts create mode 100644 src/lib-dev/modal/styles.scss create mode 100644 src/lib-dev/modal/template.html create mode 100644 src/lib/core/services/measure-scrollbar.service.ts create mode 100644 src/lib/modal/README.md create mode 100644 src/lib/modal/_modal-animation.scss create mode 100644 src/lib/modal/_modal-confirm.scss create mode 100644 src/lib/modal/_modal-theme.scss create mode 100644 src/lib/modal/css-unit.pipe.ts create mode 100644 src/lib/modal/index.ts create mode 100644 src/lib/modal/modal-control.service.ts create mode 100644 src/lib/modal/modal-ref.class.ts create mode 100644 src/lib/modal/modal-util.ts create mode 100644 src/lib/modal/modal.component.html create mode 100644 src/lib/modal/modal.component.ts create mode 100644 src/lib/modal/modal.module.ts create mode 100644 src/lib/modal/modal.scss create mode 100644 src/lib/modal/modal.service.ts create mode 100644 src/lib/modal/modal.spec.ts create mode 100644 src/lib/modal/modal.type.ts create mode 100644 src/lib/modal/public-api.ts create mode 100644 src/lib/modal/tsconfig.build.json diff --git a/src/lib-dev/modal/module.ts b/src/lib-dev/modal/module.ts new file mode 100644 index 000000000..4069bd7bc --- /dev/null +++ b/src/lib-dev/modal/module.ts @@ -0,0 +1,190 @@ +import { Component, Input, NgModule, TemplateRef, ViewEncapsulation } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { McButtonModule } from '../../lib/button/'; +import { McIconModule } from '../../lib/icon'; +import { McModalModule, McModalRef, McModalService } from '../../lib/modal'; + + +// tslint:disable:no-console +// tslint:disable:no-magic-numbers +// tslint:disable:no-unnecessary-class +@Component({ + selector: 'app', + template: require('./template.html'), + styleUrls: ['./styles.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ModalDemoComponent { + isVisible = false; + tplModal: McModalRef; + htmlModalVisible = false; + + constructor(private modalService: McModalService) {} + + showConfirm() { + this.modalService.success({ + mcContent : 'Сохранить сделанные изменения в запросе "Все активы с виндой"?', + mcOkText : 'Сохранить', + mcCancelText: 'Отмена', + mcOnOk : () => console.log('OK') + }); + } + + showDeleteConfirm() { + this.modalService.delete({ + mcContent : 'The selected action "Send to Arbor" is used in a rule' + + ' or an alert. It will be deleted there too.

' + + 'Delete the selected action anyway?', + mcOkType : 'warn', + mcOkText : 'Yes', + mcCancelText: 'No', + mcWidth : '480px', + mcOnOk : () => console.log('OK'), + mcOnCancel : () => console.log('Cancel') + }); + } + + createTplModal(tplTitle: TemplateRef<{}>, tplContent: TemplateRef<{}>, tplFooter: TemplateRef<{}>) { + this.tplModal = this.modalService.create({ + mcTitle : tplTitle, + mcContent : tplContent, + mcFooter : tplFooter, + mcMaskClosable: false, + mcClosable : true, + mcOnOk : () => console.log('Click ok') + }); + } + + createLongModal() { + + const modal = this.modalService.create({ + mcTitle : 'Modal Title', + mcContent : McModalLongCustomComponent, + mcOkText : 'Yes', + mcCancelText: 'No' + }); + } + + createComponentModal() { + const modal = this.modalService.create({ + mcTitle: 'Modal Title', + mcContent: McModalCustomComponent, + mcComponentParams: { + title: 'title in component', + subtitle: 'component sub title,will be changed after 2 sec' + }, + mcFooter: [{ + label: 'change component title from outside', + type: 'primary', + onClick: (componentInstance: any) => { + componentInstance.title = 'title in inner component is changed'; + } + }] + }); + + modal.afterOpen.subscribe(() => console.log('[afterOpen] emitted!')); + + // Return a result when closed + modal.afterClose.subscribe((result) => console.log('[afterClose] The result is:', result)); + + // delay until modal instance created + window.setTimeout(() => { + const instance = modal.getContentComponent(); + instance.subtitle = 'sub title is changed'; + }, 2000); + } + + openAndCloseAll() { + let pos = 0; + + [ 'create', 'delete', 'success' ].forEach((method) => this.modalService[method]({ + mcOkText : 'Yes', + mcMask: false, + mcContent: `Test content: ${method}`, + mcStyle: { position: 'absolute', top: `${pos * 70}px`, left: `${(pos++) * 300}px` } + })); + + this.htmlModalVisible = true; + + this.modalService.afterAllClose.subscribe(() => console.log('afterAllClose emitted!')); + + window.setTimeout(() => this.modalService.closeAll(), 5000); + } + + destroyTplModal() { + this.tplModal.destroy(); + } +} + + +@Component({ + selector: 'mc-modal-custom-long-component', + template: ` + +

{{ item }}

+
+ ` +}) +export class McModalLongCustomComponent { + + longText: any = []; + + constructor() { + for (let i = 0; i < 50; i++) { + this.longText.push(`text lint - ${i}`); + } + } +} + +@Component({ + selector: 'mc-modal-custom-component', + template: ` +
+

{{ title }}

+

{{ subtitle }}

+

+ Get Modal instance in component + +

+
+ ` +}) +export class McModalCustomComponent { + @Input() title: string; + @Input() subtitle: string; + + constructor(private modal: McModalRef) { } + + destroyModal() { + this.modal.destroy({ data: 'this the result data' }); + } +} + +@NgModule({ + declarations: [ + ModalDemoComponent, + McModalCustomComponent, + McModalLongCustomComponent + ], + entryComponents: [ + McModalCustomComponent, + McModalLongCustomComponent + ], + imports: [ + BrowserModule, + McButtonModule, + McIconModule, + McModalModule + ], + bootstrap: [ + ModalDemoComponent + ] +}) +export class DemoModule {} + +platformBrowserDynamic() + .bootstrapModule(DemoModule) + .catch((error) => console.error(error)); + diff --git a/src/lib-dev/modal/styles.scss b/src/lib-dev/modal/styles.scss new file mode 100644 index 000000000..0f57268f9 --- /dev/null +++ b/src/lib-dev/modal/styles.scss @@ -0,0 +1,3 @@ +@import '~@ptsecurity/mosaic-icons/dist/styles/mc-icons'; + +@import '../../lib/core/theming/prebuilt/default-theme'; diff --git a/src/lib-dev/modal/template.html b/src/lib-dev/modal/template.html new file mode 100644 index 000000000..3b6d8bcfe --- /dev/null +++ b/src/lib-dev/modal/template.html @@ -0,0 +1,44 @@ +

Confirm

+ + + + + +

Modal from service

+ + + Заголовок окна + + +

some contents...

+

some contents...

+

some contents...

+

some contents...

+

some contents...

+
+ + + + +

Modal with custom component

+ + + +

Modal with looong component

+ + + +

Many many modals

+ +This is a non-service html modal diff --git a/src/lib/core/public-api.ts b/src/lib/core/public-api.ts index 25e7b0128..0082e2186 100644 --- a/src/lib/core/public-api.ts +++ b/src/lib/core/public-api.ts @@ -3,3 +3,4 @@ export * from './common-behaviors/index'; export * from './line/line'; export * from './error/error-options'; export * from './selection/index'; +export * from './services/measure-scrollbar.service'; diff --git a/src/lib/core/services/measure-scrollbar.service.ts b/src/lib/core/services/measure-scrollbar.service.ts new file mode 100644 index 000000000..b63c63e14 --- /dev/null +++ b/src/lib/core/services/measure-scrollbar.service.ts @@ -0,0 +1,50 @@ +import { DOCUMENT } from '@angular/common'; +import { Inject, Injectable } from '@angular/core'; + + +@Injectable({ + providedIn: 'root' +}) +export class McMeasureScrollbarService { + + get scrollBarWidth(): number { + if (this._scrollbarWidth) { + return this._scrollbarWidth; + } + this.initScrollBarWidth(); + + return this._scrollbarWidth; + } + + private _scrollbarWidth: number; + private scrollbarMeasure = { + position: 'absolute', + top: '-9999px', + width: '50px', + height: '50px', + overflow: 'scroll' + }; + + constructor( + @Inject(DOCUMENT) private document: any + ) { + this.initScrollBarWidth(); + } + + initScrollBarWidth() { + const scrollDiv = this.document.createElement('div'); + + for (const scrollProp in this.scrollbarMeasure) { + if (this.scrollbarMeasure.hasOwnProperty(scrollProp)) { + scrollDiv.style[scrollProp] = this.scrollbarMeasure[scrollProp]; + } + } + + this.document.body.appendChild(scrollDiv); + + const width = scrollDiv.offsetWidth - scrollDiv.clientWidth; + + this.document.body.removeChild(scrollDiv); + this._scrollbarWidth = width; + } +} diff --git a/src/lib/core/styles/_variables.scss b/src/lib/core/styles/_variables.scss index e69de29bb..2bb855fe8 100644 --- a/src/lib/core/styles/_variables.scss +++ b/src/lib/core/styles/_variables.scss @@ -0,0 +1,9 @@ + +$zindex-modal-mask : 1000; +$zindex-modal : 1000; +$zindex-notification : 1010; +$zindex-message : 1010; +$zindex-popover : 1030; +$zindex-picker : 1050; +$zindex-dropdown : 1050; +$zindex-tooltip : 1060; diff --git a/src/lib/core/styles/typography/_all-typography.scss b/src/lib/core/styles/typography/_all-typography.scss index 214f50dac..84d6f4e0a 100644 --- a/src/lib/core/styles/typography/_all-typography.scss +++ b/src/lib/core/styles/typography/_all-typography.scss @@ -9,6 +9,7 @@ @import '../../../navbar/navbar-theme'; @import '../../../input/input-theme'; @import '../../../form-field/form-field-theme'; +@import '../../../modal/modal-theme'; @mixin mosaic-typography($config: null) { @@ -28,4 +29,5 @@ @include mc-navbar-typography($config); @include mc-input-typography($config); @include mc-form-field-typography($config); + @include mc-modal-typography($config); } diff --git a/src/lib/core/theming/_all-theme.scss b/src/lib/core/theming/_all-theme.scss index f12f714ef..44b48b081 100644 --- a/src/lib/core/theming/_all-theme.scss +++ b/src/lib/core/theming/_all-theme.scss @@ -13,6 +13,7 @@ @import '../../navbar/navbar-theme'; @import '../../input/input-theme'; @import '../../form-field/form-field-theme'; +@import '../../modal/modal-theme'; @mixin mosaic-theme($theme) { @@ -30,4 +31,5 @@ @include mc-navbar-theme($theme); @include mc-input-theme($theme); @include mc-form-field-theme($theme); + @include mc-modal-theme($theme); } diff --git a/src/lib/modal/README.md b/src/lib/modal/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/src/lib/modal/_modal-animation.scss b/src/lib/modal/_modal-animation.scss new file mode 100644 index 000000000..2afc39009 --- /dev/null +++ b/src/lib/modal/_modal-animation.scss @@ -0,0 +1,114 @@ +@import '../core/styles/functions/timing'; + + +$animation-duration-base: 0.3s; + +@mixin _motion-common($duration: $animation-duration-base) { + animation-duration: $duration; + animation-fill-mode: both; +} + +@mixin _motion-common-leave($duration: $animation-duration-base) { + animation-duration: $duration; + animation-fill-mode: both; +} + +@mixin _make-motion($className, $keyframeName, $duration: $animation-duration-base) { + .#{$className}-enter, + .#{$className}-appear { + @include _motion-common($duration); + animation-play-state: paused; + } + + .#{$className}-leave { + @include _motion-common-leave($duration); + animation-play-state: paused; + } + + .#{$className}-enter.#{$className}-enter-active, + .#{$className}-appear.#{$className}-appear-active { + animation-name: #{$keyframeName}In; + animation-play-state: running; + } + + .#{$className}-leave.#{$className}-leave-active { + animation-name: #{$keyframeName}Out; + animation-play-state: running; + pointer-events: none; + } +} + +@mixin zoom-motion($className, $keyframeName, $duration: $animation-duration-base) { + @include _make-motion($className, $keyframeName, $duration); + + .#{className}-enter, + .#{className}-appear { + transform: translate(0, -25%); + animation-timing-function: $ease-out-circ; + } + + .#{className}-leave { + transform: translate(0, 0); + animation-timing-function: $ease-in-out-circ; + } +} + +@mixin fade-motion($className, $keyframeName) { + @include _make-motion($className, $keyframeName); + + .#{className}-enter, + .#{className}-appear { + opacity: 0; + animation-timing-function: ease-out; + } + + .#{className}-leave { + animation-timing-function: ease-out; + } +} + +@keyframes mcFadeIn { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes mcFadeOut { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +@keyframes mcZoomIn { + 0% { + opacity: 0; + transform: translate(0, -25%); + } + + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes mcZoomOut { + 0% { + transform: scale(1); + } + + 100% { + opacity: 0; + transform: translate(0, -30%); + } +} + +@include fade-motion(fade, mcFade); +@include zoom-motion(zoom, mcZoom); diff --git a/src/lib/modal/_modal-confirm.scss b/src/lib/modal/_modal-confirm.scss new file mode 100644 index 000000000..6a2d7deb8 --- /dev/null +++ b/src/lib/modal/_modal-confirm.scss @@ -0,0 +1,47 @@ + +.mc-confirm { + + .mc-modal-header { + display: none; + } + + .mc-modal-close { + display: none; + } + + .mc-modal-body { + padding: 24px; + } + + // TODO + &-body-wrapper { + //.clearfix() + zoom: 1; + + &:before, + &:after { + content: ""; + display: table; + } + + &:after { + clear: both; + } + } + + &-body { + .mc-confirm-title { + display: block; + overflow: auto; + } + } + + .mc-confirm-btns { + border-radius: 0 0 4px 4px; + text-align: right; + + button + button { + margin: 16px 16px 16px; + } + } +} diff --git a/src/lib/modal/_modal-theme.scss b/src/lib/modal/_modal-theme.scss new file mode 100644 index 000000000..4d964c2f7 --- /dev/null +++ b/src/lib/modal/_modal-theme.scss @@ -0,0 +1,72 @@ +@import '../core/theming/theming'; +@import '../core/styles/typography/typography-utils'; + +@import '../core/styles/common/animation'; + + +@mixin mc-modal-theme($theme) { + + $primary: map-get($theme, primary); + $second: map-get($theme, second); + $warn: map-get($theme, warn); + $hover-darken: 5%; + + .mc-modal { + &-title { + color: mc-color($second, 700); + } + + &-header { + border-bottom: 1px solid mc-color($second, 60); + } + + &-content { + box-shadow: + 0 0 0 1px mc-color($second, 300), + 0 6px 12px 0 rgba(0, 0, 0, 0.5); + } + + &-footer { + background-color: mc-color($second, 60); + } + + + &-close-x { + + .mc-closer__icon { + color: mc-color($second, 200); + } + + &:hover .mc-closer__icon { + color: darken(mc-color($second, 200), $hover-darken); + } + } + } + + .mc-confirm { + + .mc-confirm-btns { + background-color: mc-color($second, 60); + border-top: 1px solid mc-color($second, 60); + } + } +} + +@mixin mc-modal-typography($config) { + + .mc-modal { + &-title { + @include mc-typography-level-to-styles($config, title); + } + + .mc-modal-body { + @include mc-typography-level-to-styles($config, body); + } + } + + .mc-confirm { + .mc-modal-body { + @include mc-typography-level-to-styles($config, body); + } + } +} diff --git a/src/lib/modal/css-unit.pipe.ts b/src/lib/modal/css-unit.pipe.ts new file mode 100644 index 000000000..a111b8ef9 --- /dev/null +++ b/src/lib/modal/css-unit.pipe.ts @@ -0,0 +1,13 @@ +import { Pipe, PipeTransform } from '@angular/core'; + + +@Pipe({ + name: 'toCssUnit' +}) +export class CssUnitPipe implements PipeTransform { + transform(value: number | string, defaultUnit: string = 'px'): string { + const formatted = +value; + + return isNaN(formatted) ? `${value}` : `${formatted}${defaultUnit}`; + } +} diff --git a/src/lib/modal/index.ts b/src/lib/modal/index.ts new file mode 100644 index 000000000..7e1a213e3 --- /dev/null +++ b/src/lib/modal/index.ts @@ -0,0 +1 @@ +export * from './public-api'; diff --git a/src/lib/modal/modal-control.service.ts b/src/lib/modal/modal-control.service.ts new file mode 100644 index 000000000..652d4a201 --- /dev/null +++ b/src/lib/modal/modal-control.service.ts @@ -0,0 +1,76 @@ +import { Injectable, Optional, SkipSelf } from '@angular/core'; +import { Subject, Subscription } from 'rxjs'; + +import { McModalRef } from './modal-ref.class'; + + +interface IRegisteredMeta { + modalRef: McModalRef; + afterOpenSubscription: Subscription; + afterCloseSubscription: Subscription; +} + +@Injectable() +export class McModalControlService { + + // Track singleton afterAllClose through over the injection tree + get afterAllClose(): Subject { + return this.parentService ? this.parentService.afterAllClose : this.rootAfterAllClose; + } + + // Track singleton openModals array through over the injection tree + get openModals(): McModalRef[] { + return this.parentService ? this.parentService.openModals : this.rootOpenModals; + } + + // @ts-ignore + private rootOpenModals: McModalRef[] = this.parentService ? null : []; + // @ts-ignore + private rootAfterAllClose: Subject = this.parentService ? null : new Subject(); + // @ts-ignore + private rootRegisteredMetaMap: Map = this.parentService ? null : new Map(); + + // Registered modal for later usage + private get registeredMetaMap(): Map { + return this.parentService ? this.parentService.registeredMetaMap : this.rootRegisteredMetaMap; + } + + constructor( + @Optional() @SkipSelf() private parentService: McModalControlService) { + } + + // Register a modal to listen its open/close + registerModal(modalRef: McModalRef): void { + if (!this.hasRegistered(modalRef)) { + const afterOpenSubscription = modalRef.afterOpen.subscribe(() => this.openModals.push(modalRef)); + const afterCloseSubscription = modalRef.afterClose.subscribe(() => this.removeOpenModal(modalRef)); + + this.registeredMetaMap.set(modalRef, {modalRef, afterOpenSubscription, afterCloseSubscription}); + } + } + + hasRegistered(modalRef: McModalRef): boolean { + return this.registeredMetaMap.has(modalRef); + } + + // Close all registered opened modals + closeAll(): void { + let i = this.openModals.length; + + while (i--) { + this.openModals[i].close(); + } + } + + private removeOpenModal(modalRef: McModalRef): void { + const index = this.openModals.indexOf(modalRef); + + if (index > -1) { + this.openModals.splice(index, 1); + + if (!this.openModals.length) { + this.afterAllClose.next(); + } + } + } +} diff --git a/src/lib/modal/modal-ref.class.ts b/src/lib/modal/modal-ref.class.ts new file mode 100644 index 000000000..bd422a668 --- /dev/null +++ b/src/lib/modal/modal-ref.class.ts @@ -0,0 +1,50 @@ +import { Observable } from 'rxjs'; + +import { McModalComponent } from './modal.component'; + + +/** + * API class that public to users to handle the modal instance. + * McModalRef is aim to avoid accessing to the modal instance directly by users. + */ +export abstract class McModalRef { + abstract afterOpen: Observable; + abstract afterClose: Observable; + + abstract open(): void; + + abstract close(result?: R): void; + + abstract destroy(result?: R): void; + + /** + * Trigger the nzOnOk/nzOnCancel by manual + */ + abstract triggerOk(): void; + + abstract triggerCancel(): void; + + // /** + // * Return the ComponentRef of nzContent when specify nzContent as a Component + // * Note: this method may return undefined if the Component has not ready yet. + // (it only available after Modal's ngOnInit) + // */ + // abstract getContentComponentRef(): ComponentRef<{}>; + + /** + * Return the component instance of nzContent when specify nzContent as a Component + * Note: this method may return undefined if the Component has not ready yet. + * (it only available after Modal's ngOnInit) + */ + abstract getContentComponent(): T; + + /** + * Get the dom element of this Modal + */ + abstract getElement(): HTMLElement; + + /** + * Get the instance of the Modal itself + */ + abstract getInstance(): McModalComponent; +} diff --git a/src/lib/modal/modal-util.ts b/src/lib/modal/modal-util.ts new file mode 100644 index 000000000..b3643b1bf --- /dev/null +++ b/src/lib/modal/modal-util.ts @@ -0,0 +1,25 @@ +export interface IClickPosition { + x: number; + y: number; +} + +export class ModalUtil { + private lastPosition: IClickPosition; + + constructor(private document: Document) { + this.lastPosition = {x: -1, y: -1}; + this.listenDocumentClick(); + } + + getLastClickPosition(): IClickPosition { + return this.lastPosition; + } + + listenDocumentClick(): void { + this.document.addEventListener('click', (event: MouseEvent) => { + this.lastPosition = {x: event.clientX, y: event.clientY}; + }); + } +} + +export default new ModalUtil(document); diff --git a/src/lib/modal/modal.component.html b/src/lib/modal/modal.component.html new file mode 100644 index 000000000..532bc9906 --- /dev/null +++ b/src/lib/modal/modal.component.html @@ -0,0 +1,127 @@ + + + + + +
+
+ +
+ + + +
+
+ + + +
+
+
+
+
+
+ + + + +
+
+ +
+
+
+ +
+ + + + +
+
+
+
+ + + + +
+
+ +
+
+
+
+
+
+
+ + +
+
+ diff --git a/src/lib/modal/modal.component.ts b/src/lib/modal/modal.component.ts new file mode 100644 index 000000000..2a52eeb9e --- /dev/null +++ b/src/lib/modal/modal.component.ts @@ -0,0 +1,517 @@ +import { DOCUMENT } from '@angular/common'; +import { + AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, + Component, + ComponentFactoryResolver, + ComponentRef, + ElementRef, + EventEmitter, + Inject, + Injector, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + Renderer2, + SimpleChanges, + TemplateRef, + Type, + ViewChild, + ViewContainerRef, ViewEncapsulation +} from '@angular/core'; +import { ESCAPE } from '@ptsecurity/cdk/keycodes'; +import { Overlay, OverlayRef } from '@ptsecurity/cdk/overlay'; +import { McMeasureScrollbarService } from '@ptsecurity/mosaic/core'; +import { Observable } from 'rxjs'; + +import { McModalControlService } from './modal-control.service'; +import { McModalRef } from './modal-ref.class'; +// tslint:disable-next-line +import ModalUtil from './modal-util'; +import { IModalButtonOptions, IModalOptions, ModalType, OnClickCallback } from './modal.type'; + + +// Duration when perform animations (ms) +export const MODAL_ANIMATE_DURATION = 200; + +type AnimationState = 'enter' | 'leave' | null; + +@Component({ + selector: 'mc-modal', + templateUrl: './modal.component.html', + styleUrls: ['./modal.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: { + '(keydown)': 'onKeyDown($event)' + } +}) +export class McModalComponent extends McModalRef + implements OnInit, OnChanges, AfterViewInit, OnDestroy, IModalOptions { + + // tslint:disable-next-line:no-any + @Input() mcModalType: ModalType = 'default'; + // If not specified, will use + @Input() mcContent: string | TemplateRef<{}> | Type; + // available when mcContent is a component + @Input() mcComponentParams: object; + // Default Modal ONLY + @Input() mcFooter: string | TemplateRef<{}> | IModalButtonOptions[]; + + @Input() + get mcVisible() { return this._mcVisible; } + set mcVisible(value) { this._mcVisible = value; } + _mcVisible = false; + + @Output() mcVisibleChange = new EventEmitter(); + + @Input() mcZIndex: number = 1000; + @Input() mcWidth: number | string = 480; + @Input() mcWrapClassName: string; + @Input() mcClassName: string; + @Input() mcStyle: object; + @Input() mcTitle: string | TemplateRef<{}>; + @Input() mcCloseByESC: boolean = true; + + @Input() + get mcClosable() { return this._mcClosable; } + set mcClosable(value) { this._mcClosable = value; } + _mcClosable = true; + + @Input() + get mcMask() { return this._mcMask; } + set mcMask(value) { this._mcMask = value; } + _mcMask = true; + + @Input() + get mcMaskClosable() { return this._mcMaskClosable; } + set mcMaskClosable(value) { this._mcMaskClosable = value; } + _mcMaskClosable = true; + + @Input() mcMaskStyle: object; + @Input() mcBodyStyle: object; + + // Trigger when modal open(visible) after animations + @Output() mcAfterOpen = new EventEmitter(); + // Trigger when modal leave-animation over + @Output() mcAfterClose = new EventEmitter(); + + // --- Predefined OK & Cancel buttons + @Input() mcOkText: string; + @Input() mcOkType = 'primary'; + + @Input() + get mcOkLoading() { return this._mcOkLoading; } + set mcOkLoading(value) { this._mcOkLoading = value; } + _mcOkLoading = false; + + @Input() @Output() mcOnOk: EventEmitter | OnClickCallback = new EventEmitter(); + // Only aim to focus the ok button that needs to be auto focused + @ViewChild('autoFocusButtonOk', {read: ElementRef}) autoFocusButtonOk: ElementRef; + @Input() mcCancelText: string; + + @Input() + get mcCancelLoading() { return this._mcCancelLoading; } + set mcCancelLoading(value) { this._mcCancelLoading = value; } + _mcCancelLoading = false; + + @Input() @Output() mcOnCancel: EventEmitter | OnClickCallback = new EventEmitter(); + @ViewChild('modalContainer') modalContainer: ElementRef; + @ViewChild('bodyContainer', {read: ViewContainerRef}) bodyContainer: ViewContainerRef; + maskAnimationClassMap: object; + modalAnimationClassMap: object; + // The origin point that animation based on + transformOrigin = '0px 0px 0px'; + + // Observable alias for mcAfterOpen + get afterOpen(): Observable { + return this.mcAfterOpen.asObservable(); + } + + // Observable alias for mcAfterClose + get afterClose(): Observable { + return this.mcAfterClose.asObservable(); + } + + get okText(): string { + return this.mcOkText; + } + + get cancelText(): string { + return this.mcCancelText; + } + + // Indicate whether this dialog should hidden + get hidden(): boolean { + return !this.mcVisible && !this.animationState; + } + + // Handle the reference when using mcContent as Component + private contentComponentRef: ComponentRef; + // Current animation state + private animationState: AnimationState; + private container: HTMLElement | OverlayRef; + + constructor( + private overlay: Overlay, + private renderer: Renderer2, + private cfr: ComponentFactoryResolver, + private elementRef: ElementRef, + private viewContainer: ViewContainerRef, + private mcMeasureScrollbarService: McMeasureScrollbarService, + private modalControl: McModalControlService, + private changeDetector: ChangeDetectorRef, + @Inject(DOCUMENT) private document: any) { + + super(); + } + + @Input() mcGetContainer: HTMLElement | OverlayRef | (() => HTMLElement | OverlayRef) = () => this.overlay.create(); + + ngOnInit() { + + // Create component along without View + if (this.isComponent(this.mcContent)) { + this.createDynamicComponent(this.mcContent as Type); + } + + // Setup default button options + if (this.isModalButtons(this.mcFooter)) { + this.mcFooter = this.formatModalButtons(this.mcFooter as IModalButtonOptions[]); + } + + // Place the modal dom to elsewhere + this.container = typeof this.mcGetContainer === 'function' ? this.mcGetContainer() : this.mcGetContainer; + if (this.container instanceof HTMLElement) { + this.container.appendChild(this.elementRef.nativeElement); + } else if (this.container instanceof OverlayRef) { + // NOTE: only attach the dom to overlay, the view container is not changed actually + this.container.overlayElement.appendChild(this.elementRef.nativeElement); + } + + // Register modal when afterOpen/afterClose is stable + this.modalControl.registerModal(this); + } + + // [NOTE] NOT available when using by service! + // Because ngOnChanges never be called when using by service, + // here we can't support "mcContent"(Component) etc. as inputs that initialized dynamically. + // BUT: User also can change "mcContent" dynamically to trigger UI changes + // (provided you don't use Component that needs initializations) + ngOnChanges(changes: SimpleChanges) { + if (changes.mcVisible) { + // Do not trigger animation while initializing + this.handleVisibleStateChange(this.mcVisible, !changes.mcVisible.firstChange); + } + } + + ngAfterViewInit() { + // If using Component, it is the time to attach View while bodyContainer is ready + if (this.contentComponentRef) { + this.bodyContainer.insert(this.contentComponentRef.hostView); + } + + if (this.autoFocusButtonOk) { + (this.autoFocusButtonOk.nativeElement as HTMLButtonElement).focus(); + } + } + + ngOnDestroy() { + if (this.container instanceof OverlayRef) { + this.container.dispose(); + } + } + + open() { + this.changeVisibleFromInside(true); + } + + close(result?: R) { + this.changeVisibleFromInside(false, result); + } + + // Destroy equals Close + destroy(result?: R) { + this.close(result); + } + + triggerOk() { + this.onClickOkCancel('ok'); + } + + triggerCancel() { + this.onClickOkCancel('cancel'); + } + + getInstance(): McModalComponent { + return this; + } + + getContentComponentRef(): ComponentRef { + return this.contentComponentRef; + } + + getContentComponent(): T { + return this.contentComponentRef && this.contentComponentRef.instance; + } + + getElement(): HTMLElement { + return this.elementRef && this.elementRef.nativeElement; + } + + onClickMask($event: MouseEvent) { + if ( + this.mcMask && + this.mcMaskClosable && + ($event.target as HTMLElement).classList.contains('mc-modal-wrap') && + this.mcVisible + ) { + this.onClickOkCancel('cancel'); + } + } + + // tslint:disable-next-line + isModalType(type: ModalType): boolean { + return this.mcModalType === type; + } + + onKeyDown(event: KeyboardEvent): void { + + if (event.keyCode === ESCAPE && this.container && (this.container instanceof OverlayRef)) { + + this.close(); + event.preventDefault(); + } + } + + // AoT + onClickCloseBtn() { + if (this.mcVisible) { + this.onClickOkCancel('cancel'); + } + } + + // AoT + // tslint:disable-next-line + onClickOkCancel(type: 'ok' | 'cancel') { + const trigger = { ok: this.mcOnOk, cancel: this.mcOnCancel }[type]; + const loadingKey = { ok: 'mcOkLoading', cancel: 'mcCancelLoading' }[type]; + + if (trigger instanceof EventEmitter) { + trigger.emit(this.getContentComponent()); + } else if (typeof trigger === 'function') { + + const result = trigger(this.getContentComponent()); + // Users can return "false" to prevent closing by default + const caseClose = (doClose: boolean | void | {}) => (doClose !== false) && this.close(doClose as R); + + if (isPromise(result)) { + this[loadingKey] = true; + const handleThen = (doClose) => { + this[loadingKey] = false; + caseClose(doClose); + }; + (result as Promise).then(handleThen).catch(handleThen); + } else { + caseClose(result); + } + } + } + + // AoT + isNonEmptyString(value: {}): boolean { + return typeof value === 'string' && value !== ''; + } + + // AoT + isTemplateRef(value: {}): boolean { + return value instanceof TemplateRef; + } + + // AoT + isComponent(value: {}): boolean { + return value instanceof Type; + } + + // AoT + isModalButtons(value: {}): boolean { + return Array.isArray(value) && value.length > 0; + } + + // Do rest things when visible state changed + private handleVisibleStateChange(visible: boolean, animation: boolean = true, closeResult?: R): Promise { + // Hide scrollbar at the first time when shown up + if (visible) { + this.changeBodyOverflow(1); + } + + return Promise + .resolve(animation && this.animateTo(visible)) + // Emit open/close event after animations over + .then(() => { + if (visible) { + this.mcAfterOpen.emit(); + } else { + this.mcAfterClose.emit(closeResult); + // Show/hide scrollbar when animation is over + this.changeBodyOverflow(); + } + }); + } + + // Lookup a button's property, if the prop is a function, call & then return the result, otherwise, return itself. + // AoT + // tslint:disable-next-line + getButtonCallableProp(options: IModalButtonOptions, prop: string): {} { + const value = options[prop]; + const args: any[] = []; + if (this.contentComponentRef) { + args.push(this.contentComponentRef.instance); + } + + return typeof value === 'function' ? value.apply(options, args) : value; + } + + // On mcFooter's modal button click + // AoT + // tslint:disable-next-line + onButtonClick(button: IModalButtonOptions) { + // Call onClick directly + const result = this.getButtonCallableProp(button, 'onClick'); + if (isPromise(result)) { + button.loading = true; + (result as Promise<{}>).then(() => button.loading = false).catch(() => button.loading = false); + } + } + + // Change mcVisible from inside + private changeVisibleFromInside(visible: boolean, closeResult?: R): Promise { + if (this.mcVisible !== visible) { + // Change mcVisible value immediately + this.mcVisible = visible; + this.mcVisibleChange.emit(visible); + + return this.handleVisibleStateChange(visible, true, closeResult); + } + + return Promise.resolve(); + } + + private changeAnimationState(state: AnimationState) { + this.animationState = state; + if (state) { + this.maskAnimationClassMap = { + [`fade-${state}`]: true, + [`fade-${state}-active`]: true + }; + this.modalAnimationClassMap = { + [`zoom-${state}`]: true, + [`zoom-${state}-active`]: true + }; + } else { + // @ts-ignore + this.maskAnimationClassMap = this.modalAnimationClassMap = null; + } + + if (this.contentComponentRef) { + this.contentComponentRef.changeDetectorRef.markForCheck(); + } else { + this.changeDetector.markForCheck(); + } + } + + private animateTo(isVisible: boolean): Promise { + // Figure out the lastest click position when shows up + if (isVisible) { + // [NOTE] Using timeout due to the document.click event is fired later than visible change, + // so if not postponed to next event-loop, we can't get the lastest click position + window.setTimeout(() => this.updateTransformOrigin()); + } + + this.changeAnimationState(isVisible ? 'enter' : 'leave'); + + // Return when animation is over + return new Promise((resolve) => window.setTimeout(() => { + this.changeAnimationState(null); + resolve(); + }, MODAL_ANIMATE_DURATION)); + } + + private formatModalButtons(buttons: IModalButtonOptions[]): IModalButtonOptions[] { + return buttons.map((button) => { + + return { + ...{ + type: 'default', + size: 'default', + autoLoading: true, + show: true, + loading: false, + disabled: false + }, + ...button + }; + }); + } + + /** + * Create a component dynamically but not attach to any View + * (this action will be executed when bodyContainer is ready) + * @param component Component class + */ + private createDynamicComponent(component: Type) { + const factory = this.cfr.resolveComponentFactory(component); + const childInjector = Injector.create({ + providers: [{provide: McModalRef, useValue: this}], + parent: this.viewContainer.parentInjector + }); + + this.contentComponentRef = factory.create(childInjector); + + if (this.mcComponentParams) { + Object.assign(this.contentComponentRef.instance, this.mcComponentParams); + } + + // Do the first change detection immediately + // (or we do detection at ngAfterViewInit, multi-changes error will be thrown) + this.contentComponentRef.changeDetectorRef.detectChanges(); + + + } + + // Update transform-origin to the last click position on document + private updateTransformOrigin() { + const modalElement = this.modalContainer.nativeElement as HTMLElement; + const lastPosition = ModalUtil.getLastClickPosition(); + + if (lastPosition) { + // tslint:disable-next-line + this.transformOrigin = `${lastPosition.x - modalElement.offsetLeft}px ${lastPosition.y - modalElement.offsetTop}px 0px`; + } + } + + /** + * Take care of the body's overflow to decide the existense of scrollbar + * @param plusNum The number that the openModals.length will increase soon + */ + private changeBodyOverflow(plusNum: number = 0) { + const openModals = this.modalControl.openModals; + + if (openModals.length + plusNum > 0) { + // tslint:disable-next-line + this.renderer.setStyle(this.document.body, 'padding-right', `${this.mcMeasureScrollbarService.scrollBarWidth}px`); + this.renderer.setStyle(this.document.body, 'overflow', 'hidden'); + } else { + this.renderer.removeStyle(this.document.body, 'padding-right'); + this.renderer.removeStyle(this.document.body, 'overflow'); + } + } +} + +//////////// + +function isPromise(obj: {} | void): boolean { + // tslint:disable-next-line + return !!obj && (typeof obj === 'object' || typeof obj === 'function') && typeof (obj as Promise<{}>).then === 'function' && typeof (obj as Promise<{}>).catch === 'function'; +} diff --git a/src/lib/modal/modal.module.ts b/src/lib/modal/modal.module.ts new file mode 100644 index 000000000..12a8b1156 --- /dev/null +++ b/src/lib/modal/modal.module.ts @@ -0,0 +1,21 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { OverlayModule } from '@ptsecurity/cdk/overlay'; +import { McButtonModule } from '@ptsecurity/mosaic/button'; +import { McIconModule } from '@ptsecurity/mosaic/icon'; + +import { CssUnitPipe } from './css-unit.pipe'; +import { McModalControlService } from './modal-control.service'; +import { McModalComponent } from './modal.component'; +import { McModalService } from './modal.service'; + + +@NgModule({ + imports: [ CommonModule, OverlayModule, McButtonModule, McIconModule ], + exports: [ McModalComponent ], + declarations: [ McModalComponent, CssUnitPipe ], + entryComponents: [ McModalComponent ], + providers: [ McModalControlService, McModalService ] +}) +export class McModalModule { } diff --git a/src/lib/modal/modal.scss b/src/lib/modal/modal.scss new file mode 100644 index 000000000..3c510a023 --- /dev/null +++ b/src/lib/modal/modal.scss @@ -0,0 +1,112 @@ +@import '../core/styles/variables'; +@import './modal-animation'; +@import './modal-confirm'; + + +.mc-modal { + box-sizing: border-box; + list-style: none; + position: relative; + width: auto; + margin: 0 auto; + top: 48px; + padding: 0 0 24px 0; + + &-wrap { + position: fixed; + overflow: auto; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: $zindex-modal; + -webkit-overflow-scrolling: touch; + outline: 0; + } + + &-title { + margin: 0; + } + + &-content { + position: relative; + border-radius: 4px; + background-clip: padding-box; + background-color: white; + } + + &-close { + cursor: pointer; + border: 0; + background: transparent; + position: absolute; + right: 0; + top: 0; + z-index: 10; + outline: 0; + padding: 0; + + &-x { + display: block; + vertical-align: baseline; + text-align: center; + width: 56px; + height: 56px; + line-height: 56px; + } + } + + &-header { + padding: 14px 16px; + border-radius: 4px 4px 0 0; + + background: white; + } + + &-body { + padding: 16px 24px 24px 24px; + word-wrap: break-word; + + overflow-y: auto; + max-height: calc(100vh - 260px); + } + + &-footer { + padding: 16px 16px; + text-align: right; + border-radius: 0 0 4px 4px; + + button + button { + margin-left: 16px; + margin-bottom: 0; + } + } + + &.zoom-enter, + &.zoom-appear { + animation-duration: 0.3s; + // reset scale avoid mousePosition bug + transform: none; + opacity: 0; + } + + &-mask { + position: fixed; + top: 0; + right: 0; + left: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + height: 100%; + z-index: $zindex-modal-mask; + filter: alpha(opacity=50); + + &-hidden { + display: none; + } + } + + &-open { + overflow: hidden; + } +}; diff --git a/src/lib/modal/modal.service.ts b/src/lib/modal/modal.service.ts new file mode 100644 index 000000000..d7435b297 --- /dev/null +++ b/src/lib/modal/modal.service.ts @@ -0,0 +1,146 @@ +import { ComponentRef, Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +import { ESCAPE } from '@ptsecurity/cdk/keycodes'; +import { Overlay, OverlayRef } from '@ptsecurity/cdk/overlay'; +import { ComponentPortal } from '@ptsecurity/cdk/portal'; + +import { McModalControlService } from './modal-control.service'; +import { McModalRef } from './modal-ref.class'; +import { McModalComponent } from './modal.component'; +import { ConfirmType, IModalOptions, IModalOptionsForService } from './modal.type'; + + +// A builder used for managing service creating modals +export class ModalBuilderForService { + + // Modal ComponentRef, "null" means it has been destroyed + private modalRef: ComponentRef; + private overlayRef: OverlayRef; + + constructor(private overlay: Overlay, options: IModalOptionsForService = {}) { + this.createModal(); + + if (!('mcGetContainer' in options)) { + options.mcGetContainer = null; + } + + this.changeProps(options); + this.modalRef.instance.open(); + this.modalRef.instance.mcAfterClose.subscribe(() => this.destroyModal()); + + this.overlayRef.keydownEvents() + // @ts-ignore + .pipe(filter((event: KeyboardEvent) => { + return event.keyCode === ESCAPE && options.mcCloseByESC; + })) + .subscribe(() => this.modalRef.instance.close()); + } + + getInstance(): McModalComponent { + return this.modalRef && this.modalRef.instance; + } + + destroyModal(): void { + if (this.modalRef) { + this.overlayRef.dispose(); + // @ts-ignore + this.modalRef = null; + } + } + + private changeProps(options: IModalOptions): void { + if (this.modalRef) { + // here not limit user's inputs at runtime + Object.assign(this.modalRef.instance, options); + } + } + + // Create component to ApplicationRef + private createModal(): void { + this.overlayRef = this.overlay.create(); + this.modalRef = this.overlayRef.attach(new ComponentPortal(McModalComponent)); + } +} + +@Injectable() +export class McModalService { + // Track of the current close modals (we assume invisible is close this time) + get openModals(): McModalRef[] { + return this.modalControl.openModals; + } + + get afterAllClose(): Observable { + return this.modalControl.afterAllClose.asObservable(); + } + + constructor( + private overlay: Overlay, + private modalControl: McModalControlService) { + } + + // Closes all of the currently-open dialogs + closeAll(): void { + this.modalControl.closeAll(); + } + + create(options: IModalOptionsForService = {}): McModalRef { + + if (typeof options.mcOnCancel !== 'function') { + // Leave a empty function to close this modal by default + // tslint:disable-next-line + options.mcOnCancel = () => {}; + } + + if (!('mcCloseByESC' in options)) { + options.mcCloseByESC = true; + } + + + if (!('mcWidth' in options)) { + // tslint:disable-next-line + options.mcWidth = 480; + } + + return new ModalBuilderForService(this.overlay, options).getInstance(); + } + + confirm(options: IModalOptionsForService = {}, confirmType: ConfirmType = 'confirm'): McModalRef { + if ('mcFooter' in options) { + console.warn(`The Confirm-Modal doesn't support "mcFooter", this property will be ignored.`); + } + + // NOTE: only support function currently by calling confirm() + if (typeof options.mcOnOk !== 'function') { + // Leave a empty function to close this modal by default + // tslint:disable-next-line + options.mcOnOk = () => {}; + } + + options.mcModalType = 'confirm'; + options.mcClassName = `mc-confirm mc-confirm-${confirmType} ${options.mcClassName || ''}`; + options.mcMaskClosable = false; + + return this.create(options); + } + + success(options: IModalOptionsForService = {}): McModalRef { + return this.simpleConfirm(options, 'success'); + } + + delete(options: IModalOptionsForService = {}): McModalRef { + return this.simpleConfirm(options, 'warn'); + } + + private simpleConfirm(options: IModalOptionsForService = {}, confirmType: ConfirmType): McModalRef { + // Remove the Cancel button if the user not specify a Cancel button + if (!('mcCancelText' in options)) { + // @ts-ignore + options.mcCancelText = null; + } + + return this.confirm(options, confirmType); + } +} diff --git a/src/lib/modal/modal.spec.ts b/src/lib/modal/modal.spec.ts new file mode 100644 index 000000000..fec763677 --- /dev/null +++ b/src/lib/modal/modal.spec.ts @@ -0,0 +1,215 @@ +import { Component, DebugElement, ElementRef, EventEmitter, Input, NgModule } from '@angular/core'; +import { async, ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { OverlayContainer } from '@ptsecurity/cdk/overlay'; +import { McMeasureScrollbarService } from '@ptsecurity/mosaic/core'; + +import { CssUnitPipe } from './css-unit.pipe'; +import { McModalControlService } from './modal-control.service'; +import { McModalRef } from './modal-ref.class'; +import { McModalModule } from './modal.module'; +import { McModalService } from './modal.service'; + + +// tslint:disable:no-magic-numbers +// tslint:disable:max-line-length +// tslint:disable:no-console +// tslint:disable:no-unnecessary-class +describe('McModal', () => { + let modalService: McModalService; + let overlayContainer: OverlayContainer; + let overlayContainerElement: HTMLElement; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ ModalTestModule ], + providers: [ McMeasureScrollbarService ] + }); + + TestBed.compileComponents(); + })); + + beforeEach(inject([ McModalService, OverlayContainer ], + (ms: McModalService, oc: OverlayContainer) => { + modalService = ms; + overlayContainer = oc; + overlayContainerElement = oc.getContainerElement(); + })); + + afterEach(() => { + overlayContainer.ngOnDestroy(); + }); + + describe('created by service', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(ModalByServiceComponent); + }); + + afterEach(fakeAsync(() => { // wait all openModals tobe closed to clean up the ModalManager as it is globally static + modalService.closeAll(); + fixture.detectChanges(); + tick(1000); + })); + + it('should trigger both afterOpen/mcAfterOpen and have the correct openModals length', fakeAsync(() => { + const spy = jasmine.createSpy('afterOpen spy'); + const mcAfterOpen = new EventEmitter(); + const modalRef = modalService.create({ mcAfterOpen }); + + modalRef.afterOpen.subscribe(spy); + mcAfterOpen.subscribe(spy); + + fixture.detectChanges(); + expect(spy).not.toHaveBeenCalled(); + + tick(600); + expect(spy).toHaveBeenCalledTimes(2); + expect(modalService.openModals.indexOf(modalRef)).toBeGreaterThan(-1); + expect(modalService.openModals.length).toBe(1); + })); + + it('should trigger both afterClose/mcAfterClose and have the correct openModals length', fakeAsync(() => { + const spy = jasmine.createSpy('afterClose spy'); + const mcAfterClose = new EventEmitter(); + const modalRef = modalService.create({ mcAfterClose }); + + modalRef.afterClose.subscribe(spy); + mcAfterClose.subscribe(spy); + + fixture.detectChanges(); + tick(600); + modalRef.close(); + fixture.detectChanges(); + expect(spy).not.toHaveBeenCalled(); + + tick(600); + expect(spy).toHaveBeenCalledTimes(2); + expect(modalService.openModals.indexOf(modalRef)).toBe(-1); + expect(modalService.openModals.length).toBe(0); + })); + + it('should return/receive with/without result data', fakeAsync(() => { + const spy = jasmine.createSpy('afterClose without result spy'); + const modalRef = modalService.success(); + + modalRef.afterClose.subscribe(spy); + fixture.detectChanges(); + tick(600); + modalRef.destroy(); + expect(spy).not.toHaveBeenCalled(); + tick(600); + expect(spy).toHaveBeenCalledWith(undefined); + })); + + it('should return/receive with result data', fakeAsync(() => { + const result = { data: 'Fake Error' }; + const spy = jasmine.createSpy('afterClose with result spy'); + const modalRef = modalService.delete(); + + fixture.detectChanges(); + tick(600); + modalRef.destroy(result); + modalRef.afterClose.subscribe(spy); + expect(spy).not.toHaveBeenCalled(); + tick(600); + expect(spy).toHaveBeenCalledWith(result); + })); + + it('should close all opened modals (include non-service modals)', fakeAsync(() => { + const spy = jasmine.createSpy('afterAllClose spy'); + const modalMethods = [ 'create', 'delete', 'success' ]; + const uniqueId = (name: string) => `__${name}_ID_SUFFIX__`; + const queryOverlayElement = (name: string) => overlayContainerElement.querySelector(`.${uniqueId(name)}`) as HTMLElement; + + modalService.afterAllClose.subscribe(spy); + + fixture.componentInstance.nonServiceModalVisible = true; // Show non-service modal + modalMethods.forEach((method) => modalService[method]({ mcWrapClassName: uniqueId(method) })); // Service modals + + fixture.detectChanges(); + tick(600); + // Cover non-service modal for later checking + (modalMethods.concat('NON_SERVICE')).forEach((method) => { + expect(queryOverlayElement(method).style.display).not.toBe('none'); + }); + expect(modalService.openModals.length).toBe(4); + + modalService.closeAll(); + fixture.detectChanges(); + expect(spy).not.toHaveBeenCalled(); + tick(600); + expect(spy).toHaveBeenCalled(); + expect(modalService.openModals.length).toBe(0); + })); + + it('should modal not be registered twice', fakeAsync(() => { + const modalRef = modalService.create(); + + fixture.detectChanges(); + (modalService as any).modalControl.registerModal(modalRef); + tick(600); + expect(modalService.openModals.length).toBe(1); + })); + + it('should trigger nzOnOk/nzOnCancel', () => { + const spyOk = jasmine.createSpy('ok spy'); + const spyCancel = jasmine.createSpy('cancel spy'); + const modalRef: McModalRef = modalService.create({ + mcOnOk: spyOk, + mcOnCancel: spyCancel + }); + + fixture.detectChanges(); + + modalRef.triggerOk(); + expect(spyOk).toHaveBeenCalled(); + + modalRef.triggerCancel(); + expect(spyCancel).toHaveBeenCalled(); + }); + }); +}); + + +// ------------------------------------------- +// | Testing Components +// ------------------------------------------- + +@Component({ + template: `
` +}) +class TestCssUnitPipeComponent { } + + +@Component({ + selector: 'mc-modal-by-service', + template: ` + + `, + // Testing for service with parent service + providers: [ McModalControlService ] +}) +class ModalByServiceComponent { + nonServiceModalVisible = false; + + // @ts-ignore + constructor(modalControlService: McModalControlService) {} +} + + +const TEST_DIRECTIVES = [ + ModalByServiceComponent +]; + +@NgModule({ + imports: [ McModalModule ], + exports: TEST_DIRECTIVES, + declarations: TEST_DIRECTIVES, + entryComponents: [ + ModalByServiceComponent + ] +}) +class ModalTestModule { } diff --git a/src/lib/modal/modal.type.ts b/src/lib/modal/modal.type.ts new file mode 100644 index 000000000..db342d6d4 --- /dev/null +++ b/src/lib/modal/modal.type.ts @@ -0,0 +1,69 @@ +import { EventEmitter, TemplateRef, Type } from '@angular/core'; + +import { OverlayRef } from '@ptsecurity/cdk/overlay'; + + +export type OnClickCallback = ((instance: T) => (false | void | {}) | Promise); + +// Different modal styles we have supported +export type ModalType = 'default' | 'confirm'; + +// Subtypes of Confirm Modal +export type ConfirmType = 'confirm' | 'success' | 'warn'; + +// Public options for using by service +export interface IModalOptions { + mcModalType?: ModalType; + mcVisible?: boolean; + mcZIndex?: number; + mcWidth?: number | string; + mcWrapClassName?: string; + mcClassName?: string; + mcStyle?: object; + mcTitle?: string | TemplateRef<{}>; + mcContent?: string | TemplateRef<{}> | Type; + mcComponentParams?: object; + mcClosable?: boolean; + mcMask?: boolean; + mcMaskClosable?: boolean; + mcMaskStyle?: object; + mcBodyStyle?: object; + mcFooter?: string | TemplateRef<{}> | IModalButtonOptions[]; // Default Modal ONLY + mcGetContainer?: HTMLElement | OverlayRef | (() => HTMLElement | OverlayRef) | null; // STATIC + mcAfterOpen?: EventEmitter; + mcAfterClose?: EventEmitter; + mcCloseByESC?: boolean; + + // --- Predefined OK & Cancel buttons + mcOkText?: string; + mcOkType?: string; + mcOkLoading?: boolean; + mcOnOk?: EventEmitter | OnClickCallback; + mcCancelText?: string; + mcCancelLoading?: boolean; + mcOnCancel?: EventEmitter | OnClickCallback; +} + +// tslint:disable-next-line:no-any +export interface IModalOptionsForService extends IModalOptions { + mcOnOk?: OnClickCallback; + mcOnCancel?: OnClickCallback; +} + +export interface IModalButtonOptions { + label: string; + // tslint:disable-next-line + type?: string; + shape?: string; + ghost?: boolean; + size?: string; + // Default: true, indicate whether show loading automatically while onClick returned a Promise + autoLoading?: boolean; + + // [NOTE] "componentInstance" will refer to the component's instance when using Component + show?: boolean | ((this: IModalButtonOptions, contentComponentInstance?: T) => boolean); + loading?: boolean | ((this: IModalButtonOptions, contentComponentInstance?: T) => boolean); + disabled?: boolean | ((this: IModalButtonOptions, contentComponentInstance?: T) => boolean); + + onClick?(this: IModalButtonOptions, contentComponentInstance?: T): (void | {}) | Promise<(void | {})>; +} diff --git a/src/lib/modal/public-api.ts b/src/lib/modal/public-api.ts new file mode 100644 index 000000000..7ef27782b --- /dev/null +++ b/src/lib/modal/public-api.ts @@ -0,0 +1,5 @@ +export { McModalComponent } from './modal.component'; +export { McModalRef } from './modal-ref.class'; +export { McModalModule } from './modal.module'; +export { McModalService } from './modal.service'; +export * from './modal.type'; diff --git a/src/lib/modal/tsconfig.build.json b/src/lib/modal/tsconfig.build.json new file mode 100644 index 000000000..9179194ec --- /dev/null +++ b/src/lib/modal/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.build", + "files": [ + "public-api.ts" + ], + "angularCompilerOptions": { + "strictMetadataEmit": true, + "flatModuleOutFile": "index.js", + "flatModuleId": "@ptsecurity/mosaic/modal", + "skipTemplateCodegen": true, + "fullTemplateTypeCheck": true + } +} diff --git a/src/lib/public-api.ts b/src/lib/public-api.ts index b4770827d..617a323db 100644 --- a/src/lib/public-api.ts +++ b/src/lib/public-api.ts @@ -9,6 +9,7 @@ export * from '@ptsecurity/mosaic/icon'; export * from '@ptsecurity/mosaic/input'; export * from '@ptsecurity/mosaic/list'; export * from '@ptsecurity/mosaic/link'; +export * from '@ptsecurity/mosaic/modal'; export * from '@ptsecurity/mosaic/navbar'; export * from '@ptsecurity/mosaic/progress-bar'; export * from '@ptsecurity/mosaic/progress-spinner'; diff --git a/tests/karma-system-config.js b/tests/karma-system-config.js index 05da8a364..5555c4dd4 100644 --- a/tests/karma-system-config.js +++ b/tests/karma-system-config.js @@ -61,7 +61,8 @@ System.config({ '@ptsecurity/mosaic/checkbox': 'dist/packages/mosaic/checkbox/index.js', '@ptsecurity/mosaic/input': 'dist/packages/mosaic/input/index.js', '@ptsecurity/mosaic/form-field': 'dist/packages/mosaic/form-field/index.js', - '@ptsecurity/mosaic/tree': 'dist/packages/mosaic/tree/index.js' + '@ptsecurity/mosaic/tree': 'dist/packages/mosaic/tree/index.js', + '@ptsecurity/mosaic/modal': 'dist/packages/mosaic/modal/index.js' }, packages: { // Thirdparty barrels.