From 5c620f752a3130f534858fe2a1ddb6268f2b4907 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Sat, 21 Jul 2018 12:16:22 +0300 Subject: [PATCH 1/8] added modal --- .../services/measure-scrollbar.service.ts | 45 +++ src/lib/modal/README.md | 0 src/lib/modal/index.ts | 1 + src/lib/modal/modal-abstr-ref.class.ts | 43 +++ src/lib/modal/modal-control.service.ts | 78 +++++ src/lib/modal/modal.component.html | 0 src/lib/modal/modal.component.ts | 319 ++++++++++++++++++ src/lib/modal/modal.md | 13 + src/lib/modal/modal.module.ts | 20 ++ src/lib/modal/modal.service.ts | 139 ++++++++ src/lib/modal/modal.types.ts | 37 ++ src/lib/modal/public-api.ts | 2 + src/lib/modal/tsconfig.build.json | 14 + src/lib/modal/utils.ts | 25 ++ 14 files changed, 736 insertions(+) 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/index.ts create mode 100644 src/lib/modal/modal-abstr-ref.class.ts create mode 100644 src/lib/modal/modal-control.service.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.md create mode 100644 src/lib/modal/modal.module.ts create mode 100644 src/lib/modal/modal.service.ts create mode 100644 src/lib/modal/modal.types.ts create mode 100644 src/lib/modal/public-api.ts create mode 100644 src/lib/modal/tsconfig.build.json create mode 100644 src/lib/modal/utils.ts 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..8a845d079 --- /dev/null +++ b/src/lib/core/services/measure-scrollbar.service.ts @@ -0,0 +1,45 @@ +import { DOCUMENT } from '@angular/common'; +import { Inject, Injectable } from '@angular/core'; + + +@Injectable({ + providedIn: 'root' +}) +export class McMeasureScrollbarService { + private _scrollbarWidth: number; + private scrollbarMeasure = { + position: 'absolute', + top: '-9999px', + width: '50px', + height: '50px', + overflow: 'scroll' + }; + + get scrollBarWidth(): number { + if (this._scrollbarWidth) { + return this._scrollbarWidth; + } + this.initScrollBarWidth(); + + return this._scrollbarWidth; + } + + initScrollBarWidth(): void { + 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; + } + + // tslint:disable-next-line:no-any + constructor(@Inject(DOCUMENT) private document: any) { + this.initScrollBarWidth(); + } +} 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/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-abstr-ref.class.ts b/src/lib/modal/modal-abstr-ref.class.ts new file mode 100644 index 000000000..2129f8386 --- /dev/null +++ b/src/lib/modal/modal-abstr-ref.class.ts @@ -0,0 +1,43 @@ +import { Observable } from 'rxjs'; + +import { McModalComponent } from './modal.component'; + + +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 mcOnOk/mcOnCancel by manual + */ + abstract triggerOk(): void; + + abstract triggerCancel(): void; + + // /** + // * Return the ComponentRef of mcContent when specify mcContent 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 mcContent when specify mcContent 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-control.service.ts b/src/lib/modal/modal-control.service.ts new file mode 100644 index 000000000..61f90a606 --- /dev/null +++ b/src/lib/modal/modal-control.service.ts @@ -0,0 +1,78 @@ +import { Injectable, Optional, SkipSelf} from '@angular/core'; +import { Subject, Subscription } from 'rxjs'; + +import { McModalRef } from './modal-abstr-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(); + + private get registeredMetaMap(): Map { // Registered modal for later usage + 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 }); + } + } + + // TODO: allow deregister modals + // deregisterModal(modalRef: McModalRef): void {} + 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.component.html b/src/lib/modal/modal.component.html new file mode 100644 index 000000000..e69de29bb diff --git a/src/lib/modal/modal.component.ts b/src/lib/modal/modal.component.ts new file mode 100644 index 000000000..d62869f54 --- /dev/null +++ b/src/lib/modal/modal.component.ts @@ -0,0 +1,319 @@ +import { DOCUMENT } from '@angular/common'; +import { + AfterViewInit, + Component, ComponentFactoryResolver, ComponentRef, ElementRef, + EventEmitter, Inject, Injector, + Input, + OnChanges, + OnDestroy, + OnInit, Output, Renderer2, SimpleChanges, + TemplateRef, + Type, ViewChild, + ViewContainerRef +} from '@angular/core'; +import { Overlay, OverlayRef } from '@ptsecurity/cdk/overlay'; +import { Observable } from 'rxjs'; + +import { McMeasureScrollbarService } from '../core/services/measure-scrollbar.service'; + +import { McModalRef } from './modal-abstr-ref.class'; +import { McModalControlService } from './modal-control.service'; +import { IModalOptions, ModalType, OnClickCallback } from './modal.types'; +/* tslint:disable:import-name */ +import ModalUtil from './utils'; + + +export const MODAL_ANIMATE_DURATION = 200; + +type AnimationState = 'enter' | 'leave' | null; + +@Component({ + selector : 'mc-modal', + templateUrl: './modal.component.html' +}) +export class McModalComponent extends McModalRef implements OnInit, + OnChanges, AfterViewInit, OnDestroy, IModalOptions { + /* tslint:disable:member-ordering */ + + maskAnimationClassMap: object; + modalAnimationClassMap: object; + transformOrigin = '0px 0px 0px'; // The origin point that animation based on + + @Input() mcOkText: string; + + get okText(): string { + return this.mcOkText; + } + + @Input() mcZIndex: number = 1000; + @Input() mcWidth: number | string = 520; + @Input() mcTitle: string | TemplateRef<{}>; + + @Input() mcOkType = 'primary'; + @Input() mcOkLoading: boolean = 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; + // Trigger when modal open(visible) after animations + @Output() mcAfterOpen = new EventEmitter(); + @Output() mcAfterClose = new EventEmitter(); + get afterOpen(): Observable { // Observable alias for mcAfterOpen + return this.mcAfterOpen.asObservable(); + } + + get afterClose(): Observable { // Observable alias for mcAfterClose + return this.mcAfterClose.asObservable(); + } + + @Input() mcCancelLoading: boolean = false; + @Input() @Output() mcOnCancel: EventEmitter | OnClickCallback = new EventEmitter(); + @ViewChild('modalContainer') modalContainer: ElementRef; + @ViewChild('bodyContainer', { read: ViewContainerRef }) bodyContainer: ViewContainerRef; + + get cancelText(): string { + return this.mcCancelText; + } + + get hidden(): boolean { + return !this.mcVisible && !this.animationState; + } // Indicate whether this dialog should hidden + + @Input() mcModalType: ModalType = 'default'; + @Input() mcContent: string | TemplateRef<{}> | Type; + @Input() mcComponentParams: object; + @Input() mcGetContainer: HTMLElement | OverlayRef | (() => HTMLElement | OverlayRef) = () => this.overlay.create(); + + @Input() mcVisible: boolean = false; + @Output() mcVisibleChange = new EventEmitter(); + + private contentComponentRef: ComponentRef; // Handle the reference when using mcContent as Component + private animationState: AnimationState; // Current animation state + private container: HTMLElement | OverlayRef; + + constructor( + private overlay: Overlay, + private elementRef: ElementRef, + private viewContainer: ViewContainerRef, + private cfr: ComponentFactoryResolver, + private modalControl: McModalControlService, + private renderer: Renderer2, + private mcMeasureScrollbarService: McMeasureScrollbarService, + @Inject(DOCUMENT) private document: any + ) { + super(); + } + + ngOnInit(): void { + + if (this.isComponent(this.mcContent)) { + this.createDynamicComponent(this.mcContent as Type); // Create component along without View + } + + // 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); + } + + this.modalControl.registerModal(this); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.nzVisible) { + // Do not trigger animation while initializing + this.handleVisibleStateChange(this.mcVisible, !changes.nzVisible.firstChange); + } + } + + ngAfterViewInit(): void { + // 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(): void { + if (this.container instanceof OverlayRef) { + this.container.dispose(); + } + } + + open(): void { + this.changeVisibleFromInside(true); + } + + close(result?: R): void { + this.changeVisibleFromInside(false, result); + } + + destroy(result?: R): void { // Destroy equals Close + this.close(result); + } + + triggerOk(): void { + this.onClickOkCancel('ok'); + } + + triggerCancel(): void { + 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; + } + + private onClickOkCancel(_type: 'ok' | 'cancel'): void { + /* tslint:disable:object-literal-key-quotes */ + 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); + } + } + } + + 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 createDynamicComponent(component: Type): void { + 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(): void { + const modalElement = this.modalContainer.nativeElement as HTMLElement; + const lastPosition = ModalUtil.getLastClickPosition(); + + if (lastPosition) { + /* tslint:disable:max-line-length */ + this.transformOrigin = `${lastPosition.x - modalElement.offsetLeft}px ${lastPosition.y - modalElement.offsetTop}px 0px`; + } + } + + private changeAnimationState(state: AnimationState): void { + 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; + } + } + + private animateTo(isVisible: boolean): Promise { + // Figure out the latest 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 new Promise((resolve) => window.setTimeout(() => { // Return when animation is over + this.changeAnimationState(null); + resolve(); + }, MODAL_ANIMATE_DURATION)); + } + + // Do rest things when visible state changed + private handleVisibleStateChange(visible: boolean, animation: boolean = true, closeResult?: R): Promise { + if (visible) { // Hide scrollbar at the first time when shown up + this.changeBodyOverflow(1); + } + + return Promise + .resolve(animation && this.animateTo(visible)) + .then(() => { // Emit open/close event after animations over + if (visible) { + this.mcAfterOpen.emit(); + } else { + this.mcAfterClose.emit(closeResult); + this.changeBodyOverflow(); // Show/hide scrollbar when animation is over + } + }); + } + + private changeBodyOverflow(plusNum: number = 0): void { + const openModals = this.modalControl.openModals; + + if (openModals.length + plusNum > 0) { + 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'); + } + } + + private isComponent(value: {}): boolean { + return value instanceof Type; + } +} + +function isPromise(obj: {} | void): boolean { + 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.md b/src/lib/modal/modal.md new file mode 100644 index 000000000..0859df146 --- /dev/null +++ b/src/lib/modal/modal.md @@ -0,0 +1,13 @@ +Mosaic buttons are native ` +

+ + ` +}) +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..1f7f067f5 --- /dev/null +++ b/src/lib-dev/modal/template.html @@ -0,0 +1,57 @@ +

As component (not use in prod)

+ + + +

Content one

+

Content two

+

Content three

+
+ +

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 index 8a845d079..b63c63e14 100644 --- a/src/lib/core/services/measure-scrollbar.service.ts +++ b/src/lib/core/services/measure-scrollbar.service.ts @@ -6,6 +6,16 @@ import { Inject, Injectable } from '@angular/core'; 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', @@ -15,16 +25,13 @@ export class McMeasureScrollbarService { overflow: 'scroll' }; - get scrollBarWidth(): number { - if (this._scrollbarWidth) { - return this._scrollbarWidth; - } + constructor( + @Inject(DOCUMENT) private document: any + ) { this.initScrollBarWidth(); - - return this._scrollbarWidth; } - initScrollBarWidth(): void { + initScrollBarWidth() { const scrollDiv = this.document.createElement('div'); for (const scrollProp in this.scrollbarMeasure) { @@ -32,14 +39,12 @@ export class McMeasureScrollbarService { 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; } - - // tslint:disable-next-line:no-any - constructor(@Inject(DOCUMENT) private document: any) { - this.initScrollBarWidth(); - } } 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/_modal-animation.scss b/src/lib/modal/_modal-animation.scss new file mode 100644 index 000000000..2c89e0158 --- /dev/null +++ b/src/lib/modal/_modal-animation.scss @@ -0,0 +1,114 @@ +@import '../core/styles/functions/timing'; + + +$animation-duration-base: 0.2s; + +@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: linear; + } + + .#{className}-leave { + animation-timing-function: linear; + } +} + +@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, 0); + } +} + +@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..c5f632df0 --- /dev/null +++ b/src/lib/modal/_modal-confirm.scss @@ -0,0 +1,46 @@ + +.mc-confirm { + .mc-modal-header { + display: none; + } + + .mc-modal-close { + display: none; + } + + .mc-modal-body { + padding: 16px 24px 24px 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..386195d63 --- /dev/null +++ b/src/lib/modal/_modal-theme.scss @@ -0,0 +1,62 @@ +@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); + } + + &-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-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/modal-control.service.ts b/src/lib/modal/modal-control.service.ts index 61f90a606..652d4a201 100644 --- a/src/lib/modal/modal-control.service.ts +++ b/src/lib/modal/modal-control.service.ts @@ -1,7 +1,7 @@ -import { Injectable, Optional, SkipSelf} from '@angular/core'; +import { Injectable, Optional, SkipSelf } from '@angular/core'; import { Subject, Subscription } from 'rxjs'; -import { McModalRef } from './modal-abstr-ref.class'; +import { McModalRef } from './modal-ref.class'; interface IRegisteredMeta { @@ -27,11 +27,11 @@ export class McModalControlService { 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(); - private get registeredMetaMap(): Map { // Registered modal for later usage + // Registered modal for later usage + private get registeredMetaMap(): Map { return this.parentService ? this.parentService.registeredMetaMap : this.rootRegisteredMetaMap; } @@ -45,12 +45,10 @@ export class McModalControlService { const afterOpenSubscription = modalRef.afterOpen.subscribe(() => this.openModals.push(modalRef)); const afterCloseSubscription = modalRef.afterClose.subscribe(() => this.removeOpenModal(modalRef)); - this.registeredMetaMap.set(modalRef, { modalRef, afterOpenSubscription, afterCloseSubscription }); + this.registeredMetaMap.set(modalRef, {modalRef, afterOpenSubscription, afterCloseSubscription}); } } - // TODO: allow deregister modals - // deregisterModal(modalRef: McModalRef): void {} hasRegistered(modalRef: McModalRef): boolean { return this.registeredMetaMap.has(modalRef); } @@ -60,7 +58,7 @@ export class McModalControlService { let i = this.openModals.length; while (i--) { - this.openModals[ i ].close(); + this.openModals[i].close(); } } diff --git a/src/lib/modal/modal-abstr-ref.class.ts b/src/lib/modal/modal-ref.class.ts similarity index 64% rename from src/lib/modal/modal-abstr-ref.class.ts rename to src/lib/modal/modal-ref.class.ts index 2129f8386..bd422a668 100644 --- a/src/lib/modal/modal-abstr-ref.class.ts +++ b/src/lib/modal/modal-ref.class.ts @@ -3,6 +3,10 @@ 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; @@ -14,20 +18,23 @@ export abstract class McModalRef { abstract destroy(result?: R): void; /** - * Trigger the mcOnOk/mcOnCancel by manual + * Trigger the nzOnOk/nzOnCancel by manual */ abstract triggerOk(): void; abstract triggerCancel(): void; // /** - // * Return the ComponentRef of mcContent when specify mcContent as a Component - // * Note: this method may return undefined if the Component has not ready yet. (it only available after Modal's ngOnInit) + // * 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 mcContent when specify mcContent as a Component - * Note: this method may return undefined if the Component has not ready yet. (it only available after Modal's ngOnInit) + * 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; diff --git a/src/lib/modal/utils.ts b/src/lib/modal/modal-util.ts similarity index 80% rename from src/lib/modal/utils.ts rename to src/lib/modal/modal-util.ts index cbabb5b28..b3643b1bf 100644 --- a/src/lib/modal/utils.ts +++ b/src/lib/modal/modal-util.ts @@ -4,10 +4,10 @@ export interface IClickPosition { } export class ModalUtil { - private lastPosition: IClickPosition; constructor(private document: Document) { + this.lastPosition = {x: -1, y: -1}; this.listenDocumentClick(); } @@ -17,7 +17,7 @@ export class ModalUtil { listenDocumentClick(): void { this.document.addEventListener('click', (event: MouseEvent) => { - this.lastPosition = { x: event.clientX, y: event.clientY }; + this.lastPosition = {x: event.clientX, y: event.clientY}; }); } } diff --git a/src/lib/modal/modal.component.html b/src/lib/modal/modal.component.html index e69de29bb..532bc9906 100644 --- a/src/lib/modal/modal.component.html +++ 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 index d62869f54..2a52eeb9e 100644 --- a/src/lib/modal/modal.component.ts +++ b/src/lib/modal/modal.component.ts @@ -1,114 +1,184 @@ import { DOCUMENT } from '@angular/common'; import { - AfterViewInit, - Component, ComponentFactoryResolver, ComponentRef, ElementRef, - EventEmitter, Inject, Injector, + AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, + Component, + ComponentFactoryResolver, + ComponentRef, + ElementRef, + EventEmitter, + Inject, + Injector, Input, OnChanges, OnDestroy, - OnInit, Output, Renderer2, SimpleChanges, + OnInit, + Output, + Renderer2, + SimpleChanges, TemplateRef, - Type, ViewChild, - ViewContainerRef + 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 { McMeasureScrollbarService } from '../core/services/measure-scrollbar.service'; - -import { McModalRef } from './modal-abstr-ref.class'; import { McModalControlService } from './modal-control.service'; -import { IModalOptions, ModalType, OnClickCallback } from './modal.types'; -/* tslint:disable:import-name */ -import ModalUtil from './utils'; +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' + 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:member-ordering */ +export class McModalComponent extends McModalRef + implements OnInit, OnChanges, AfterViewInit, OnDestroy, IModalOptions { - maskAnimationClassMap: object; - modalAnimationClassMap: object; - transformOrigin = '0px 0px 0px'; // The origin point that animation based on + // 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() mcOkText: string; + @Input() + get mcVisible() { return this._mcVisible; } + set mcVisible(value) { this._mcVisible = value; } + _mcVisible = false; - get okText(): string { - return this.mcOkText; - } + @Output() mcVisibleChange = new EventEmitter(); @Input() mcZIndex: number = 1000; - @Input() mcWidth: number | string = 520; + @Input() mcWidth: number | string = 480; + @Input() mcWrapClassName: string; + @Input() mcClassName: string; + @Input() mcStyle: object; @Input() mcTitle: string | TemplateRef<{}>; + @Input() mcCloseByESC: boolean = true; - @Input() mcOkType = 'primary'; - @Input() mcOkLoading: boolean = 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() + 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; - @Input() mcCancelText: string; // Trigger when modal open(visible) after animations @Output() mcAfterOpen = new EventEmitter(); + // Trigger when modal leave-animation over @Output() mcAfterClose = new EventEmitter(); - get afterOpen(): Observable { // Observable alias for mcAfterOpen + + // --- 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(); } - get afterClose(): Observable { // Observable alias for mcAfterClose + // Observable alias for mcAfterClose + get afterClose(): Observable { return this.mcAfterClose.asObservable(); } - @Input() mcCancelLoading: boolean = false; - @Input() @Output() mcOnCancel: EventEmitter | OnClickCallback = new EventEmitter(); - @ViewChild('modalContainer') modalContainer: ElementRef; - @ViewChild('bodyContainer', { read: ViewContainerRef }) bodyContainer: ViewContainerRef; + 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; - } // Indicate whether this dialog should hidden - - @Input() mcModalType: ModalType = 'default'; - @Input() mcContent: string | TemplateRef<{}> | Type; - @Input() mcComponentParams: object; - @Input() mcGetContainer: HTMLElement | OverlayRef | (() => HTMLElement | OverlayRef) = () => this.overlay.create(); - - @Input() mcVisible: boolean = false; - @Output() mcVisibleChange = new EventEmitter(); + } - private contentComponentRef: ComponentRef; // Handle the reference when using mcContent as Component - private animationState: AnimationState; // Current animation state + // 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 cfr: ComponentFactoryResolver, - private modalControl: McModalControlService, - private renderer: Renderer2, private mcMeasureScrollbarService: McMeasureScrollbarService, - @Inject(DOCUMENT) private document: any - ) { + private modalControl: McModalControlService, + private changeDetector: ChangeDetectorRef, + @Inject(DOCUMENT) private document: any) { + super(); } - ngOnInit(): void { + @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); // Create component along without View + 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 @@ -120,17 +190,23 @@ export class McModalComponent extends McModalRef impleme this.container.overlayElement.appendChild(this.elementRef.nativeElement); } + // Register modal when afterOpen/afterClose is stable this.modalControl.registerModal(this); } - ngOnChanges(changes: SimpleChanges): void { - if (changes.nzVisible) { + // [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.nzVisible.firstChange); + this.handleVisibleStateChange(this.mcVisible, !changes.mcVisible.firstChange); } } - ngAfterViewInit(): void { + ngAfterViewInit() { // If using Component, it is the time to attach View while bodyContainer is ready if (this.contentComponentRef) { this.bodyContainer.insert(this.contentComponentRef.hostView); @@ -141,29 +217,30 @@ export class McModalComponent extends McModalRef impleme } } - ngOnDestroy(): void { + ngOnDestroy() { if (this.container instanceof OverlayRef) { this.container.dispose(); } } - open(): void { + open() { this.changeVisibleFromInside(true); } - close(result?: R): void { + close(result?: R) { this.changeVisibleFromInside(false, result); } - destroy(result?: R): void { // Destroy equals Close + // Destroy equals Close + destroy(result?: R) { this.close(result); } - triggerOk(): void { + triggerOk() { this.onClickOkCancel('ok'); } - triggerCancel(): void { + triggerCancel() { this.onClickOkCancel('cancel'); } @@ -183,21 +260,56 @@ export class McModalComponent extends McModalRef impleme return this.elementRef && this.elementRef.nativeElement; } - private onClickOkCancel(_type: 'ok' | 'cancel'): void { - /* tslint:disable:object-literal-key-quotes */ - const trigger = { 'ok': this.mcOnOk, 'cancel': this.mcOnCancel }[ _type ]; - const loadingKey = { 'ok': 'mcOkLoading', 'cancel': 'mcCancelLoading' }[ _type ]; + 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()); + 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; + this[loadingKey] = true; const handleThen = (doClose) => { - this[ loadingKey ] = false; + this[loadingKey] = false; caseClose(doClose); }; (result as Promise).then(handleThen).catch(handleThen); @@ -207,64 +319,110 @@ export class McModalComponent extends McModalRef impleme } } - private changeVisibleFromInside(visible: boolean, closeResult?: R): Promise { - if (this.mcVisible !== visible) { - // Change mcVisible value immediately - this.mcVisible = visible; - this.mcVisibleChange.emit(visible); + // AoT + isNonEmptyString(value: {}): boolean { + return typeof value === 'string' && value !== ''; + } - return this.handleVisibleStateChange(visible, true, closeResult); + // 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(); + 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(); + } + }); } - private createDynamicComponent(component: Type): void { - 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); + // 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); } - // Do the first change detection immediately - // (or we do detection at ngAfterViewInit, multi-changes error will be thrown) - this.contentComponentRef.changeDetectorRef.detectChanges(); + return typeof value === 'function' ? value.apply(options, args) : value; } - // Update transform-origin to the last click position on document - private updateTransformOrigin(): void { - const modalElement = this.modalContainer.nativeElement as HTMLElement; - const lastPosition = ModalUtil.getLastClickPosition(); + // 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); + } + } - if (lastPosition) { - /* tslint:disable:max-line-length */ - this.transformOrigin = `${lastPosition.x - modalElement.offsetLeft}px ${lastPosition.y - modalElement.offsetTop}px 0px`; + // 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): void { + private changeAnimationState(state: AnimationState) { this.animationState = state; if (state) { this.maskAnimationClassMap = { - [ `fade-${state}` ] : true, - [ `fade-${state}-active` ]: true + [`fade-${state}`]: true, + [`fade-${state}-active`]: true }; this.modalAnimationClassMap = { - [ `zoom-${state}` ] : true, - [ `zoom-${state}-active` ]: true + [`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 latest click position when shows up + 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 @@ -273,34 +431,75 @@ export class McModalComponent extends McModalRef impleme this.changeAnimationState(isVisible ? 'enter' : 'leave'); - return new Promise((resolve) => window.setTimeout(() => { // Return when animation is over + // Return when animation is over + return new Promise((resolve) => window.setTimeout(() => { this.changeAnimationState(null); resolve(); }, MODAL_ANIMATE_DURATION)); } - // Do rest things when visible state changed - private handleVisibleStateChange(visible: boolean, animation: boolean = true, closeResult?: R): Promise { - if (visible) { // Hide scrollbar at the first time when shown up - this.changeBodyOverflow(1); + 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); } - return Promise - .resolve(animation && this.animateTo(visible)) - .then(() => { // Emit open/close event after animations over - if (visible) { - this.mcAfterOpen.emit(); - } else { - this.mcAfterClose.emit(closeResult); - this.changeBodyOverflow(); // Show/hide scrollbar when animation is over - } - }); + // Do the first change detection immediately + // (or we do detection at ngAfterViewInit, multi-changes error will be thrown) + this.contentComponentRef.changeDetectorRef.detectChanges(); + + } - private changeBodyOverflow(plusNum: number = 0): void { + // 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 { @@ -308,12 +507,11 @@ export class McModalComponent extends McModalRef impleme this.renderer.removeStyle(this.document.body, 'overflow'); } } - - private isComponent(value: {}): boolean { - return value instanceof Type; - } } +//////////// + 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.md b/src/lib/modal/modal.md deleted file mode 100644 index 0859df146..000000000 --- a/src/lib/modal/modal.md +++ /dev/null @@ -1,13 +0,0 @@ -Mosaic buttons are native `
As component (not use in prod)

Content three

-

Confirm

+

Confirm

- + -

Modal from service

+

Modal from service

@@ -35,19 +35,19 @@

Modal from service

-

Modal with custom component

+

Modal with custom component

-

Modal with looong component

+

Modal with looong component

-

Many many modals

+

Many many modals

Date: Fri, 10 Aug 2018 16:08:35 +0300 Subject: [PATCH 4/8] chore: wip position --- src/lib/modal/_modal-confirm.scss | 3 +++ src/lib/modal/modal.scss | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/modal/_modal-confirm.scss b/src/lib/modal/_modal-confirm.scss index 71d4c46d3..943b94af2 100644 --- a/src/lib/modal/_modal-confirm.scss +++ b/src/lib/modal/_modal-confirm.scss @@ -1,5 +1,8 @@ .mc-confirm { + + top: 40% !important; + .mc-modal-header { display: none; } diff --git a/src/lib/modal/modal.scss b/src/lib/modal/modal.scss index 8c37d8b5b..73731a3dd 100644 --- a/src/lib/modal/modal.scss +++ b/src/lib/modal/modal.scss @@ -9,7 +9,7 @@ position: relative; width: auto; margin: 0 auto; - top: 100px; + top: 48px; padding: 0 0 24px 0; &-wrap { @@ -71,7 +71,7 @@ word-wrap: break-word; overflow-y: auto; - max-height: calc(100vh - 350px); + max-height: calc(100vh - 260px); } &-footer { From 97d59e63f5dd7d70de10067646b3b8452bc12e45 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Wed, 22 Aug 2018 18:51:25 +0300 Subject: [PATCH 5/8] chore: guide fixes --- src/lib/modal/_modal-confirm.scss | 2 -- src/lib/modal/_modal-theme.scss | 6 ++++++ src/lib/modal/modal.scss | 3 --- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/lib/modal/_modal-confirm.scss b/src/lib/modal/_modal-confirm.scss index 943b94af2..6a2d7deb8 100644 --- a/src/lib/modal/_modal-confirm.scss +++ b/src/lib/modal/_modal-confirm.scss @@ -1,8 +1,6 @@ .mc-confirm { - top: 40% !important; - .mc-modal-header { display: none; } diff --git a/src/lib/modal/_modal-theme.scss b/src/lib/modal/_modal-theme.scss index 86cb1820b..4d964c2f7 100644 --- a/src/lib/modal/_modal-theme.scss +++ b/src/lib/modal/_modal-theme.scss @@ -20,6 +20,12 @@ 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); } diff --git a/src/lib/modal/modal.scss b/src/lib/modal/modal.scss index 73731a3dd..3c510a023 100644 --- a/src/lib/modal/modal.scss +++ b/src/lib/modal/modal.scss @@ -30,12 +30,9 @@ &-content { position: relative; - border: 0; border-radius: 4px; background-clip: padding-box; - background-color: white; - box-shadow: 0 6px 12px 0 rgba(0, 0, 0, 0.5); } &-close { From 21479e557d68a3001784faf2b820bbbca591a7e0 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Fri, 31 Aug 2018 14:39:51 +0300 Subject: [PATCH 6/8] chore: animation & rm exmaple --- src/lib-dev/modal/module.ts | 14 -------------- src/lib-dev/modal/template.html | 13 ------------- src/lib/modal/_modal-animation.scss | 4 ++-- 3 files changed, 2 insertions(+), 29 deletions(-) diff --git a/src/lib-dev/modal/module.ts b/src/lib-dev/modal/module.ts index 094d7470d..4069bd7bc 100644 --- a/src/lib-dev/modal/module.ts +++ b/src/lib-dev/modal/module.ts @@ -116,20 +116,6 @@ export class ModalDemoComponent { destroyTplModal() { this.tplModal.destroy(); } - - showModal() { - this.isVisible = true; - } - - handleOk() { - console.log('Button ok clicked!'); - this.isVisible = false; - } - - handleCancel() { - console.log('Button cancel clicked!'); - this.isVisible = false; - } } diff --git a/src/lib-dev/modal/template.html b/src/lib-dev/modal/template.html index e83f146d8..3b6d8bcfe 100644 --- a/src/lib-dev/modal/template.html +++ b/src/lib-dev/modal/template.html @@ -1,16 +1,3 @@ -

As component (not use in prod)

- - - -

Content one

-

Content two

-

Content three

-
-

Confirm

diff --git a/src/lib/modal/_modal-animation.scss b/src/lib/modal/_modal-animation.scss index 2c89e0158..e975a0939 100644 --- a/src/lib/modal/_modal-animation.scss +++ b/src/lib/modal/_modal-animation.scss @@ -1,7 +1,7 @@ @import '../core/styles/functions/timing'; -$animation-duration-base: 0.2s; +$animation-duration-base: 0.3s; @mixin _motion-common($duration: $animation-duration-base) { animation-duration: $duration; @@ -106,7 +106,7 @@ $animation-duration-base: 0.2s; 100% { opacity: 0; - transform: translate(0, 0); + transform: translate(0, -30%); } } From 877f08856c09bc774f94981ada607a94d0c47e58 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Fri, 31 Aug 2018 14:44:15 +0300 Subject: [PATCH 7/8] chore: added ease-out --- src/lib/modal/_modal-animation.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/modal/_modal-animation.scss b/src/lib/modal/_modal-animation.scss index e975a0939..2afc39009 100644 --- a/src/lib/modal/_modal-animation.scss +++ b/src/lib/modal/_modal-animation.scss @@ -59,11 +59,11 @@ $animation-duration-base: 0.3s; .#{className}-enter, .#{className}-appear { opacity: 0; - animation-timing-function: linear; + animation-timing-function: ease-out; } .#{className}-leave { - animation-timing-function: linear; + animation-timing-function: ease-out; } } From 793cee4765d2719ccaa225052acc12593c86ac5d Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Mon, 3 Sep 2018 15:11:39 +0300 Subject: [PATCH 8/8] fix: Esc animation --- src/lib/modal/modal.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/modal/modal.service.ts b/src/lib/modal/modal.service.ts index cf09cb656..d7435b297 100644 --- a/src/lib/modal/modal.service.ts +++ b/src/lib/modal/modal.service.ts @@ -36,7 +36,7 @@ export class ModalBuilderForService { .pipe(filter((event: KeyboardEvent) => { return event.keyCode === ESCAPE && options.mcCloseByESC; })) - .subscribe(() => this.destroyModal()); + .subscribe(() => this.modalRef.instance.close()); } getInstance(): McModalComponent {