UNPKG

@looorent/ngx-simple-modal

Version:

A simple unopinionated framework to implement simple modal based behaviour in angular (v2+) projects.

764 lines (752 loc) 29.5 kB
import * as i1 from '@angular/common'; import { CommonModule } from '@angular/common'; import * as i0 from '@angular/core'; import { InjectionToken, Injector, ViewContainerRef, ElementRef, Component, ViewChild, EventEmitter, Directive, Input, Output, Inject, Injectable, Optional, ComponentFactoryResolver, ApplicationRef, NgModule, HostListener } from '@angular/core'; import { Subject, fromEvent, of, BehaviorSubject, Observable } from 'rxjs'; import { filter, map, switchMap, takeUntil } from 'rxjs/operators'; const DefaultSimpleModalOptionConfig = new InjectionToken('default-simple-modal.config'); // eslint-disable-line const defaultSimpleModalOptions = { closeOnEscape: false, closeOnClickOutside: false, bodyClass: 'modal-open', wrapperDefaultClasses: 'modal fade-anim', wrapperClass: 'in', draggableClass: 'draggable', animationDuration: 300, autoFocus: false, draggable: false, }; /** * The modal backdrop wrapping wrapper to the modal */ class SimpleModalWrapperComponent { resolver; /** * Target viewContainer to insert modal content component */ viewContainer; /** * Link wrapper DOM element */ wrapper; /** * Wrapper modal and fade classes */ modalClasses = 'modal fade-anim'; /** * Dialog content componet * @type {SimpleModalComponent} */ content; /** * Click outside callback */ clickOutsideCallback; /** * Constructor * @param {ComponentFactoryResolver} resolver */ constructor(resolver) { this.resolver = resolver; } /** * Adds content modal component to wrapper * @param {Type<SimpleModalComponent>} component * @return {SimpleModalComponent} */ addComponent(component) { const factory = this.resolver.resolveComponentFactory(component); const injector = Injector.create({ providers: [], parent: this.viewContainer.injector }); const componentRef = factory.create(injector); this.viewContainer.insert(componentRef.hostView); this.content = componentRef.instance; this.content.wrapper = this.wrapper; return { ref: componentRef, component: this.content }; } /** * Configures the function to call when you click on background of a modal but not the contents * @param callback */ onClickOutsideModalContent(callback) { const containerEl = this.wrapper.nativeElement; this.clickOutsideCallback = (event) => { if (event.target === containerEl) { callback(); } }; containerEl.addEventListener('click', this.clickOutsideCallback, false); } ngOnDestroy() { if (this.clickOutsideCallback) { const containerEl = this.wrapper.nativeElement; containerEl.removeEventListener('click', this.clickOutsideCallback, false); this.clickOutsideCallback = null; } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.0.3", ngImport: i0, type: SimpleModalWrapperComponent, deps: [{ token: i0.ComponentFactoryResolver }], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "16.0.3", type: SimpleModalWrapperComponent, selector: "simple-modal-wrapper", viewQueries: [{ propertyName: "viewContainer", first: true, predicate: ["viewContainer"], descendants: true, read: ViewContainerRef, static: true }, { propertyName: "wrapper", first: true, predicate: ["wrapper"], descendants: true, read: ElementRef, static: true }], ngImport: i0, template: ` <div #wrapper [ngClass]="modalClasses" [ngStyle]="{ display: 'block' }" role="dialog"> <ng-template #viewContainer></ng-template> </div> `, isInline: true, dependencies: [{ kind: "directive", type: i1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.0.3", ngImport: i0, type: SimpleModalWrapperComponent, decorators: [{ type: Component, args: [{ selector: 'simple-modal-wrapper', template: ` <div #wrapper [ngClass]="modalClasses" [ngStyle]="{ display: 'block' }" role="dialog"> <ng-template #viewContainer></ng-template> </div> `, }] }], ctorParameters: function () { return [{ type: i0.ComponentFactoryResolver }]; }, propDecorators: { viewContainer: [{ type: ViewChild, args: ['viewContainer', { read: ViewContainerRef, static: true }] }], wrapper: [{ type: ViewChild, args: ['wrapper', { read: ElementRef, static: true }] }] } }); class DraggableDirective { host; zone; renderer; dragHandle; dragTarget; dragEnabled = false; set dialogDragOffset(offset) { this.reset(offset); } dragged = new EventEmitter(); /** Element to be dragged */ target; /** Drag handle */ handle; delta = { x: 0, y: 0 }; offset = { x: 0, y: 0 }; enabled = true; destroy$ = new Subject(); constructor(host, zone, renderer) { this.host = host; this.zone = zone; this.renderer = renderer; } ngAfterViewInit() { if (!this.enabled) { return; } this.init(); } ngOnChanges() { if (!this.enabled && this.dragEnabled && this.dragTarget) { this.enabled = true; /** determine if the component has been init by the handle variable */ if (this.handle) { this.renderer.setStyle(this.handle, 'cursor', 'move'); } else if (this.enabled) { this.init(); } } if (!this.dragEnabled) { this.enabled = false; if (this.handle) { this.renderer.setStyle(this.handle, 'cursor', ''); } } } ngOnDestroy() { this.destroy$.next(); } reset(offset) { const defaultValues = { x: 0, y: 0 }; this.offset = { ...defaultValues, ...offset }; this.delta = { ...defaultValues }; this.translate(); } setupEvents() { this.zone.runOutsideAngular(() => { const mousedown$ = fromEvent(this.handle, 'mousedown'); const mousemove$ = fromEvent(document, 'mousemove'); const mouseup$ = fromEvent(document, 'mouseup'); const mousedrag$ = mousedown$.pipe(filter(() => this.enabled), map(event => ({ startX: event.clientX, startY: event.clientY })), switchMap(({ startX, startY }) => mousemove$.pipe(map(event => { event.preventDefault(); this.delta = { x: event.clientX - startX, y: event.clientY - startY }; }), takeUntil(mouseup$))), takeUntil(this.destroy$)); mousedrag$.subscribe(() => { if (this.delta.x === 0 && this.delta.y === 0) { return; } this.translate(); }); mouseup$ .pipe(filter(() => this.enabled), /** Only emit change if the element has moved */ filter(() => this.delta.x !== 0 || this.delta.y !== 0), takeUntil(this.destroy$)) .subscribe(() => { this.offset.x += this.delta.x; this.offset.y += this.delta.y; this.dragged.emit(this.offset); this.delta = { x: 0, y: 0 }; }); }); } translate() { if (this.target) { this.zone.runOutsideAngular(() => { requestAnimationFrame(() => { const transform = `translate(${this.offset.x + this.delta.x}px, ${this.offset.y + this.delta.y}px)`; this.renderer.setStyle(this.target, 'transform', transform); }); }); } } /** * Init the directive */ init() { if (!this.dragTarget) { throw new Error('You need to specify the drag target'); } this.handle = this.dragHandle instanceof Element ? this.dragHandle : typeof this.dragHandle === 'string' && this.dragHandle ? document.querySelector(this.dragHandle) : this.host.nativeElement; /** add the move cursor */ if (this.handle && this.enabled) { this.renderer.addClass(this.handle, 'handle'); } this.target = this.dragTarget instanceof HTMLElement ? this.dragTarget : document.querySelector(this.dragTarget); this.setupEvents(); this.translate(); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.0.3", ngImport: i0, type: DraggableDirective, deps: [{ token: i0.ElementRef }, { token: i0.NgZone }, { token: i0.Renderer2 }], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "16.0.3", type: DraggableDirective, selector: "[dialogDraggable]", inputs: { dragHandle: "dragHandle", dragTarget: "dragTarget", dragEnabled: "dragEnabled", dialogDragOffset: "dialogDragOffset" }, outputs: { dragged: "dragged" }, usesOnChanges: true, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.0.3", ngImport: i0, type: DraggableDirective, decorators: [{ type: Directive, args: [{ selector: '[dialogDraggable]' }] }], ctorParameters: function () { return [{ type: i0.ElementRef }, { type: i0.NgZone }, { type: i0.Renderer2 }]; }, propDecorators: { dragHandle: [{ type: Input }], dragTarget: [{ type: Input }], dragEnabled: [{ type: Input }], dialogDragOffset: [{ type: Input }], dragged: [{ type: Output }] } }); /** * View container manager which manages a list of modals currently active * inside the viewvontainer */ class SimpleModalHolderComponent { resolver; renderer; ngZone; defaultSimpleModalOptions; /** * Target viewContainer to insert modals */ viewContainer; /** * modal collection, maintained by addModal and removeModal * @type {Array<SimpleModalComponent> } */ modals = []; /** * if auto focus is on and no element focused, store it here to be restored back after close */ previousActiveElement = null; /** * Constructor * @param {ComponentFactoryResolver} resolver * @param renderer * @param ngZone * @param defaultSimpleModalOptions */ constructor(resolver, renderer, ngZone, defaultSimpleModalOptions) { this.resolver = resolver; this.renderer = renderer; this.ngZone = ngZone; this.defaultSimpleModalOptions = defaultSimpleModalOptions; } /** * Configures then adds modal to the modals array, and populates with data passed in * @param {Type<SimpleModalComponent>} component * @param {object?} data * @param {SimpleModalOptionsOverrides?} options * @return {Observable<*>} */ addModal(component, data, options) { // create component if (!this.viewContainer) { return of(null); } const factory = this.resolver.resolveComponentFactory(SimpleModalWrapperComponent); const componentRef = this.viewContainer.createComponent(factory); const modalWrapper = (componentRef.instance); const { ref: _componentRef, component: _component } = modalWrapper.addComponent(component); // assign options refs _component.options = options = Object.assign({}, this.defaultSimpleModalOptions, options); // set base classes for wrapper modalWrapper.modalClasses = options.wrapperDefaultClasses; // add to stack this.modals.push(_component); // wait a tick then setup the following while adding a modal this.wait().then(() => { this.toggleWrapperClass(modalWrapper.wrapper, options.wrapperClass); this.toggleBodyClass(options.bodyClass); if (options.draggable) { this.setDraggable(_componentRef, options); } this.wait(options.animationDuration).then(() => { this.autoFocusFirstElement(_component.wrapper, options.autoFocus); _component.markAsReady(); }); }); // when closing modal remove it _component.onClosing(modal => this.removeModal(modal)); // if clicking on background closes modal this.configureCloseOnClickOutside(modalWrapper); // map and return observable _component.mapDataObject(data); return _component.setupObserver(); } /** * triggers components close function * to take effect * @returns {Promise<void>} * @param closingModal */ removeModal(closingModal) { const options = closingModal.options; this.toggleWrapperClass(closingModal.wrapper, options.wrapperClass); return this.wait(options.animationDuration).then(() => { this.removeModalFromArray(closingModal); this.toggleBodyClass(options.bodyClass); this.restorePreviousFocus(); }); } /** * Instructs all open modals to */ removeAllModals() { return Promise.all(this.modals.map(modal => this.removeModal(modal))); } /** * Bind a body class 'modal-open' to a condition of modals in pool > 0 * @param bodyClass - string to add and remove from body in document */ toggleBodyClass(bodyClass) { if (!bodyClass) { return; } const body = document.getElementsByTagName('body')[0]; const bodyClassItems = bodyClass.split(' '); if (!this.modals.length) { body.classList.remove(...bodyClassItems); } else { body.classList.add(...bodyClassItems); } } /** * if the option to close on background click is set, then hook up a callback * @param modalWrapper */ configureCloseOnClickOutside(modalWrapper) { modalWrapper.onClickOutsideModalContent(() => { if (modalWrapper.content.options.closeOnClickOutside) { modalWrapper.content.close(); } }); } /** * Auto focus o the first element if autofocus is on * @param componentWrapper * @param autoFocus */ autoFocusFirstElement(componentWrapper, autoFocus) { if (autoFocus) { const focusable = componentWrapper.nativeElement.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); if (focusable && focusable.length) { this.previousActiveElement = document.activeElement; focusable[0].focus(); } } } /** * Restores the last focus is there was one */ restorePreviousFocus() { if (this.previousActiveElement) { this.previousActiveElement.focus(); this.previousActiveElement = null; } } /** * Configure the adding and removal of a wrapper class - predominantly animation focused * @param modalWrapperEl * @param wrapperClass */ toggleWrapperClass(modalWrapperEl, wrapperClass) { const wrapperClassList = modalWrapperEl.nativeElement.classList; const wrapperClassItems = wrapperClass.split(' '); if (wrapperClassList.toString().indexOf(wrapperClass) !== -1) { wrapperClassList.remove(...wrapperClassItems); } else { wrapperClassList.add(...wrapperClassItems); } } /** * Enables the drag option on the modal if the options have it enabled * @param component * @param options * @private */ setDraggable(component, options) { const draggableDirective = new DraggableDirective(component.location, this.ngZone, this.renderer); draggableDirective.dragTarget = component.location.nativeElement; draggableDirective.dragHandle = component.instance.handle ? component.instance.handle.nativeElement : undefined; draggableDirective.ngAfterViewInit(); component.location.nativeElement.classList.add(options.draggableClass); } /** * Helper function for a more readable timeout * @param ms */ wait(ms = 0) { return new Promise((resolve, reject) => { setTimeout(() => resolve(undefined), ms); }); } /** * Instructs the holder to remove the modal and * removes this component from the collection * @param {SimpleModalComponent} component */ removeModalFromArray(component) { const index = this.modals.indexOf(component); if (index > -1) { this.viewContainer.remove(index); this.modals.splice(index, 1); } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.0.3", ngImport: i0, type: SimpleModalHolderComponent, deps: [{ token: i0.ComponentFactoryResolver }, { token: i0.Renderer2 }, { token: i0.NgZone }, { token: DefaultSimpleModalOptionConfig }], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "16.0.3", type: SimpleModalHolderComponent, selector: "simple-modal-holder", viewQueries: [{ propertyName: "viewContainer", first: true, predicate: ["viewContainer"], descendants: true, read: ViewContainerRef, static: true }], ngImport: i0, template: '<ng-template #viewContainer></ng-template>', isInline: true }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.0.3", ngImport: i0, type: SimpleModalHolderComponent, decorators: [{ type: Component, args: [{ selector: 'simple-modal-holder', template: '<ng-template #viewContainer></ng-template>', }] }], ctorParameters: function () { return [{ type: i0.ComponentFactoryResolver }, { type: i0.Renderer2 }, { type: i0.NgZone }, { type: undefined, decorators: [{ type: Inject, args: [DefaultSimpleModalOptionConfig] }] }]; }, propDecorators: { viewContainer: [{ type: ViewChild, args: ['viewContainer', { read: ViewContainerRef, static: true }] }] } }); class SimpleModalServiceConfig { container = null; } class SimpleModalService { resolver; applicationRef; injector; /** * Placeholder of modals * @type {SimpleModalHolderComponent} */ modalHolderComponent; /** * HTML container for modals * type {HTMLElement | string} */ _container; /** * @param {ComponentFactoryResolver} resolver * @param {ApplicationRef} applicationRef * @param {Injector} injector * @param {SimpleModalServiceConfig} config */ constructor(resolver, applicationRef, injector, config) { this.resolver = resolver; this.applicationRef = applicationRef; this.injector = injector; if (config) { this.container = config.container; } } /** * Adds modal * @param {Type<SimpleModalComponent<T, T1>>} component * @param {T?} data * @param {SimpleModalOptionsOverrides?} options * @return {Observable<T1>} */ addModal(component, data, options) { if (!this.modalHolderComponent) { this.modalHolderComponent = this.createSimpleModalHolder(); } return this.modalHolderComponent.addModal(component, data, options); } /** * Hides and removes modal from DOM, resolves promise when fully removed * @param {SimpleModalComponent} component * @return {Promise<{}>} */ removeModal(component) { if (!this.modalHolderComponent) { return Promise.resolve({}); } return this.modalHolderComponent.removeModal(component); } /** * Closes all modals, resolves promise when they're fully removed * @return {Promise<{}>} */ removeAll() { if (!this.modalHolderComponent) { return Promise.resolve({}); } return this.modalHolderComponent.removeAllModals(); } /** * Accessor for contain - will auto generate from string * if needed or default to the root element if nothing was set */ set container(c) { this._container = c; } get container() { if (typeof this._container === 'string') { this._container = document.getElementById(this._container); } if (!this._container && this.applicationRef['components'].length) { const componentRootViewContainer = this.applicationRef['components'][0]; this.container = componentRootViewContainer.hostView .rootNodes[0]; } // fallback if (!this._container || typeof this._container === 'string') { this._container = document.getElementsByTagName('body')[0]; } return this._container; } /** * Creates and add to DOM modal holder component * @return {SimpleModalHolderComponent} */ createSimpleModalHolder() { const componentFactory = this.resolver.resolveComponentFactory(SimpleModalHolderComponent); const componentRef = componentFactory.create(this.injector); const componentRootNode = componentRef.hostView .rootNodes[0]; this.applicationRef.attachView(componentRef.hostView); componentRef.onDestroy(() => { this.applicationRef.detachView(componentRef.hostView); }); this.container.appendChild(componentRootNode); return componentRef.instance; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.0.3", ngImport: i0, type: SimpleModalService, deps: [{ token: i0.ComponentFactoryResolver }, { token: i0.ApplicationRef }, { token: i0.Injector }, { token: SimpleModalServiceConfig, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "16.0.3", ngImport: i0, type: SimpleModalService }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.0.3", ngImport: i0, type: SimpleModalService, decorators: [{ type: Injectable }], ctorParameters: function () { return [{ type: i0.ComponentFactoryResolver }, { type: i0.ApplicationRef }, { type: i0.Injector }, { type: SimpleModalServiceConfig, decorators: [{ type: Optional }] }]; } }); /** * Modal service factory. Creates modal service with options * @param { ComponentFactoryResolver } resolver * @param { ApplicationRef } applicationRef * @param { Injector } injector * @param { SimpleModalServiceConfig } options * @return { SimpleModalService } */ function SimpleModalServiceFactory(resolver, applicationRef, injector, options) { return new SimpleModalService(resolver, applicationRef, injector, options); } class SimpleModalModule { static forRoot(config, defaultModalOptions) { return { ngModule: SimpleModalModule, providers: [ { provide: SimpleModalServiceConfig, useValue: config }, { provide: SimpleModalService, useFactory: SimpleModalServiceFactory, deps: [ComponentFactoryResolver, ApplicationRef, Injector, SimpleModalServiceConfig], }, { provide: DefaultSimpleModalOptionConfig, useValue: defaultModalOptions || defaultSimpleModalOptions, } ], }; } constructor() { } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.0.3", ngImport: i0, type: SimpleModalModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "16.0.3", ngImport: i0, type: SimpleModalModule, declarations: [SimpleModalHolderComponent, SimpleModalWrapperComponent, DraggableDirective], imports: [CommonModule] }); static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "16.0.3", ngImport: i0, type: SimpleModalModule, providers: [ SimpleModalService, { provide: DefaultSimpleModalOptionConfig, useValue: defaultSimpleModalOptions, }, ], imports: [CommonModule] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.0.3", ngImport: i0, type: SimpleModalModule, decorators: [{ type: NgModule, args: [{ declarations: [SimpleModalHolderComponent, SimpleModalWrapperComponent, DraggableDirective], providers: [ SimpleModalService, { provide: DefaultSimpleModalOptionConfig, useValue: defaultSimpleModalOptions, }, ], imports: [CommonModule] }] }], ctorParameters: function () { return []; } }); class SimpleModalComponent { /** * Observer to return result from modal */ observer; /** * Drag handle */ handle; /** * Dialog result * @type {T1} */ result; /** * Dialog wrapper (modal placeholder) */ wrapper; /** * ref of options for this component */ options; /** * ready$ is when all animations and focusing have comleted */ _ready$ = new BehaviorSubject(false); /** * Callback to the holders close function */ closerCallback = () => Promise.resolve(); /** * Constructor */ constructor() { } /** * Maps your object passed in the creation to fields in your own Dialog classes * @param {T} data */ mapDataObject(data) { data = data || {}; const keys = Object.keys(data); for (let i = 0, length = keys.length; i < length; i++) { const key = keys[i]; if (data[key] && this[key] && typeof data[key] === 'object' && typeof this[key] === 'object') { Object.assign(this[key], data[key]); } else { this[key] = data[key]; } } } /** * Setup observer * @return {Observable<T1>} */ setupObserver() { return Observable.create(observer => { this.observer = observer; // called if observable is unsubscribed to return () => { this.close(); }; }); } /** * Defines what happens when close is called - default this * will just call the default remove modal process. If overridden * must include * @param callback */ onClosing(callback) { this.closerCallback = callback; } /** * Closes modal */ close() { return this.closerCallback(this).then(v => { if (this.observer) { this.observer.next(this.result); this.observer.complete(); } return v; }); } /** * keypress binding ngx way * @param evt */ onKeydownHandler(evt) { if (this.options && this.options.closeOnEscape) { this.close(); } } get ready$() { return this._ready$.asObservable(); } markAsReady() { this._ready$.next(true); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.0.3", ngImport: i0, type: SimpleModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "16.0.3", type: SimpleModalComponent, selector: "ng-component", host: { listeners: { "document:keydown.escape": "onKeydownHandler($event)" } }, ngImport: i0, template: '', isInline: true }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.0.3", ngImport: i0, type: SimpleModalComponent, decorators: [{ type: Component, args: [{ template: '' }] }], ctorParameters: function () { return []; }, propDecorators: { onKeydownHandler: [{ type: HostListener, args: ['document:keydown.escape', ['$event']] }] } }); /** * Generated bundle index. Do not edit. */ export { DefaultSimpleModalOptionConfig, DraggableDirective, SimpleModalComponent, SimpleModalModule, SimpleModalService, SimpleModalServiceConfig, defaultSimpleModalOptions }; //# sourceMappingURL=looorent-ngx-simple-modal.mjs.map