diff --git a/lib/amd/build/custom_interaction_events.min.js b/lib/amd/build/custom_interaction_events.min.js
new file mode 100644
index 00000000000..8ce561df5cd
--- /dev/null
+++ b/lib/amd/build/custom_interaction_events.min.js
@@ -0,0 +1 @@
+define(["jquery","core/key_codes"],function(a,b){var c={activate:"cie:activate",keyboardActivate:"cie:keyboardactivate",escape:"cie:escape",down:"cie:down",up:"cie:up",home:"cie:home",end:"cie:end",next:"cie:next",previous:"cie:previous",asterix:"cie:asterix",scrollTop:"cie:scrollTop",scrollBottom:"cie:scrollBottom",ctrlPageUp:"cie:ctrlPageUp",ctrlPageDown:"cie:ctrlPageDown",enter:"cie:enter"},d=function(a,b){return b=b||[],!(!b.length||b.indexOf(a)===-1)},e=function(a){return a.shiftKey||a.metaKey||a.altKey||a.ctrlKey},f=function(b,c,d){b.off("keydown."+c).on("keydown."+c,function(b){e(b)||b.keyCode==d&&a(b.target).trigger(c,[{originalEvent:b}])})},g=function(d){d.off("click.cie.activate").on("click.cie.activate",function(b){a(b.target).trigger(c.activate,[{originalEvent:b}])}),d.off("keydown.cie.activate").on("keydown.cie.activate",function(d){e(d)||d.keyCode!=b.enter&&d.keyCode!=b.space||a(d.target).trigger(c.activate,[{originalEvent:d}])})},h=function(d){d.off("keydown.cie.keyboardactivate").on("keydown.cie.keyboardactivate",function(d){e(d)||d.keyCode!=b.enter&&d.keyCode!=b.space||a(d.target).trigger(c.keyboardActivate,[{originalEvent:d}])})},i=function(a){f(a,c.escape,b.escape)},j=function(a){f(a,c.down,b.arrowDown)},k=function(a){f(a,c.up,b.arrowUp)},l=function(a){f(a,c.home,b.home)},m=function(a){f(a,c.end,b.end)},n=function(d){var e="rtl"==a("html").attr("dir")?b.arrowLeft:b.arrowRight;f(d,c.next,e)},o=function(d){var e="rtl"==a("html").attr("dir")?b.arrowRight:b.arrowLeft;f(d,c.previous,e)},p=function(a){f(a,c.asterix,b.asterix)},q=function(a){a.off("scroll.cie.scrollTop").on("scroll.cie.scrollTop",function(){var b=a.scrollTop();0===b&&a.trigger(c.scrollTop)})},r=function(a){a.off("scroll.cie.scrollBottom").on("scroll.cie.scrollBottom",function(){var b=a.scrollTop(),d=a.innerHeight(),e=a[0].scrollHeight;b+d>=e&&a.trigger(c.scrollBottom)})},s=function(d){d.off("keydown.cie.ctrlpageup").on("keydown.cie.ctrlpageup",function(d){d.ctrlKey&&d.keyCode==b.pageUp&&a(d.target).trigger(c.ctrlPageUp,[{originalEvent:d}])})},t=function(d){d.off("keydown.cie.ctrlpagedown").on("keydown.cie.ctrlpagedown",function(d){d.ctrlKey&&d.keyCode==b.pageDown&&a(d.target).trigger(c.ctrlPageDown,[{originalEvent:d}])})},u=function(a){f(a,c.enter,b.enter)},v=function(){var a={};return a[c.activate]=g,a[c.keyboardActivate]=h,a[c.escape]=i,a[c.down]=j,a[c.up]=k,a[c.home]=l,a[c.end]=m,a[c.next]=n,a[c.previous]=o,a[c.asterix]=p,a[c.scrollTop]=q,a[c.scrollBottom]=r,a[c.ctrlPageUp]=s,a[c.ctrlPageDown]=t,a[c.enter]=u,a},w=function(b,c){b=a(b),c=c||[],b.length&&c.length&&a.each(v(),function(a,e){d(a,c)&&e(b)})};return{define:w,events:c}});
\ No newline at end of file
diff --git a/lib/amd/build/key_codes.min.js b/lib/amd/build/key_codes.min.js
new file mode 100644
index 00000000000..04bace5c914
--- /dev/null
+++ b/lib/amd/build/key_codes.min.js
@@ -0,0 +1 @@
+define(function(){return{tab:9,enter:13,escape:27,space:32,end:35,home:36,arrowLeft:37,arrowUp:38,arrowRight:39,arrowDown:40,8:56,asterix:106,pageUp:33,pageDown:34}});
\ No newline at end of file
diff --git a/lib/amd/build/modal.min.js b/lib/amd/build/modal.min.js
new file mode 100644
index 00000000000..98fd7246eb8
--- /dev/null
+++ b/lib/amd/build/modal.min.js
@@ -0,0 +1 @@
+define(["jquery","core/templates","core/notification","core/key_codes","core/custom_interaction_events","core/modal_backdrop","core/modal_events"],function(a,b,c,d,e,f,g){var h,i={CONTAINER:'[data-region="modal-container"]',MODAL:'[data-region="modal"]',HEADER:'[data-region="header"]',TITLE:'[data-region="title"]',BODY:'[data-region="body"]',FOOTER:'[data-region="footer"]',HIDE:'[data-action="hide"]',DIALOG:"[role=dialog]",MENU_BAR:"[role=menubar]",HAS_Z_INDEX:".moodle-has-zindex",CAN_RECEIVE_FOCUS:'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]'},j={LOADING:"core/loading",BACKDROP:"core/modal_backdrop"},k=function(b){this.root=a(b),this.modal=this.root.find(i.MODAL),this.header=this.modal.find(i.HEADER),this.title=this.header.find(i.TITLE),this.body=this.modal.find(i.BODY),this.footer=this.modal.find(i.FOOTER),this.hiddenSiblings=[],this.isAttached=!1,this.bodyJS=null,this.footerJS=null,this.root.is(i.CONTAINER)||c.exception({message:"Element is not a modal container"}),this.modal.length||c.exception({message:"Container does not contain a modal"}),this.header.length||c.exception({message:"Modal is missing a header region"}),this.title.length||c.exception({message:"Modal header is missing a title region"}),this.body.length||c.exception({message:"Modal is missing a body region"}),this.footer.length||c.exception({message:"Modal is missing a footer region"}),this.registerEventListeners()};return k.prototype.attachToDOM=function(){this.isAttached||(a("body").append(this.root),this.bodyJS&&(b.runTemplateJS(this.bodyJS),this.bodyJS=null),this.footerJS&&(b.runTemplateJS(this.footerJS),this.footerJS=null),this.isAttached=!0)},k.prototype.countOtherVisibleModals=function(){var b=0;return a("body").find(i.CONTAINER).each(function(c,d){d=a(d),!this.root.is(d)&&d.hasClass("show")&&b++}.bind(this)),b},k.prototype.getBackdrop=function(){return h||(h=b.render(j.BACKDROP,{}).then(function(b){var c=a(b);return new f(c)}).fail(c.exception)),h},k.prototype.getRoot=function(){return this.root},k.prototype.getModal=function(){return this.modal},k.prototype.getTitle=function(){return this.title},k.prototype.getBody=function(){return this.body},k.prototype.getFooter=function(){return this.footer},k.prototype.setTitle=function(a){var b=this.getTitle();b.html(a)},k.prototype.setBody=function(a){var c=this.getBody();"string"==typeof a?c.html(a):b.render(j.LOADING,{}).done(function(d){c.html(d),a.done(function(a,d){c.html(a),this.isAttached?b.runTemplateJS(d):this.bodyJS=d}.bind(this))}.bind(this))},k.prototype.setFooter=function(a){var c=this.getFooter();"string"==typeof a?c.html(a):b.render(j.LOADING,{}).done(function(d){c.html(d),a.done(function(a,d){c.html(a),this.isAttached?b.runTemplateJS(d):this.footerJS=d}.bind(this))}.bind(this))},k.prototype.setLarge=function(){this.isLarge()||this.getRoot().addClass("large")},k.prototype.isLarge=function(){return this.getRoot().hasClass("large")},k.prototype.setSmall=function(){this.isSmall()||this.getRoot().removeClass("large")},k.prototype.isSmall=function(){return!this.getRoot().hasClass("large")},k.prototype.calculateZIndex=function(){var b=a(i.DIALOG+", "+i.MENU_BAR+", "+i.HAS_Z_INDEX),c=parseInt(this.root.css("z-index"));return b.each(function(b,d){d=a(d);var e=d.css("z-index")?parseInt(d.css("z-index")):0;e>c&&(c=e)}),c},k.prototype.isVisible=function(){return this.root.hasClass("show")},k.prototype.hasFocus=function(){var b=a(document.activeElement);return this.root.is(b)||this.root.has(b).length},k.prototype.hasTransitions=function(){return this.getRoot().hasClass("fade")},k.prototype.show=function(){this.isVisible()||(this.isAttached||this.attachToDOM(),this.getBackdrop().done(function(b){var c=this.calculateZIndex(),d=c+2,e=d-1;this.root.css("z-index",d),b.setZIndex(e),b.show(),this.root.removeClass("hide").addClass("show"),this.accessibilityShow(),this.getTitle().focus(),a("body").addClass("modal-open"),this.root.trigger(g.shown,this)}.bind(this)))},k.prototype.hide=function(){this.isVisible()&&this.getBackdrop().done(function(b){this.countOtherVisibleModals()||(b.hide(),a("body").removeClass("modal-open"));var c=parseInt(this.root.css("z-index"));this.root.css("z-index",""),b.setZIndex(c-3),this.accessibilityHide(),this.hasTransitions()?this.getRoot().one("transitionend webkitTransitionEnd oTransitionEnd",function(){this.getRoot().removeClass("show").addClass("hide")}.bind(this)):this.getRoot().removeClass("show").addClass("hide"),this.root.trigger(g.hidden,this)}.bind(this))},k.prototype.destroy=function(){this.root.remove(),this.root.trigger(g.destroyed,this)},k.prototype.accessibilityShow=function(){a("body").children().each(function(b,c){if(!this.root.is(c)){c=a(c);var d=c.attr("aria-hidden");"true"!==d&&(c.data("previous-aria-hidden",d),this.hiddenSiblings.push(c),c.attr("aria-hidden","true"))}}.bind(this)),this.root.attr("aria-hidden","false")},k.prototype.accessibilityHide=function(){this.root.attr("aria-hidden","true"),a.each(this.hiddenSiblings,function(b,c){c=a(c);var d=c.data("previous-aria-hidden");"undefined"==typeof d?c.removeAttr("aria-hidden"):c.attr("aria-hidden",d)}),this.hiddenSiblings=[]},k.prototype.handleTabLock=function(b){if(this.hasFocus()){var c=a(document.activeElement),d=this.modal.find(i.CAN_RECEIVE_FOCUS),e=d.first(),f=d.last();c.is(e)&&b.shiftKey?(f.focus(),b.preventDefault()):c.is(f)&&!b.shiftKey&&(e.focus(),b.preventDefault())}},k.prototype.registerEventListeners=function(){this.getRoot().on("keydown",function(a){this.isVisible()&&(a.keyCode==d.tab?this.handleTabLock(a):a.keyCode==d.escape&&this.hide())}.bind(this)),e.define(this.getModal(),[e.events.activate]),this.getModal().on(e.events.activate,i.HIDE,function(a,b){this.hide(),b.originalEvent.preventDefault()}.bind(this))},k});
\ No newline at end of file
diff --git a/lib/amd/build/modal_backdrop.min.js b/lib/amd/build/modal_backdrop.min.js
new file mode 100644
index 00000000000..7984ca10bc9
--- /dev/null
+++ b/lib/amd/build/modal_backdrop.min.js
@@ -0,0 +1 @@
+define(["jquery","core/templates","core/notification"],function(a,b,c){var d={ROOT:'[data-region="modal-backdrop"]'},e=function(b){this.root=a(b),this.isAttached=!1,this.root.is(d.ROOT)||c.exception({message:"Element is not a modal backdrop"})};return e.prototype.getRoot=function(){return this.root},e.prototype.attachToDOM=function(){this.isAttached||(a("body").append(this.root),this.isAttached=!0)},e.prototype.setZIndex=function(a){this.root.css("z-index",a)},e.prototype.isVisible=function(){return this.root.hasClass("show")},e.prototype.hasTransitions=function(){return this.getRoot().hasClass("fade")},e.prototype.show=function(){this.isVisible()||(this.isAttached||this.attachToDOM(),this.root.removeClass("hide").addClass("show"))},e.prototype.hide=function(){this.isVisible()&&(this.hasTransitions()?this.getRoot().one("transitionend webkitTransitionEnd oTransitionEnd",function(){this.getRoot().removeClass("show").addClass("hide")}.bind(this)):this.getRoot().removeClass("show").addClass("hide"))},e.prototype.destroy=function(){this.root.remove()},e});
\ No newline at end of file
diff --git a/lib/amd/build/modal_events.min.js b/lib/amd/build/modal_events.min.js
new file mode 100644
index 00000000000..ee5cdcef65c
--- /dev/null
+++ b/lib/amd/build/modal_events.min.js
@@ -0,0 +1 @@
+define([],function(){return{shown:"modal:shown",hidden:"modal:hidden",destroyed:"modal:destroyed",save:"modal-save-cancel:save",cancel:"modal-save-cancel:cancel"}});
\ No newline at end of file
diff --git a/lib/amd/build/modal_factory.min.js b/lib/amd/build/modal_factory.min.js
new file mode 100644
index 00000000000..01914519432
--- /dev/null
+++ b/lib/amd/build/modal_factory.min.js
@@ -0,0 +1 @@
+define(["jquery","core/modal_events","core/modal","core/modal_save_cancel","core/templates","core/notification","core/custom_interaction_events"],function(a,b,c,d,e,f,g){var h={DEFAULT:"core/modal",SAVE_CANCEL:"core/modal_save_cancel"},i={DEFAULT:c,SAVE_CANCEL:d},j={DEFAULT:"DEFAULT",SAVE_CANCEL:"SAVE_CANCEL"},k=function(a,c){"undefined"!=typeof c&&(g.define(c,[g.events.activate]),c.on(g.events.activate,function(){a.show()}),a.getRoot().on(b.hidden,function(){c.focus()}))},l=function(b,c,d){c=a(c);var e=i[b],f=new e(c);return k(f,d),f},m=function(b,c){var d=h[b];return e.render(d,{}).then(function(d){var e=a(d);return l(b,e,c)}).fail(f.exception)},n=function(a,b){var c=a.type||j.DEFAULT,d=!!a.large;return j[c]||(c=j.DEFAULT),m(c,b).then(function(b){return"undefined"!=typeof a.title&&b.setTitle(a.title),"undefined"!=typeof a.body&&b.setBody(a.body),"undefined"!=typeof a.footer&&b.setFooter(a.footer),d&&b.setLarge(),b})};return{create:n,types:j}});
\ No newline at end of file
diff --git a/lib/amd/build/modal_save_cancel.min.js b/lib/amd/build/modal_save_cancel.min.js
new file mode 100644
index 00000000000..0bdfe30ae00
--- /dev/null
+++ b/lib/amd/build/modal_save_cancel.min.js
@@ -0,0 +1 @@
+define(["jquery","core/notification","core/custom_interaction_events","core/modal","core/modal_events"],function(a,b,c,d,e){var f={SAVE_BUTTON:'[data-action="save"]',CANCEL_BUTTON:'[data-action="cancel"]'},g=function(a){d.call(this,a),this.getFooter().find(f.SAVE_BUTTON).length||b.exception({message:"No save button found"}),this.getFooter().find(f.CANCEL_BUTTON).length||b.exception({message:"No cancel button found"})};return g.prototype=Object.create(d.prototype),g.prototype.constructor=g,g.prototype.setFooter=function(){b.exception({message:"Can not change the footer of a save cancel modal"})},g.prototype.registerEventListeners=function(){d.prototype.registerEventListeners.call(this),this.getModal().on(c.events.activate,f.SAVE_BUTTON,function(b,c){var d=a.Event(e.save);this.getRoot().trigger(d,this),d.isDefaultPrevented()||(this.hide(),c.originalEvent.preventDefault())}.bind(this)),this.getModal().on(c.events.activate,f.CANCEL_BUTTON,function(b,c){var d=a.Event(e.cancel);this.getRoot().trigger(d,this),d.isDefaultPrevented()||(this.hide(),c.originalEvent.preventDefault())}.bind(this))},g});
\ No newline at end of file
diff --git a/lib/amd/src/custom_interaction_events.js b/lib/amd/src/custom_interaction_events.js
new file mode 100644
index 00000000000..8666b232300
--- /dev/null
+++ b/lib/amd/src/custom_interaction_events.js
@@ -0,0 +1,381 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * This module provides a wrapper to encapsulate a lot of the common combinations of
+ * user interaction we use in Moodle.
+ *
+ * @module core/custom_interaction_events
+ * @class custom_interaction_events
+ * @package core
+ * @copyright 2016 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since 3.2
+ */
+define(['jquery', 'core/key_codes'], function($, keyCodes) {
+ // The list of events provided by this module. Namespaced to avoid clashes.
+ var events = {
+ activate: 'cie:activate',
+ keyboardActivate: 'cie:keyboardactivate',
+ escape: 'cie:escape',
+ down: 'cie:down',
+ up: 'cie:up',
+ home: 'cie:home',
+ end: 'cie:end',
+ next: 'cie:next',
+ previous: 'cie:previous',
+ asterix: 'cie:asterix',
+ scrollTop: 'cie:scrollTop',
+ scrollBottom: 'cie:scrollBottom',
+ ctrlPageUp: 'cie:ctrlPageUp',
+ ctrlPageDown: 'cie:ctrlPageDown',
+ enter: 'cie:enter',
+ };
+
+ /**
+ * Check if the caller has asked for the given event type to be
+ * registered.
+ *
+ * @method shouldAddEvent
+ * @private
+ * @param {string} eventType name of the event (see events above)
+ * @param {array} include the list of events to be added
+ * @return {bool} true if the event should be added, false otherwise.
+ */
+ var shouldAddEvent = function(eventType, include) {
+ include = include || [];
+
+ if (include.length && include.indexOf(eventType) !== -1) {
+ return true;
+ }
+
+ return false;
+ };
+
+ /**
+ * Check if any of the modifier keys have been pressed on the event.
+ *
+ * @method isModifierPressed
+ * @private
+ * @param {event} e jQuery event
+ * @return {bool} true if shift, meta (command on Mac), alt or ctrl are pressed
+ */
+ var isModifierPressed = function(e) {
+ return (e.shiftKey || e.metaKey || e.altKey || e.ctrlKey);
+ };
+
+ /**
+ * Register a keyboard event that ignores modifier keys.
+ *
+ * @method addKeyboardEvent
+ * @private
+ * @param {object} element A jQuery object of the element to bind events to
+ * @param {string} event The custom interaction event name
+ * @param {int} keyCode The key code.
+ */
+ var addKeyboardEvent = function(element, event, keyCode) {
+ element.off('keydown.' + event).on('keydown.' + event, function(e) {
+ if (!isModifierPressed(e)) {
+ if (e.keyCode == keyCode) {
+ $(e.target).trigger(event, [{originalEvent: e}]);
+ }
+ }
+ });
+ };
+
+ /**
+ * Trigger the activate event on the given element if it is clicked or the enter
+ * or space key are pressed without a modifier key.
+ *
+ * @method addActivateListener
+ * @private
+ * @param {object} element jQuery object to add event listeners to
+ */
+ var addActivateListener = function(element) {
+ element.off('click.cie.activate').on('click.cie.activate', function(e) {
+ $(e.target).trigger(events.activate, [{originalEvent: e}]);
+ });
+ element.off('keydown.cie.activate').on('keydown.cie.activate', function(e) {
+ if (!isModifierPressed(e)) {
+ if (e.keyCode == keyCodes.enter || e.keyCode == keyCodes.space) {
+ $(e.target).trigger(events.activate, [{originalEvent: e}]);
+ }
+ }
+ });
+ };
+
+ /**
+ * Trigger the keyboard activate event on the given element if the enter
+ * or space key are pressed without a modifier key.
+ *
+ * @method addKeyboardActivateListener
+ * @private
+ * @param {object} element jQuery object to add event listeners to
+ */
+ var addKeyboardActivateListener = function(element) {
+ element.off('keydown.cie.keyboardactivate').on('keydown.cie.keyboardactivate', function(e) {
+ if (!isModifierPressed(e)) {
+ if (e.keyCode == keyCodes.enter || e.keyCode == keyCodes.space) {
+ $(e.target).trigger(events.keyboardActivate, [{originalEvent: e}]);
+ }
+ }
+ });
+ };
+
+ /**
+ * Trigger the escape event on the given element if the escape key is pressed
+ * without a modifier key.
+ *
+ * @method addEscapeListener
+ * @private
+ * @param {object} element jQuery object to add event listeners to
+ */
+ var addEscapeListener = function(element) {
+ addKeyboardEvent(element, events.escape, keyCodes.escape);
+ };
+
+ /**
+ * Trigger the down event on the given element if the down arrow key is pressed
+ * without a modifier key.
+ *
+ * @method addDownListener
+ * @private
+ * @param {object} element jQuery object to add event listeners to
+ */
+ var addDownListener = function(element) {
+ addKeyboardEvent(element, events.down, keyCodes.arrowDown);
+ };
+
+ /**
+ * Trigger the up event on the given element if the up arrow key is pressed
+ * without a modifier key.
+ *
+ * @method addUpListener
+ * @private
+ * @param {object} element jQuery object to add event listeners to
+ */
+ var addUpListener = function(element) {
+ addKeyboardEvent(element, events.up, keyCodes.arrowUp);
+ };
+
+ /**
+ * Trigger the home event on the given element if the home key is pressed
+ * without a modifier key.
+ *
+ * @method addHomeListener
+ * @private
+ * @param {object} element jQuery object to add event listeners to
+ */
+ var addHomeListener = function(element) {
+ addKeyboardEvent(element, events.home, keyCodes.home);
+ };
+
+ /**
+ * Trigger the end event on the given element if the end key is pressed
+ * without a modifier key.
+ *
+ * @method addEndListener
+ * @private
+ * @param {object} element jQuery object to add event listeners to
+ */
+ var addEndListener = function(element) {
+ addKeyboardEvent(element, events.end, keyCodes.end);
+ };
+
+ /**
+ * Trigger the next event on the given element if the right arrow key is pressed
+ * without a modifier key in LTR mode or left arrow key in RTL mode.
+ *
+ * @method addNextListener
+ * @private
+ * @param {object} element jQuery object to add event listeners to
+ */
+ var addNextListener = function(element) {
+ // Left and right are flipped in RTL mode.
+ var keyCode = $('html').attr('dir') == "rtl" ? keyCodes.arrowLeft : keyCodes.arrowRight;
+
+ addKeyboardEvent(element, events.next, keyCode);
+ };
+
+ /**
+ * Trigger the previous event on the given element if the left arrow key is pressed
+ * without a modifier key in LTR mode or right arrow key in RTL mode.
+ *
+ * @method addPreviousListener
+ * @private
+ * @param {object} element jQuery object to add event listeners to
+ */
+ var addPreviousListener = function(element) {
+ // Left and right are flipped in RTL mode.
+ var keyCode = $('html').attr('dir') == "rtl" ? keyCodes.arrowRight : keyCodes.arrowLeft;
+
+ addKeyboardEvent(element, events.previous, keyCode);
+ };
+
+ /**
+ * Trigger the asterix event on the given element if the asterix key is pressed
+ * without a modifier key.
+ *
+ * @method addAsterixListener
+ * @private
+ * @param {object} element jQuery object to add event listeners to
+ */
+ var addAsterixListener = function(element) {
+ addKeyboardEvent(element, events.asterix, keyCodes.asterix);
+ };
+
+
+ /**
+ * Trigger the scrollTop event on the given element if the user scrolls to
+ * the top of the given element.
+ *
+ * @method addScrollTopListener
+ * @private
+ * @param {object} element jQuery object to add event listeners to
+ */
+ var addScrollTopListener = function(element) {
+ element.off('scroll.cie.scrollTop').on('scroll.cie.scrollTop', function() {
+ var scrollTop = element.scrollTop();
+ if (scrollTop === 0) {
+ element.trigger(events.scrollTop);
+ }
+ });
+ };
+
+ /**
+ * Trigger the scrollBottom event on the given element if the user scrolls to
+ * the bottom of the given element.
+ *
+ * @method addScrollBottomListener
+ * @private
+ * @param {object} element jQuery object to add event listeners to
+ */
+ var addScrollBottomListener = function(element) {
+ element.off('scroll.cie.scrollBottom').on('scroll.cie.scrollBottom', function() {
+ var scrollTop = element.scrollTop();
+ var innerHeight = element.innerHeight();
+ var scrollHeight = element[0].scrollHeight;
+
+ if (scrollTop + innerHeight >= scrollHeight) {
+ element.trigger(events.scrollBottom);
+ }
+ });
+ };
+
+ /**
+ * Trigger the ctrlPageUp event on the given element if the user presses the
+ * control and page up key.
+ *
+ * @method addCtrlPageUpListener
+ * @private
+ * @param {object} element jQuery object to add event listeners to
+ */
+ var addCtrlPageUpListener = function(element) {
+ element.off('keydown.cie.ctrlpageup').on('keydown.cie.ctrlpageup', function(e) {
+ if (e.ctrlKey) {
+ if (e.keyCode == keyCodes.pageUp) {
+ $(e.target).trigger(events.ctrlPageUp, [{originalEvent: e}]);
+ }
+ }
+ });
+ };
+
+ /**
+ * Trigger the ctrlPageDown event on the given element if the user presses the
+ * control and page down key.
+ *
+ * @method addCtrlPageDownListener
+ * @private
+ * @param {object} element jQuery object to add event listeners to
+ */
+ var addCtrlPageDownListener = function(element) {
+ element.off('keydown.cie.ctrlpagedown').on('keydown.cie.ctrlpagedown', function(e) {
+ if (e.ctrlKey) {
+ if (e.keyCode == keyCodes.pageDown) {
+ $(e.target).trigger(events.ctrlPageDown, [{originalEvent: e}]);
+ }
+ }
+ });
+ };
+
+ /**
+ * Trigger the enter event on the given element if the enter key is pressed
+ * without a modifier key.
+ *
+ * @method addEnterListener
+ * @private
+ * @param {object} element jQuery object to add event listeners to
+ */
+ var addEnterListener = function(element) {
+ addKeyboardEvent(element, events.enter, keyCodes.enter);
+ };
+
+ /**
+ * Get the list of events and their handlers.
+ *
+ * @method getHandlers
+ * @private
+ * @return {object} object key of event names and value of handler functions
+ */
+ var getHandlers = function() {
+ var handlers = {};
+
+ handlers[events.activate] = addActivateListener;
+ handlers[events.keyboardActivate] = addKeyboardActivateListener;
+ handlers[events.escape] = addEscapeListener;
+ handlers[events.down] = addDownListener;
+ handlers[events.up] = addUpListener;
+ handlers[events.home] = addHomeListener;
+ handlers[events.end] = addEndListener;
+ handlers[events.next] = addNextListener;
+ handlers[events.previous] = addPreviousListener;
+ handlers[events.asterix] = addAsterixListener;
+ handlers[events.scrollTop] = addScrollTopListener;
+ handlers[events.scrollBottom] = addScrollBottomListener;
+ handlers[events.ctrlPageUp] = addCtrlPageUpListener;
+ handlers[events.ctrlPageDown] = addCtrlPageDownListener;
+ handlers[events.enter] = addEnterListener;
+
+ return handlers;
+ };
+
+ /**
+ * Add all of the listeners on the given element for the requested events.
+ *
+ * @method define
+ * @public
+ * @param {object} element the DOM element to register event listeners on
+ * @param {array} include the array of events to be triggered
+ */
+ var define = function(element, include) {
+ element = $(element);
+ include = include || [];
+
+ if (!element.length || !include.length) {
+ return;
+ }
+
+ $.each(getHandlers(), function(eventType, handler) {
+ if (shouldAddEvent(eventType, include)) {
+ handler(element);
+ }
+ });
+ };
+
+ return /** @module core/custom_interaction_events */ {
+ define: define,
+ events: events,
+ };
+});
diff --git a/lib/amd/src/key_codes.js b/lib/amd/src/key_codes.js
new file mode 100644
index 00000000000..b64529b1cc4
--- /dev/null
+++ b/lib/amd/src/key_codes.js
@@ -0,0 +1,44 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * A list of human readable names for the keycodes.
+ *
+ * @module core/key_codes
+ * @class key_codes
+ * @package core
+ * @copyright 2016 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since 3.2
+ */
+define(function() {
+
+ return /** @alias module:core/key_codes */ {
+ 'tab': 9,
+ 'enter': 13,
+ 'escape': 27,
+ 'space': 32,
+ 'end': 35,
+ 'home': 36,
+ 'arrowLeft': 37,
+ 'arrowUp': 38,
+ 'arrowRight': 39,
+ 'arrowDown': 40,
+ '8': 56,
+ 'asterix': 106,
+ 'pageUp': 33,
+ 'pageDown': 34,
+ };
+});
diff --git a/lib/amd/src/modal.js b/lib/amd/src/modal.js
new file mode 100644
index 00000000000..2421359082c
--- /dev/null
+++ b/lib/amd/src/modal.js
@@ -0,0 +1,591 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Contain the logic for modals.
+ *
+ * @module core/modal
+ * @class modal
+ * @package core
+ * @copyright 2016 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/templates', 'core/notification', 'core/key_codes',
+ 'core/custom_interaction_events', 'core/modal_backdrop', 'core/modal_events'],
+ function($, Templates, Notification, KeyCodes, CustomEvents, ModalBackdrop, ModalEvents) {
+
+ var SELECTORS = {
+ CONTAINER: '[data-region="modal-container"]',
+ MODAL: '[data-region="modal"]',
+ HEADER: '[data-region="header"]',
+ TITLE: '[data-region="title"]',
+ BODY: '[data-region="body"]',
+ FOOTER: '[data-region="footer"]',
+ HIDE: '[data-action="hide"]',
+ DIALOG: '[role=dialog]',
+ MENU_BAR: '[role=menubar]',
+ HAS_Z_INDEX: '.moodle-has-zindex',
+ CAN_RECEIVE_FOCUS: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',
+ };
+
+ var TEMPLATES = {
+ LOADING: 'core/loading',
+ BACKDROP: 'core/modal_backdrop',
+ };
+
+ /**
+ * Module singleton for the backdrop to be reused by all Modal instances.
+ */
+ var backdropPromise;
+
+ /**
+ * Constructor for the Modal.
+ *
+ * @param {object} root The root jQuery element for the modal
+ */
+ var Modal = function(root) {
+ this.root = $(root);
+ this.modal = this.root.find(SELECTORS.MODAL);
+ this.header = this.modal.find(SELECTORS.HEADER);
+ this.title = this.header.find(SELECTORS.TITLE);
+ this.body = this.modal.find(SELECTORS.BODY);
+ this.footer = this.modal.find(SELECTORS.FOOTER);
+ this.hiddenSiblings = [];
+ this.isAttached = false;
+ this.bodyJS = null;
+ this.footerJS = null;
+
+ if (!this.root.is(SELECTORS.CONTAINER)) {
+ Notification.exception({message: 'Element is not a modal container'});
+ }
+
+ if (!this.modal.length) {
+ Notification.exception({message: 'Container does not contain a modal'});
+ }
+
+ if (!this.header.length) {
+ Notification.exception({message: 'Modal is missing a header region'});
+ }
+
+ if (!this.title.length) {
+ Notification.exception({message: 'Modal header is missing a title region'});
+ }
+
+ if (!this.body.length) {
+ Notification.exception({message: 'Modal is missing a body region'});
+ }
+
+ if (!this.footer.length) {
+ Notification.exception({message: 'Modal is missing a footer region'});
+ }
+
+ this.registerEventListeners();
+ };
+
+ /**
+ * Add the modal to the page, if it hasn't already been added. This includes running any
+ * javascript that has been cached until now.
+ *
+ * @method attachToDOM
+ */
+ Modal.prototype.attachToDOM = function() {
+ if (this.isAttached) {
+ return;
+ }
+
+ $('body').append(this.root);
+
+ // If we'd cached any JS then we can run it how that the modal is
+ // attached to the DOM.
+ if (this.bodyJS) {
+ Templates.runTemplateJS(this.bodyJS);
+ this.bodyJS = null;
+ }
+
+ if (this.footerJS) {
+ Templates.runTemplateJS(this.footerJS);
+ this.footerJS = null;
+ }
+
+ this.isAttached = true;
+ };
+
+ /**
+ * Count the number of other visible modals (not including this one).
+ *
+ * @method countOtherVisibleModals
+ * @return {int}
+ */
+ Modal.prototype.countOtherVisibleModals = function() {
+ var count = 0;
+ $('body').find(SELECTORS.CONTAINER).each(function(index, element) {
+ element = $(element);
+
+ // If we haven't found ourself and the element is visible.
+ if (!this.root.is(element) && element.hasClass('show')) {
+ count++;
+ }
+ }.bind(this));
+
+ return count;
+ };
+
+ /**
+ * Get the modal backdrop.
+ *
+ * @method getBackdrop
+ * @return {object} jQuery promise
+ */
+ Modal.prototype.getBackdrop = function() {
+ if (!backdropPromise) {
+ backdropPromise = Templates.render(TEMPLATES.BACKDROP, {})
+ .then(function(html) {
+ var element = $(html);
+
+ return new ModalBackdrop(element);
+ })
+ .fail(Notification.exception);
+ }
+
+ return backdropPromise;
+ };
+
+ /**
+ * Get the root element of this modal.
+ *
+ * @method getRoot
+ * @return {object} jQuery object
+ */
+ Modal.prototype.getRoot = function() {
+ return this.root;
+ };
+
+ /**
+ * Get the modal element of this modal.
+ *
+ * @method getModal
+ * @return {object} jQuery object
+ */
+ Modal.prototype.getModal = function() {
+ return this.modal;
+ };
+
+ /**
+ * Get the modal title element.
+ *
+ * @method getTitle
+ * @return {object} jQuery object
+ */
+ Modal.prototype.getTitle = function() {
+ return this.title;
+ };
+
+ /**
+ * Get the modal body element.
+ *
+ * @method getBody
+ * @return {object} jQuery object
+ */
+ Modal.prototype.getBody = function() {
+ return this.body;
+ };
+
+ /**
+ * Get the modal footer element.
+ *
+ * @method getFooter
+ * @return {object} jQuery object
+ */
+ Modal.prototype.getFooter = function() {
+ return this.footer;
+ };
+
+ /**
+ * Set the modal title element.
+ *
+ * @method setTitle
+ * @param {string} value The title string
+ */
+ Modal.prototype.setTitle = function(value) {
+ var title = this.getTitle();
+ title.html(value);
+ };
+
+ /**
+ * Set the modal body element.
+ *
+ * This method is overloaded to take either a string
+ * value for the body or a jQuery promise that is resolved with HTML and Javascript
+ * most commonly from a Templates.render call.
+ *
+ * @method setBody
+ * @param {(string|object)} value The body string or jQuery promise
+ */
+ Modal.prototype.setBody = function(value) {
+ var body = this.getBody();
+
+ if (typeof value === 'string') {
+ // Just set the value if it's a string.
+ body.html(value);
+ } else {
+ // Otherwise we assume it's a promise to be resolved with
+ // html and javascript.
+ Templates.render(TEMPLATES.LOADING, {}).done(function(html) {
+ body.html(html);
+
+ value.done(function(html, js) {
+ body.html(html);
+
+ if (this.isAttached) {
+ // If we're in the DOM then run the JS immediately.
+ Templates.runTemplateJS(js);
+ } else {
+ // Otherwise cache it to be run when we're attached.
+ this.bodyJS = js;
+ }
+ }.bind(this));
+ }.bind(this));
+ }
+ };
+
+ /**
+ * Set the modal footer element.
+ *
+ * This method is overloaded to take either a string
+ * value for the body or a jQuery promise that is resolved with HTML and Javascript
+ * most commonly from a Templates.render call.
+ *
+ * @method setFooter
+ * @param {(string|object)} value The footer string or jQuery promise
+ */
+ Modal.prototype.setFooter = function(value) {
+ var footer = this.getFooter();
+
+ if (typeof value === 'string') {
+ // Just set the value if it's a string.
+ footer.html(value);
+ } else {
+ // Otherwise we assume it's a promise to be resolved with
+ // html and javascript.
+ Templates.render(TEMPLATES.LOADING, {}).done(function(html) {
+ footer.html(html);
+
+ value.done(function(html, js) {
+ footer.html(html);
+
+ if (this.isAttached) {
+ // If we're in the DOM then run the JS immediately.
+ Templates.runTemplateJS(js);
+ } else {
+ // Otherwise cache it to be run when we're attached.
+ this.footerJS = js;
+ }
+ }.bind(this));
+ }.bind(this));
+ }
+ };
+
+ /**
+ * Mark the modal as a large modal.
+ *
+ * @method setLarge
+ */
+ Modal.prototype.setLarge = function() {
+ if (this.isLarge()) {
+ return;
+ }
+
+ this.getRoot().addClass('large');
+ };
+
+ /**
+ * Check if the modal is a large modal.
+ *
+ * @method isLarge
+ * @return {bool}
+ */
+ Modal.prototype.isLarge = function() {
+ return this.getRoot().hasClass('large');
+ };
+
+ /**
+ * Mark the modal as a small modal.
+ *
+ * @method setSmall
+ */
+ Modal.prototype.setSmall = function() {
+ if (this.isSmall()) {
+ return;
+ }
+
+ this.getRoot().removeClass('large');
+ };
+
+ /**
+ * Check if the modal is a small modal.
+ *
+ * @method isSmall
+ * @return {bool}
+ */
+ Modal.prototype.isSmall = function() {
+ return !this.getRoot().hasClass('large');
+ };
+
+ /**
+ * Determine the highest z-index value currently on the page.
+ *
+ * @method calculateZIndex
+ * @return {int}
+ */
+ Modal.prototype.calculateZIndex = function() {
+ var items = $(SELECTORS.DIALOG + ', ' + SELECTORS.MENU_BAR + ', ' + SELECTORS.HAS_Z_INDEX);
+ var zIndex = parseInt(this.root.css('z-index'));
+
+ items.each(function(index, item) {
+ item = $(item);
+ // Note that webkit browsers won't return the z-index value from the CSS stylesheet
+ // if the element doesn't have a position specified. Instead it'll return "auto".
+ var itemZIndex = item.css('z-index') ? parseInt(item.css('z-index')) : 0;
+
+ if (itemZIndex > zIndex) {
+ zIndex = itemZIndex;
+ }
+ });
+
+ return zIndex;
+ };
+
+ /**
+ * Check if this modal is visible.
+ *
+ * @method isVisible
+ * @return {bool}
+ */
+ Modal.prototype.isVisible = function() {
+ return this.root.hasClass('show');
+ };
+
+ /**
+ * Check if this modal has focus.
+ *
+ * @method hasFocus
+ * @return {bool}
+ */
+ Modal.prototype.hasFocus = function() {
+ var target = $(document.activeElement);
+ return this.root.is(target) || this.root.has(target).length;
+ };
+
+ /**
+ * Check if this modal has CSS transitions applied.
+ *
+ * @method hasTransitions
+ * @return {bool}
+ */
+ Modal.prototype.hasTransitions = function() {
+ return this.getRoot().hasClass('fade');
+ };
+
+ /**
+ * Display this modal. The modal will be attached to the DOM if it hasn't
+ * already been.
+ *
+ * @method show
+ */
+ Modal.prototype.show = function() {
+ if (this.isVisible()) {
+ return;
+ }
+
+ if (!this.isAttached) {
+ this.attachToDOM();
+ }
+
+ this.getBackdrop().done(function(backdrop) {
+ var currentIndex = this.calculateZIndex();
+ var newIndex = currentIndex + 2;
+ var newBackdropIndex = newIndex - 1;
+ this.root.css('z-index', newIndex);
+ backdrop.setZIndex(newBackdropIndex);
+ backdrop.show();
+
+ this.root.removeClass('hide').addClass('show');
+ this.accessibilityShow();
+ this.getTitle().focus();
+ $('body').addClass('modal-open');
+ this.root.trigger(ModalEvents.shown, this);
+ }.bind(this));
+ };
+
+ /**
+ * Hide this modal.
+ *
+ * @method hide
+ */
+ Modal.prototype.hide = function() {
+ if (!this.isVisible()) {
+ return;
+ }
+
+ this.getBackdrop().done(function(backdrop) {
+ if (!this.countOtherVisibleModals()) {
+ // Hide the backdrop if we're the last open modal.
+ backdrop.hide();
+ $('body').removeClass('modal-open');
+ }
+
+ var currentIndex = parseInt(this.root.css('z-index'));
+ this.root.css('z-index', '');
+ backdrop.setZIndex(currentIndex - 3);
+
+ this.accessibilityHide();
+
+ if (this.hasTransitions()) {
+ // Wait for CSS transitions to complete before hiding the element.
+ this.getRoot().one('transitionend webkitTransitionEnd oTransitionEnd', function() {
+ this.getRoot().removeClass('show').addClass('hide');
+ }.bind(this));
+ } else {
+ this.getRoot().removeClass('show').addClass('hide');
+ }
+
+ this.root.trigger(ModalEvents.hidden, this);
+ }.bind(this));
+ };
+
+ /**
+ * Remove this modal from the DOM.
+ *
+ * @method destroy
+ */
+ Modal.prototype.destroy = function() {
+ this.root.remove();
+ this.root.trigger(ModalEvents.destroyed, this);
+ };
+
+ /**
+ * Sets the appropriate aria attributes on this dialogue and the other
+ * elements in the DOM to ensure that screen readers are able to navigate
+ * the dialogue popup correctly.
+ *
+ * @method accessibilityShow
+ */
+ Modal.prototype.accessibilityShow = function() {
+ // We need to get a list containing each sibling element and the shallowest
+ // non-ancestral nodes in the DOM. We can shortcut this a little by leveraging
+ // the fact that this dialogue is always appended to the document body therefore
+ // it's siblings are the shallowest non-ancestral nodes. If that changes then
+ // this code should also be updated.
+ $('body').children().each(function(index, child) {
+ // Skip the current modal.
+ if (!this.root.is(child)) {
+ child = $(child);
+ var hidden = child.attr('aria-hidden');
+ // If they are already hidden we can ignore them.
+ if (hidden !== 'true') {
+ // Save their current state.
+ child.data('previous-aria-hidden', hidden);
+ this.hiddenSiblings.push(child);
+
+ // Hide this node from screen readers.
+ child.attr('aria-hidden', 'true');
+ }
+ }
+ }.bind(this));
+
+ // Make us visible to screen readers.
+ this.root.attr('aria-hidden', 'false');
+ };
+
+ /**
+ * Restores the aria visibility on the DOM elements changed when displaying
+ * the dialogue popup and makes the dialogue aria hidden to allow screen
+ * readers to navigate the main page correctly when the dialogue is closed.
+ *
+ * @method accessibilityHide
+ */
+ Modal.prototype.accessibilityHide = function() {
+ this.root.attr('aria-hidden', 'true');
+
+ // Restore the sibling nodes back to their original values.
+ $.each(this.hiddenSiblings, function(index, sibling) {
+ sibling = $(sibling);
+ var previousValue = sibling.data('previous-aria-hidden');
+ // If the element didn't previously have an aria-hidden attribute
+ // then we can just remove the one we set.
+ if (typeof previousValue == 'undefined') {
+ sibling.removeAttr('aria-hidden');
+ } else {
+ // Otherwise set it back to the old value (which will be false).
+ sibling.attr('aria-hidden', previousValue);
+ }
+ });
+
+ // Clear the cache. No longer need to store these.
+ this.hiddenSiblings = [];
+ };
+
+ /**
+ * Handle the tab event to lock focus within this modal.
+ *
+ * @method handleTabLock
+ * @param {event} e The tab key jQuery event
+ */
+ Modal.prototype.handleTabLock = function(e) {
+ if (!this.hasFocus()) {
+ return;
+ }
+
+ var target = $(document.activeElement);
+ var focusableElements = this.modal.find(SELECTORS.CAN_RECEIVE_FOCUS);
+ var firstFocusable = focusableElements.first();
+ var lastFocusable = focusableElements.last();
+
+ if (target.is(firstFocusable) && e.shiftKey) {
+ lastFocusable.focus();
+ e.preventDefault();
+ } else if (target.is(lastFocusable) && !e.shiftKey) {
+ firstFocusable.focus();
+ e.preventDefault();
+ }
+ };
+
+ /**
+ * Set up all of the event handling for the modal.
+ *
+ * @method registerEventListeners
+ */
+ Modal.prototype.registerEventListeners = function() {
+ this.getRoot().on('keydown', function(e) {
+ if (!this.isVisible()) {
+ return;
+ }
+
+ if (e.keyCode == KeyCodes.tab) {
+ this.handleTabLock(e);
+ } else if (e.keyCode == KeyCodes.escape) {
+ this.hide();
+ }
+ }.bind(this));
+
+ CustomEvents.define(this.getModal(), [CustomEvents.events.activate]);
+ this.getModal().on(CustomEvents.events.activate, SELECTORS.HIDE, function(e, data) {
+ this.hide();
+ data.originalEvent.preventDefault();
+ }.bind(this));
+ };
+
+ return Modal;
+});
diff --git a/lib/amd/src/modal_backdrop.js b/lib/amd/src/modal_backdrop.js
new file mode 100644
index 00000000000..75a7b26a12b
--- /dev/null
+++ b/lib/amd/src/modal_backdrop.js
@@ -0,0 +1,148 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Contain the logic for modal backdrops.
+ *
+ * @module core/modal_backdrop
+ * @class modal_backdrop
+ * @package core
+ * @copyright 2016 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/templates', 'core/notification'],
+ function($, Templates, Notification) {
+
+ var SELECTORS = {
+ ROOT: '[data-region="modal-backdrop"]',
+ };
+
+ /**
+ * Constructor for ModalBackdrop.
+ *
+ * @param {object} root The root element for the modal backdrop
+ */
+ var ModalBackdrop = function(root) {
+ this.root = $(root);
+ this.isAttached = false;
+
+ if (!this.root.is(SELECTORS.ROOT)) {
+ Notification.exception({message: 'Element is not a modal backdrop'});
+ }
+ };
+
+ /**
+ * Get the root element of this modal backdrop.
+ *
+ * @method getRoot
+ * @return {object} jQuery object
+ */
+ ModalBackdrop.prototype.getRoot = function() {
+ return this.root;
+ };
+
+ /**
+ * Add the modal backdrop to the page, if it hasn't already been added.
+ *
+ * @method attachToDOM
+ */
+ ModalBackdrop.prototype.attachToDOM = function() {
+ if (this.isAttached) {
+ return;
+ }
+
+ $('body').append(this.root);
+ this.isAttached = true;
+ };
+
+ /**
+ * Set the z-index value for this backdrop.
+ *
+ * @method setZIndex
+ * @param {int} value The z-index value
+ */
+ ModalBackdrop.prototype.setZIndex = function(value) {
+ this.root.css('z-index', value);
+ };
+
+ /**
+ * Check if this backdrop is visible.
+ *
+ * @method isVisible
+ * @return {bool}
+ */
+ ModalBackdrop.prototype.isVisible = function() {
+ return this.root.hasClass('show');
+ };
+
+ /**
+ * Check if this backdrop has CSS transitions applied.
+ *
+ * @method hasTransitions
+ * @return {bool}
+ */
+ ModalBackdrop.prototype.hasTransitions = function() {
+ return this.getRoot().hasClass('fade');
+ };
+
+ /**
+ * Display this backdrop. The backdrop will be attached to the DOM if it hasn't
+ * already been.
+ *
+ * @method show
+ */
+ ModalBackdrop.prototype.show = function() {
+ if (this.isVisible()) {
+ return;
+ }
+
+ if (!this.isAttached) {
+ this.attachToDOM();
+ }
+
+ this.root.removeClass('hide').addClass('show');
+ };
+
+ /**
+ * Hide this backdrop.
+ *
+ * @method hide
+ */
+ ModalBackdrop.prototype.hide = function() {
+ if (!this.isVisible()) {
+ return;
+ }
+
+ if (this.hasTransitions()) {
+ // Wait for CSS transitions to complete before hiding the element.
+ this.getRoot().one('transitionend webkitTransitionEnd oTransitionEnd', function() {
+ this.getRoot().removeClass('show').addClass('hide');
+ }.bind(this));
+ } else {
+ this.getRoot().removeClass('show').addClass('hide');
+ }
+ };
+
+ /**
+ * Remove this backdrop from the DOM.
+ *
+ * @method destroy
+ */
+ ModalBackdrop.prototype.destroy = function() {
+ this.root.remove();
+ };
+
+ return ModalBackdrop;
+});
diff --git a/lib/amd/src/modal_events.js b/lib/amd/src/modal_events.js
new file mode 100644
index 00000000000..81fc683057a
--- /dev/null
+++ b/lib/amd/src/modal_events.js
@@ -0,0 +1,35 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Contain the events a modal can fire.
+ *
+ * @module core/modal_events
+ * @class modal_events
+ * @package core
+ * @copyright 2016 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([], function() {
+ return {
+ // Default events.
+ shown: 'modal:shown',
+ hidden: 'modal:hidden',
+ destroyed: 'modal:destroyed',
+ // ModalSaveCancel events.
+ save: 'modal-save-cancel:save',
+ cancel: 'modal-save-cancel:cancel',
+ };
+});
diff --git a/lib/amd/src/modal_factory.js b/lib/amd/src/modal_factory.js
new file mode 100644
index 00000000000..7932c86ee7f
--- /dev/null
+++ b/lib/amd/src/modal_factory.js
@@ -0,0 +1,150 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Create a modal.
+ *
+ * @module core/modal_factory
+ * @class modal_factory
+ * @package core
+ * @copyright 2016 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/modal_events', 'core/modal', 'core/modal_save_cancel', 'core/templates',
+ 'core/notification', 'core/custom_interaction_events'],
+ function($, ModalEvents, Modal, ModalSaveCancel, Templates, Notification, CustomEvents) {
+
+ // The templates for each type of modal.
+ var TEMPLATES = {
+ DEFAULT: 'core/modal',
+ SAVE_CANCEL: 'core/modal_save_cancel',
+ };
+
+ // The JS classes for each type of modal.
+ var CLASSES = {
+ DEFAULT: Modal,
+ SAVE_CANCEL: ModalSaveCancel,
+ };
+
+ // The available types of modals.
+ var TYPES = {
+ DEFAULT: 'DEFAULT',
+ SAVE_CANCEL: 'SAVE_CANCEL',
+ };
+
+ /**
+ * Set up the events required to show the modal and return focus when the modal
+ * is closed.
+ *
+ * @method setUpTrigger
+ * @param {object} modal The modal instance
+ * @param {object} triggerElement The jQuery element to open the modal
+ */
+ var setUpTrigger = function(modal, triggerElement) {
+ if (typeof triggerElement != 'undefined') {
+ CustomEvents.define(triggerElement, [CustomEvents.events.activate]);
+ triggerElement.on(CustomEvents.events.activate, function() {
+ modal.show();
+ });
+
+ modal.getRoot().on(ModalEvents.hidden, function() {
+ triggerElement.focus();
+ });
+ }
+ };
+
+ /**
+ * Create the correct instance of a modal based on the givem type. Sets up
+ * the trigger between the modal and the trigger element.
+ *
+ * @method createFromElement
+ * @param {string} type A modal type (see TYPES)
+ * @param {object} modalElement The modal HTML jQuery object
+ * @param {object} triggerElement The trigger HTML jQuery object
+ * @return {object} Modal instance
+ */
+ var createFromElement = function(type, modalElement, triggerElement) {
+ modalElement = $(modalElement);
+ var className = CLASSES[type];
+ var modal = new className(modalElement);
+ setUpTrigger(modal, triggerElement);
+
+ return modal;
+ };
+
+ /**
+ * Create the correct modal instance for the given type, including loading
+ * the correct template and setting up the trigger relationship with the
+ * trigger element.
+ *
+ * @method createFromType
+ * @param {string} type A modal type (see TYPES)
+ * @param {object} triggerElement The trigger HTML jQuery object
+ * @return {promise} Resolved with a Modal instance
+ */
+ var createFromType = function(type, triggerElement) {
+ var templateName = TEMPLATES[type];
+
+ return Templates.render(templateName, {})
+ .then(function(html) {
+ var modalElement = $(html);
+ return createFromElement(type, modalElement, triggerElement);
+ })
+ .fail(Notification.exception);
+ };
+
+ /**
+ * Create a Modal instance.
+ *
+ * @method create
+ * @param {object} modalConfig The configuration to create the modal instance
+ * @param {object} triggerElement The trigger HTML jQuery object
+ * @return {promise} Resolved with a Modal instance
+ */
+ var create = function(modalConfig, triggerElement) {
+ var type = modalConfig.type || TYPES.DEFAULT;
+ var isLarge = modalConfig.large ? true : false;
+
+ if (!TYPES[type]) {
+ type = TYPES.DEFAULT;
+ }
+
+ return createFromType(type, triggerElement)
+ .then(function(modal) {
+ if (typeof modalConfig.title != 'undefined') {
+ modal.setTitle(modalConfig.title);
+ }
+
+ if (typeof modalConfig.body != 'undefined') {
+ modal.setBody(modalConfig.body);
+ }
+
+ if (typeof modalConfig.footer != 'undefined') {
+ modal.setFooter(modalConfig.footer);
+ }
+
+ if (isLarge) {
+ modal.setLarge();
+ }
+
+ return modal;
+ });
+ };
+
+ return {
+ create: create,
+ types: TYPES,
+ };
+});
diff --git a/lib/amd/src/modal_save_cancel.js b/lib/amd/src/modal_save_cancel.js
new file mode 100644
index 00000000000..6aa133c80dc
--- /dev/null
+++ b/lib/amd/src/modal_save_cancel.js
@@ -0,0 +1,92 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Contain the logic for the save/cancel modal.
+ *
+ * @module core/modal_save_cancel
+ * @class modal_save_cancel
+ * @package core
+ * @copyright 2016 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/notification', 'core/custom_interaction_events', 'core/modal', 'core/modal_events'],
+ function($, Notification, CustomEvents, Modal, ModalEvents) {
+
+ var SELECTORS = {
+ SAVE_BUTTON: '[data-action="save"]',
+ CANCEL_BUTTON: '[data-action="cancel"]',
+ };
+
+ /**
+ * Constructor for the Modal.
+ *
+ * @param {object} root The root jQuery element for the modal
+ */
+ var ModalSaveCancel = function(root) {
+ Modal.call(this, root);
+
+ if (!this.getFooter().find(SELECTORS.SAVE_BUTTON).length) {
+ Notification.exception({message: 'No save button found'});
+ }
+
+ if (!this.getFooter().find(SELECTORS.CANCEL_BUTTON).length) {
+ Notification.exception({message: 'No cancel button found'});
+ }
+ };
+
+ ModalSaveCancel.prototype = Object.create(Modal.prototype);
+ ModalSaveCancel.prototype.constructor = ModalSaveCancel;
+
+ /**
+ * Override parent implementation to prevent changing the footer content.
+ */
+ ModalSaveCancel.prototype.setFooter = function() {
+ Notification.exception({message: 'Can not change the footer of a save cancel modal'});
+ return;
+ };
+
+ /**
+ * Set up all of the event handling for the modal.
+ *
+ * @method registerEventListeners
+ */
+ ModalSaveCancel.prototype.registerEventListeners = function() {
+ // Apply parent event listeners.
+ Modal.prototype.registerEventListeners.call(this);
+
+ this.getModal().on(CustomEvents.events.activate, SELECTORS.SAVE_BUTTON, function(e, data) {
+ var saveEvent = $.Event(ModalEvents.save);
+ this.getRoot().trigger(saveEvent, this);
+
+ if (!saveEvent.isDefaultPrevented()) {
+ this.hide();
+ data.originalEvent.preventDefault();
+ }
+ }.bind(this));
+
+ this.getModal().on(CustomEvents.events.activate, SELECTORS.CANCEL_BUTTON, function(e, data) {
+ var cancelEvent = $.Event(ModalEvents.cancel);
+ this.getRoot().trigger(cancelEvent, this);
+
+ if (!cancelEvent.isDefaultPrevented()) {
+ this.hide();
+ data.originalEvent.preventDefault();
+ }
+ }.bind(this));
+ };
+
+ return ModalSaveCancel;
+});
diff --git a/lib/templates/loading.mustache b/lib/templates/loading.mustache
new file mode 100644
index 00000000000..1c5552503bb
--- /dev/null
+++ b/lib/templates/loading.mustache
@@ -0,0 +1,33 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template core/loading
+
+ Moodle loading template.
+
+ The purpose of this template is to render a loading animation.
+
+ Classes required for JS:
+ * none
+
+ Data attributes required for JS:
+ * none
+
+ Context variables required for this template:
+ *
+}}
+{{#pix}} y/loading, core, {{#str}} loading {{/str}} {{/pix}}
diff --git a/lib/templates/modal.mustache b/lib/templates/modal.mustache
new file mode 100644
index 00000000000..968847d5402
--- /dev/null
+++ b/lib/templates/modal.mustache
@@ -0,0 +1,65 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template core/modal
+
+ Moodle modal template.
+
+ The purpose of this template is to render a modal
+
+ Classes required for JS:
+ * none
+
+ Data attributes required for JS:
+ * none
+
+ Context variables required for this template:
+ * title A cleaned string (use clean_text()) to display.
+ * body HTML content for the boday
+ * footer HTML content for the footer
+
+ Example context (json):
+ {
+ "title": "Example modal",
+ "body": "Some example content for the body",
+ "footer": "Footer text, right here!",
+ }
+}}
+
+