src/modal/modal.js
'use strict';
angular.module('mgcrea.ngStrap.modal', ['mgcrea.ngStrap.core', 'mgcrea.ngStrap.helpers.dimensions'])
.provider('$modal', function () {
var defaults = this.defaults = {
animation: 'am-fade',
backdropAnimation: 'am-fade',
customClass: '',
prefixClass: 'modal',
prefixEvent: 'modal',
placement: 'top',
templateUrl: 'modal/modal.tpl.html',
template: '',
contentTemplate: false,
container: false,
element: null,
backdrop: true,
keyboard: true,
html: false,
show: true,
size: null,
zIndex: null
};
this.$get = function ($window, $rootScope, $bsCompiler, $animate, $timeout, $sce, dimensions) {
var forEach = angular.forEach;
var requestAnimationFrame = $window.requestAnimationFrame || $window.setTimeout;
var bodyElement = angular.element($window.document.body);
var backdropCount = 0;
var dialogBaseZindex = 1050;
var backdropBaseZindex = 1040;
var validSizes = {
lg: 'modal-lg',
sm: 'modal-sm'
};
function ModalFactory (config) {
var $modal = {};
// Common vars
var options = $modal.$options = angular.extend({}, defaults, config);
var promise = $modal.$promise = $bsCompiler.compile(options);
var scope = $modal.$scope = options.scope && options.scope.$new() || $rootScope.$new();
if (!options.element && !options.container) {
options.container = 'body';
}
if (options.zIndex) {
dialogBaseZindex = parseInt(options.zIndex, 10);
backdropBaseZindex = dialogBaseZindex - 10;
}
// Store $id to identify the triggering element in events
// give priority to options.id, otherwise, try to use
// element id if defined
$modal.$id = options.id || options.element && options.element.attr('id') || '';
$modal.returnFocus = function () {
};
// Support scope as string options
forEach(['title', 'content'], function (key) {
if (options[key]) scope[key] = $sce.trustAsHtml(options[key]);
});
// Provide scope helpers
scope.$hide = function () {
scope.$$postDigest(function () {
$modal.hide();
});
};
scope.$show = function () {
scope.$$postDigest(function () {
$modal.show();
});
};
scope.$toggle = function () {
scope.$$postDigest(function () {
$modal.toggle();
});
};
// Publish isShown as a protected var on scope
$modal.$isShown = scope.$isShown = false;
// Fetch, compile then initialize modal
var compileData;
var modalElement;
var modalScope;
var backdropElement = angular.element('<div class="' + options.prefixClass + '-backdrop"/>');
backdropElement.css({position: 'fixed', top: '0px', left: '0px', bottom: '0px', right: '0px'});
promise.then(function (data) {
compileData = data;
$modal.init();
});
$modal.init = function () {
// Options: show
if (options.show) {
scope.$$postDigest(function () {
$modal.show();
});
}
};
$modal.destroy = function () {
// Remove element
destroyModalElement();
// remove backdrop element
if (backdropElement) {
backdropElement.remove();
backdropElement = null;
}
// Destroy scope
scope.$destroy();
};
$modal.show = function () {
if ($modal.$isShown) return;
var parent;
var after;
if (angular.isElement(options.container)) {
parent = options.container;
after = options.container[0].lastChild ? angular.element(options.container[0].lastChild) : null;
} else {
if (options.container) {
parent = findElement(options.container);
after = parent[0] && parent[0].lastChild ? angular.element(parent[0].lastChild) : null;
} else {
parent = null;
after = options.element;
}
}
// destroy any existing modal elements
if (modalElement) destroyModalElement();
// create a new scope, so we can destroy it and all child scopes
// when destroying the modal element
modalScope = $modal.$scope.$new();
// Fetch a cloned element linked from template (noop callback is required)
modalElement = $modal.$element = compileData.link(modalScope, function (clonedElement, scope) {});
if (options.backdrop) {
// set z-index
modalElement.css({'z-index': dialogBaseZindex + (backdropCount * 20)});
backdropElement.css({'z-index': backdropBaseZindex + (backdropCount * 20)});
// increment number of backdrops
backdropCount++;
}
if (scope.$emit(options.prefixEvent + '.show.before', $modal).defaultPrevented) {
return;
}
if (angular.isDefined(options.onBeforeShow) && angular.isFunction(options.onBeforeShow)) {
options.onBeforeShow($modal);
}
// Set the initial positioning.
modalElement.css({display: 'block'}).addClass(options.placement);
// Options: customClass
if (options.customClass) {
modalElement.addClass(options.customClass);
}
// Options: size
if (options.size && validSizes[options.size]) {
angular.element(findElement('.modal-dialog', modalElement[0])).addClass(validSizes[options.size]);
}
// Options: animation
if (options.animation) {
if (options.backdrop) {
backdropElement.addClass(options.backdropAnimation);
}
modalElement.addClass(options.animation);
}
if (options.backdrop) {
$animate.enter(backdropElement, bodyElement, null);
}
// Support v1.2+ $animate
// https://github.com/angular/angular.js/issues/11713
if (angular.version.minor <= 2) {
$animate.enter(modalElement, parent, after, enterAnimateCallback);
} else {
$animate.enter(modalElement, parent, after).then(enterAnimateCallback);
}
$modal.$isShown = scope.$isShown = true;
safeDigest(scope);
// Focus once the enter-animation has started
// Weird PhantomJS bug hack
var el = modalElement[0];
requestAnimationFrame(function () {
el.focus();
});
bodyElement.addClass(options.prefixClass + '-open');
// Add assistive attributes to the body to prevent the screen reader from reading it with the virtual keys
// Only do this if the backdrop option is set.
if (options.backdrop) {
bodyElement.attr('aria-hidden', 'true');
}
if (options.animation) {
bodyElement.addClass(options.prefixClass + '-with-' + options.animation);
}
// Bind events
bindBackdropEvents();
bindKeyboardEvents();
$modal.focus();
};
function enterAnimateCallback () {
scope.$emit(options.prefixEvent + '.show', $modal);
if (angular.isDefined(options.onShow) && angular.isFunction(options.onShow)) {
options.onShow($modal);
}
modalElement.attr('aria-hidden', 'false');
modalElement[0].focus();
}
$modal.hide = function () {
if (!$modal.$isShown) return;
if (scope.$emit(options.prefixEvent + '.hide.before', $modal).defaultPrevented) {
return;
}
if (angular.isDefined(options.onBeforeHide) && angular.isFunction(options.onBeforeHide)) {
options.onBeforeHide($modal);
}
modalElement.attr('aria-hidden', 'true');
if ($modal.returnFocus && typeof $modal.returnFocus === 'function') $modal.returnFocus();
// Support v1.2+ $animate
// https://github.com/angular/angular.js/issues/11713
if (angular.version.minor <= 2) {
$animate.leave(modalElement, leaveAnimateCallback);
} else {
$animate.leave(modalElement).then(leaveAnimateCallback);
}
if (options.backdrop) {
// decrement number of backdrops
backdropCount--;
$animate.leave(backdropElement);
}
$modal.$isShown = scope.$isShown = false;
safeDigest(scope);
// Unbind events
unbindBackdropEvents();
unbindKeyboardEvents();
};
function leaveAnimateCallback () {
scope.$emit(options.prefixEvent + '.hide', $modal);
if (angular.isDefined(options.onHide) && angular.isFunction(options.onHide)) {
options.onHide($modal);
}
if (findElement('.modal').length <= 0) {
bodyElement.removeClass(options.prefixClass + '-open');
if (options.backdrop) {
bodyElement.attr('aria-hidden', 'false');
}
}
if (options.animation) {
bodyElement.removeClass(options.prefixClass + '-with-' + options.animation);
}
}
function findFocusableElements () {
// Add all elements we want to include in our selection
var focusableElements = 'a:not([disabled]), button:not([disabled]), input[type=text]:not([disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])';
if (document.activeElement) {
var focusable = Array.prototype.filter.call(modalElement[0].querySelectorAll(focusableElements),
function (element) {
// Check for visibility while always include the current activeElement
return element.offsetWidth > 0 || element.offsetHeight > 0 || element === document.activeElement;
});
return focusable;
}
}
function findNextFocusableElement (inReverse) {
if (document.activeElement) {
var focusable = findFocusableElements();
if (focusable === undefined) return;
if (inReverse) {
focusable = Array.prototype.reverse.call(focusable);
}
var index = focusable.indexOf(document.activeElement);
return focusable[index + 1];
}
}
$modal.toggle = function () {
if ($modal.$isShown) {
$modal.hide();
} else {
$modal.show();
}
};
$modal.focus = function () {
modalElement[0].focus();
};
// Protected methods
$modal.$onKeyUp = function (evt) {
// Escape was pressed on an open modal. Hide it.
if (evt.which === 27 && $modal.$isShown) {
$modal.hide();
evt.stopPropagation();
}
};
$modal.$onKeyDown = function (evt) {
if (options.keyboard) {
if (evt.keyCode === 9) {
var nextFocusable = findNextFocusableElement(evt.shiftKey);
if (nextFocusable === undefined) {
if (evt.preventDefault) evt.preventDefault();
if (evt.stopPropagation) evt.stopPropagation();
var focusable = findFocusableElements();
if (evt.shiftKey) {
focusable[focusable.length - 1].focus();
} else {
focusable[0].focus();
}
}
}
}
};
function bindBackdropEvents () {
if (options.backdrop) {
modalElement.on('click', hideOnBackdropClick);
backdropElement.on('click', hideOnBackdropClick);
backdropElement.on('wheel', preventEventDefault);
}
}
function unbindBackdropEvents () {
if (options.backdrop) {
modalElement.off('click', hideOnBackdropClick);
backdropElement.off('click', hideOnBackdropClick);
backdropElement.off('wheel', preventEventDefault);
}
}
function bindKeyboardEvents () {
if (options.keyboard) {
modalElement.on('keyup', $modal.$onKeyUp);
modalElement.on('keydown', $modal.$onKeyDown);
}
}
function unbindKeyboardEvents () {
if (options.keyboard) {
modalElement.off('keyup', $modal.$onKeyUp);
modalElement.off('keydown', $modal.$onKeyDown);
}
}
// Private helpers
function hideOnBackdropClick (evt) {
if (evt.target !== evt.currentTarget) return;
if (options.backdrop === 'static') {
$modal.focus();
} else {
$modal.hide();
}
}
function preventEventDefault (evt) {
evt.preventDefault();
}
function destroyModalElement () {
if ($modal.$isShown && modalElement !== null) {
// un-bind events
unbindBackdropEvents();
unbindKeyboardEvents();
}
if (modalScope) {
modalScope.$destroy();
modalScope = null;
}
if (modalElement) {
modalElement.remove();
modalElement = $modal.$element = null;
}
}
return $modal;
}
// Helper functions
function safeDigest (scope) {
/* eslint-disable no-unused-expressions */
scope.$$phase || (scope.$root && scope.$root.$$phase) || scope.$digest();
/* eslint-enable no-unused-expressions */
}
function findElement (query, element) {
return angular.element((element || document).querySelectorAll(query));
}
return ModalFactory;
};
})
.directive('bsModal', function ($window, $sce, $parse, $modal) {
return {
restrict: 'EAC',
scope: true,
link: function postLink (scope, element, attr, transclusion) {
// Directive options
var options = {scope: scope, element: element, show: false};
angular.forEach(['template', 'templateUrl', 'controller', 'controllerAs', 'contentTemplate', 'placement', 'backdrop', 'keyboard', 'html', 'container', 'animation', 'backdropAnimation', 'id', 'prefixEvent', 'prefixClass', 'customClass', 'modalClass', 'size', 'zIndex'], function (key) {
if (angular.isDefined(attr[key])) options[key] = attr[key];
});
// Options: alias modalClass to customClass
if (options.modalClass) {
options.customClass = options.modalClass;
}
// use string regex match boolean attr falsy values, leave truthy values be
var falseValueRegExp = /^(false|0|)$/i;
angular.forEach(['backdrop', 'keyboard', 'html', 'container'], function (key) {
if (angular.isDefined(attr[key]) && falseValueRegExp.test(attr[key])) options[key] = false;
});
// bind functions from the attrs to the show and hide events
angular.forEach(['onBeforeShow', 'onShow', 'onBeforeHide', 'onHide'], function (key) {
var bsKey = 'bs' + key.charAt(0).toUpperCase() + key.slice(1);
if (angular.isDefined(attr[bsKey])) {
options[key] = scope.$eval(attr[bsKey]);
}
});
// Support scope as data-attrs
angular.forEach(['title', 'content'], function (key) {
if (attr[key]) {
attr.$observe(key, function (newValue, oldValue) {
scope[key] = $sce.trustAsHtml(newValue);
});
}
});
// Support scope as an object
if (attr.bsModal) {
scope.$watch(attr.bsModal, function (newValue, oldValue) {
if (angular.isObject(newValue)) {
angular.extend(scope, newValue);
} else {
scope.content = newValue;
}
}, true);
}
// Initialize modal
var modal = $modal(options);
if (options.keyboard) {
modal.returnFocus = function () {
element[0].focus();
};
}
// Trigger
element.on(attr.trigger || 'click', modal.toggle);
// Garbage collection
scope.$on('$destroy', function () {
if (modal) modal.destroy();
options = null;
modal = null;
});
}
};
});