// # UI Modal // A modal displays content that temporarily blocks interactions with a web site ;(function ( $, window, document, undefined ) { $.fn.modal = function(parameters) { var // ## Group // Store a cached version of all elements initialized together $allModules = $(this), // Store cached versions of elements which are the same across all instances $window = $(window), $document = $(document), // Save a reference to the selector used for logs moduleSelector = $allModules.selector || '', // Store a reference to current time for performance time = new Date().getTime(), performance = [], // Save a reference to arguments to access this 'special variable' outside of scope query = arguments[0], methodInvoked = (typeof query == 'string'), queryArguments = [].slice.call(arguments, 1), returnedValue ; // ## Singular // Iterate over all elements to initialize module $allModules .each(function() { var // Extend setting if parameters is a settings object settings = ( $.isPlainObject(parameters) ) ? $.extend(true, {}, $.fn.modal.settings, parameters) : $.extend({}, $.fn.modal.settings), // Shortcut values for common settings selector = settings.selector, className = settings.className, namespace = settings.namespace, error = settings.error, // Namespace to store DOM Events eventNamespace = '.' + namespace, // Namespace to store instance in metadata moduleNamespace = 'module-' + namespace, // Cache DOM elements $module = $(this), $context = $(settings.context), $otherModals = $allModules.not($module), $close = $module.find(selector.close), // Set up blank variables for data stored across an instance $focusedElement, $dimmable, $dimmer, // Save a reference to the 'pure' DOM element node and current defined instance element = this, instance = $module.data(moduleNamespace), module ; // ## Module Behavior module = { // ### Initialize // Attaches modal events to page initialize: function() { // Debug/Verbose allows for a trace to be passed for the javascript console module.verbose('Initializing dimmer', $context); // Make sure all dependencies are available or provide an error if(typeof $.fn.dimmer === undefined) { module.error(error.dimmer); return; } // Initialize a dimmer in the current modal context that // Dimmer appears and hides slightly quicker than modal to avoid race conditions in callbacks $dimmable = $context .dimmer({ closable : false, show : settings.duration * 0.95, hide : settings.duration * 1.05 }) .dimmer('add content', $module) ; // Use Dimmer's Behavior API to retrieve a reference to the dimmer DOM element $dimmer = $dimmable .dimmer('get dimmer') ; module.verbose('Attaching close events', $close); // Attach some dimmer event $close .on('click' + eventNamespace, module.event.close) ; $window .on('resize', function() { module.event.debounce(module.refresh, 50); }) ; module.instantiate(); }, // Store instance in metadata so it can be retrieved and modified on future invocations instantiate: function() { module.verbose('Storing instance of modal'); // Immediately define possibly undefined instance instance = module; // Store new refrence in metadata $module .data(moduleNamespace, instance) ; }, // ### Destroy // Remove all module data from metadata and remove all events destroy: function() { module.verbose('Destroying previous modal'); $module .removeData(moduleNamespace) .off(eventNamespace) ; // Child elements must also have their events removed $close .off(eventNamespace) ; // Destroy the initialized dimmer $context .dimmer('destroy') ; }, // ### Refresh // Modal must modify its behavior depending on whether or not it can fit refresh: function() { // Remove scrolling/fixed type module.remove.scrolling(); // Cache new module size module.cacheSizes(); // Set type to either scrolling or fixed module.set.type(); module.set.position(); }, // ### Attach events // Attaches any module method to another element selector attachEvents: function(selector, event) { var $toggle = $(selector) ; // default to toggle event if none specified event = $.isFunction(module[event]) ? module[event] : module.toggle ; // determine whether element exists if($toggle.size() > 0) { module.debug('Attaching modal events to element', selector, event); // attach events $toggle .off(eventNamespace) .on('click' + eventNamespace, event) ; } else { module.error(error.notFound); } }, // ### Events // Events contain event handlers where the ``this`` context may be specific to a part of an element event: { // ### Event Close // Make sure appropriate callback occurs before hiding dimmer on close close: function() { module.verbose('Closing element pressed'); // Only hide modal if onDeny or onApprove return true if( $(this).is(selector.approve) ) { if($.proxy(settings.onApprove, element)()) { modal.hide(); } } if( $(this).is(selector.deny) ) { if($.proxy(settings.onDeny, element)()) { modal.hide(); } } else { module.hide(); } }, // ### Event Click // Only allow click event on dimmer to close modal if the click was not inside the dimmer click: function(event) { module.verbose('Determining if event occured on dimmer', event); if( $dimmer.find(event.target).size() === 0 ) { module.hide(); event.stopImmediatePropagation(); } }, // ### Debounce // Make sure resize event fires maximum of once every 50ms debounce: function(method, delay) { clearTimeout(module.timer); module.timer = setTimeout(method, delay); }, // ### Event Keyboard // Determine whether keydown event was on escape key keyboard: function(event) { var keyCode = event.which, escapeKey = 27 ; if(keyCode == escapeKey) { if(settings.closable) { module.debug('Escape key pressed hiding modal'); module.hide(); } else { module.debug('Escape key pressed, but closable is set to false'); } event.preventDefault(); } }, // ### Event Resize // Only refresh if dimmer is visible resize: function() { if( $dimmable.dimmer('is active') ) { module.refresh(); } } }, // ### Toggle // Change visibility depending on current visibility state toggle: function() { if( module.is.active() ) { module.hide(); } else { module.show(); } }, // ### Show // Find position then shows dimmer show: function() { module.showDimmer(); module.cacheSizes(); module.set.position(); module.hideAll(); // #### Loose Coupling if(settings.transition && $.fn.transition !== undefined) { // Use dimmer plugin if available $module .transition(settings.transition + ' in', settings.duration, module.set.active) ; } else { // Otherwise use fallback javascript animation $module .fadeIn(settings.duration, settings.easing, module.set.active) ; } module.debug('Triggering dimmer'); $.proxy(settings.onShow, element)(); }, // ### Show Dimmer // Keep as a separate method to allow programmatic access via // $('.modal').modal('show dimmer'); showDimmer: function() { module.debug('Showing modal'); $dimmable.dimmer('show'); }, // ### Hide // Determine whether to hide dimmer, modal, or both hide: function() { // Only attach close events if modal is allowed to close if(settings.closable) { $dimmer .off('click' + eventNamespace) ; } // Only hide dimmer once if( $dimmable.dimmer('is active') ) { $dimmable.dimmer('hide'); } // Only hide modal once if( module.is.active() ) { module.hideModal(); $.proxy(settings.onHide, element)(); } else { module.debug('Cannot hide modal, modal is not visible'); } }, // ### Hide Dimmer // Hide dimmer from page hideDimmer: function() { module.debug('Hiding dimmer'); $dimmable.dimmer('hide'); }, // ### Hide Modal // Hide modal from page hideModal: function() { module.debug('Hiding modal'); module.remove.keyboardShortcuts(); if(settings.transition && $.fn.transition !== undefined) { $module .transition(settings.transition + ' out', settings.duration, function() { module.remove.active(); module.restore.focus(); }) ; } else { $module .fadeOut(settings.duration, settings.easing, function() { module.remove.active(); module.restore.focus(); }) ; } }, // ### Hide All // Make sure all other modals are hidden before showing a new one hideAll: function() { $otherModals .filter(':visible') .modal('hide') ; }, // ### Add Keyboard Shorcuts // Add keyboard shortcut events to page add: { keyboardShortcuts: function() { module.verbose('Adding keyboard shortcuts'); $document .on('keyup' + eventNamespace, module.event.keyboard) ; } }, save: { // ### Save Focus // Save current focused element on modal show focus: function() { $focusedElement = $(document.activeElement).blur(); } }, restore: { // ### Restore Focus // Restore focus to previous element after modal is closed focus: function() { if($focusedElement && $focusedElement.size() > 0) { $focusedElement.focus(); } } }, // ### Remove // Utilities for removing and adding classes related to state remove: { active: function() { $module.removeClass(className.active); }, keyboardShortcuts: function() { module.verbose('Removing keyboard shortcuts'); $document .off('keyup' + eventNamespace) ; }, scrolling: function() { $dimmable.removeClass(className.scrolling); $module.removeClass(className.scrolling); } }, // ### Cache Sizes // Cache context and modal size to avoid recalculation cacheSizes: function() { module.cache = { height : $module.outerHeight() + settings.offset, contextHeight : (settings.context == 'body') ? $(window).height() : $dimmable.height() }; module.debug('Caching modal and container sizes', module.cache); }, // ### Can Fit // Determine whether modal's cached size is smaller than context can: { fit: function() { return (module.cache.height < module.cache.contextHeight); } }, // ### Is Active // Whether current element is active is: { active: function() { return $module.hasClass(className.active); } }, set: { // ### Set Active active: function() { // Add escape key to close module.add.keyboardShortcuts(); // Save reference to current focused element module.save.focus(); // Set to scrolling or fixed module.set.type(); // Add active class $module .addClass(className.active) ; // Add close events if(settings.closable) { $dimmer .on('click' + eventNamespace, module.event.click) ; } }, // ### Set Scrolling // Add scrolling classes scrolling: function() { $dimmable.addClass(className.scrolling); $module.addClass(className.scrolling); }, // ### Set Type // Set type to fixed or scrolling type: function() { if(module.can.fit()) { module.verbose('Modal fits on screen'); module.remove.scrolling(); } else { module.verbose('Modal cannot fit on screen setting to scrolling'); module.set.scrolling(); } }, // ### Set Position // Position modal in center of page position: function() { module.verbose('Centering modal on page', module.cache, module.cache.height / 2); if(module.can.fit()) { // If fixed position center vertically $module .css({ top: '', marginTop: -(module.cache.height / 2) }) ; } else { // If modal is not fixed position place element 1em below current scrolled position in page $module .css({ marginTop : '1em', top : $document.scrollTop() }) ; } } }, // ### Settings // Used to modify or read setting(s) after initializing setting: function(name, value) { if( $.isPlainObject(name) ) { $.extend(true, settings, name); } else if(value !== undefined) { settings[name] = value; } else { return settings[name]; } }, // ### Internal // Used to modify or read methods from module internal: function(name, value) { if( $.isPlainObject(name) ) { $.extend(true, module, name); } else if(value !== undefined) { module[name] = value; } else { return module[name]; } }, // ### Debug // Debug pushes arguments to the console formatted as a debug statement debug: function() { if(settings.debug) { if(settings.performance) { module.performance.log(arguments); } else { module.debug = Function.prototype.bind.call(console.info, console, settings.name + ':'); module.debug.apply(console, arguments); } } }, // ### Verbose // Calling verbose internally allows for additional data to be logged which can assist in debugging verbose: function() { if(settings.verbose && settings.debug) { if(settings.performance) { module.performance.log(arguments); } else { module.verbose = Function.prototype.bind.call(console.info, console, settings.name + ':'); module.verbose.apply(console, arguments); } } }, // ### Error // Error allows for the module to report named error messages, it may be useful to modify this to push error messages to the user. Error messages are defined in the modules settings object. error: function() { module.error = Function.prototype.bind.call(console.error, console, settings.name + ':'); module.error.apply(console, arguments); }, // ### Performance // This is called on each debug statement and logs the time since the last debug statement. performance: { log: function(message) { var currentTime, executionTime, previousTime ; if(settings.performance) { currentTime = new Date().getTime(); previousTime = time || currentTime; executionTime = currentTime - previousTime; time = currentTime; performance.push({ 'Element' : element, 'Name' : message[0], 'Arguments' : [].slice.call(message, 1) || '', 'Execution Time' : executionTime }); } clearTimeout(module.performance.timer); module.performance.timer = setTimeout(module.performance.display, 100); }, display: function() { var title = settings.name + ':', totalTime = 0 ; time = false; clearTimeout(module.performance.timer); $.each(performance, function(index, data) { totalTime += data['Execution Time']; }); title += ' ' + totalTime + 'ms'; if(moduleSelector) { title += ' \'' + moduleSelector + '\''; } if( (console.group !== undefined || console.table !== undefined) && performance.length > 0) { console.groupCollapsed(title); if(console.table) { console.table(performance); } else { $.each(performance, function(index, data) { console.log(data['Name'] + ': ' + data['Execution Time']+'ms'); }); } console.groupEnd(); } performance = []; } }, // ### Invoke // Invoke is used to match internal functions to string lookups. // `$('.foo').example('invoke', 'set text', 'Foo')` // Method lookups are lazy, looking for many variations of a search string // For example 'set active', will look for both `setText : function(){}`, `set: { text: function(){} }` // Invoke attempts to preserve the 'this' chaining unless a value is returned. // If multiple values are returned an array of values matching up to the length of the selector is returned invoke: function(query, passedArguments, context) { var maxDepth, found, response ; passedArguments = passedArguments || queryArguments; context = element || context; if(typeof query == 'string' && instance !== undefined) { query = query.split(/[\. ]/); maxDepth = query.length - 1; $.each(query, function(depth, value) { var camelCaseValue = (depth != maxDepth) ? value + query[depth + 1].charAt(0).toUpperCase() + query[depth + 1].slice(1) : query ; if( $.isPlainObject( instance[value] ) && (depth != maxDepth) ) { instance = instance[value]; } else if( $.isPlainObject( instance[camelCaseValue] ) && (depth != maxDepth) ) { instance = instance[camelCaseValue]; } else if( instance[value] !== undefined ) { found = instance[value]; return false; } else if( instance[camelCaseValue] !== undefined ) { found = instance[camelCaseValue]; return false; } else { module.error(error.method); return false; } }); } if ( $.isFunction( found ) ) { response = found.apply(context, passedArguments); } else if(found !== undefined) { response = found; } // ### Invocation response // If a user passes in multiple elements invoke will be called for each element and the value will be returned in an array // For example ``$('.things').example('has text')`` with two elements might return ``[true, false]`` and for one element ``true`` if($.isArray(returnedValue)) { returnedValue.push(response); } else if(returnedValue !== undefined) { returnedValue = [returnedValue, response]; } else if(response !== undefined) { returnedValue = response; } return found; } }; // ## Method Invocation // This is where the actual action occurs. // $('.foo').module('set text', 'Ho hum'); // If you call a module with a string parameter you are most likely trying to invoke a function if(methodInvoked) { if(instance === undefined) { module.initialize(); } module.invoke(query); } // if no method call is required we simply initialize the plugin, destroying it if it exists already else { if(instance !== undefined) { module.destroy(); } module.initialize(); } }) ; // ### Chaining // Return either jQuery chain or the returned value from invoke return (returnedValue !== undefined) ? returnedValue : this ; }; // ## Settings // These are the default configuration settings for any element initialized without parameters $.fn.modal.settings = { // Name for debug logs name : 'Modal', // Namespace for events and metadata namespace : 'modal', // Whether to include all logging verbose : true, // Whether to include important logs debug : true, // Whether to show performance traces performance : true, // Whether modal is able to be closed by a user closable : true, // Context which modal will be centered in context : 'body', // Animation duration duration : 500, // Animation easing easing : 'easeOutExpo', // Vertical offset of modal offset : 0, // Transition to use for animation transition : 'scale', // Callback when modal shows onShow : function(){}, // Callback when modal hides onHide : function(){}, // Callback when modal approve action is called onApprove : function(){ return true }, // Callback when modal deny action is called onDeny : function(){ return true }, // List of selectors used to match behavior to DOM elements selector : { close : '.close, .actions .button', approve : '.actions .positive, .actions .approve', deny : '.actions .negative, .actions .cancel' }, // List of error messages displayable to console error : { dimmer : 'UI Dimmer, a required component is not included in this page', method : 'The method you called is not defined.' }, // List of class names used to represent element state className : { active : 'active', scrolling : 'scrolling' }, }; })( jQuery, window , document );