import angular, { IScope } from 'angular' import _ from 'lodash' import { Inject, Injectable } from '../decorators' interface Modal { element: JQuery id: string scope: angular.IScope deferred: ng.IDeferred } interface ModalScope extends IScope { $ctrl?: ng.IController } @Injectable('mflyModalService') export default class MflyModalService { currentModal: Modal | undefined constructor( @Inject('$compile') private $compile: ng.ICompileService, @Inject('$document') private $document: ng.IDocumentService, @Inject('$q') private $q: ng.IQService, @Inject('$rootScope') private $rootScope: ng.IRootScopeService, @Inject('$timeout') private $timeout: ng.ITimeoutService, ) {} /** * Closes the current modal "successfully", resolving the open promise. * * @param data Optional data to pass with the resolving promise. */ close(data = {}) { if (this.currentModal) { this.currentModal.deferred.resolve(data) this.currentModal.element.remove() this.currentModal.scope.$destroy() this.currentModal = undefined this.cleanupDOM() } } /** * Closes the current modal "unsuccessfully" (i.e., user clicked Cancel). */ dismiss() { if (this.currentModal) { this.currentModal.deferred.reject() this.currentModal.element.remove() this.currentModal.scope.$destroy() this.currentModal = undefined this.cleanupDOM() } } /** * Compiles and opens a modal component. * * @param modalId * The name of the modal component to open. * @param bindings * An object containing data that will be one-way bound into the component. * @returns * A promise which will be resolved or rejected when `close` or `dismiss` are * called from inside the modal component. */ open(modalId: string, bindings: {} = {}): ng.IPromise { if (this.currentModal) { return this.currentModal.deferred.promise.finally(() => this.open(modalId, bindings)) } // Build the tag for the modal component, compile it and attach it to the DOM. const modalTag = this.buildComponentTag(modalId, bindings) const modalElement = angular.element(modalTag) const scope: ModalScope = this.$rootScope.$new(true) scope.$ctrl = bindings // $compile does not throw an error if the modalElement does not line up with // a legitimate component tag. It just compiles it anyway... const compiledElement = this.$compile(modalElement)(scope) // ...so we confirm the compilation worked by inspecting the resulting DOM // element for the presence of the mfly:modal nodes we expect. It needs to // be in a timeout to allow Angular enough time to have compiled the element. this.$timeout(() => { if (compiledElement.find(`#${modalId}`).length === 0) { this.cleanupDOM() throw new Error(`The modal with id "${modalId}" didn't compile correctly. Make sure a component with that modal-id exists.`) } }) this.$document.find('body') .addClass('c-modal__document-body-no-scroll') .append(compiledElement) const ariaElements = [ this.$document.find('nav'), this.$document.find('header'), this.$document.find('main'), this.$document.find('footer'), ] ariaElements.forEach(el => { if (el) { el.attr('aria-hidden', 'true') } }) // Build a modal object for tracking, with attached promise and element references. const deferred = this.$q.defer() this.currentModal = { element: this.$document.find(_.kebabCase(modalId)), id: modalId, deferred, scope, } return deferred.promise } /** * Handles cleanup of all DOM changes that were done _outside_ the modal, such * as the no-scroll body class and the various aria attributes. */ cleanupDOM() { this.$document.find('body').removeClass('c-modal__document-body-no-scroll') const ariaElements = [ this.$document.find('nav'), this.$document.find('header'), this.$document.find('main'), this.$document.find('footer'), ] ariaElements.forEach(el => { if (el) { el.attr('aria-hidden', 'false') } }) } /** * Builds a tag string representing an Angular component. * * @param tagname * The name of the component. It will be converted to kebab case. * @param bindings * An object representing data that will be one-way bound into the component. */ private buildComponentTag(tagName: string, bindings: {} = {}): string { const attributes = _.map(bindings, (_value, key) => { return `${_.kebabCase(key)}="$ctrl.${_.camelCase(key)}"` }) let attributesString = '' if (Object.getOwnPropertyNames(bindings).length > 0) { attributesString = ` ${attributes.join(' ')}` // note leading space } return `<${_.kebabCase(tagName)}${attributesString}>` } }