You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

763 lines
24 KiB

// # 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 reference 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 && $module.transition('is supported')) {
// 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('is supported')) {
$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 );