| /** |
| * Featherlight - ultra slim jQuery lightbox |
| * Version 1.7.13 - http://noelboss.github.io/featherlight/ |
| * |
| * Copyright 2018, Noël Raoul Bossart (http://www.noelboss.com) |
| * MIT Licensed. |
| **/ |
| (function($) { |
| "use strict"; |
| |
| if('undefined' === typeof $) { |
| if('console' in window){ window.console.info('Too much lightness, Featherlight needs jQuery.'); } |
| return; |
| } |
| if($.fn.jquery.match(/-ajax/)) { |
| if('console' in window){ window.console.info('Featherlight needs regular jQuery, not the slim version.'); } |
| return; |
| } |
| /* Featherlight is exported as $.featherlight. |
| It is a function used to open a featherlight lightbox. |
| |
| [tech] |
| Featherlight uses prototype inheritance. |
| Each opened lightbox will have a corresponding object. |
| That object may have some attributes that override the |
| prototype's. |
| Extensions created with Featherlight.extend will have their |
| own prototype that inherits from Featherlight's prototype, |
| thus attributes can be overriden either at the object level, |
| or at the extension level. |
| To create callbacks that chain themselves instead of overriding, |
| use chainCallbacks. |
| For those familiar with CoffeeScript, this correspond to |
| Featherlight being a class and the Gallery being a class |
| extending Featherlight. |
| The chainCallbacks is used since we don't have access to |
| CoffeeScript's `super`. |
| */ |
| |
| function Featherlight($content, config) { |
| if(this instanceof Featherlight) { /* called with new */ |
| this.id = Featherlight.id++; |
| this.setup($content, config); |
| this.chainCallbacks(Featherlight._callbackChain); |
| } else { |
| var fl = new Featherlight($content, config); |
| fl.open(); |
| return fl; |
| } |
| } |
| |
| var opened = [], |
| pruneOpened = function(remove) { |
| opened = $.grep(opened, function(fl) { |
| return fl !== remove && fl.$instance.closest('body').length > 0; |
| } ); |
| return opened; |
| }; |
| |
| // Removes keys of `set` from `obj` and returns the removed key/values. |
| function slice(obj, set) { |
| var r = {}; |
| for (var key in obj) { |
| if (key in set) { |
| r[key] = obj[key]; |
| delete obj[key]; |
| } |
| } |
| return r; |
| } |
| |
| // NOTE: List of available [iframe attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe). |
| var iFrameAttributeSet = { |
| allow: 1, allowfullscreen: 1, frameborder: 1, height: 1, longdesc: 1, marginheight: 1, marginwidth: 1, |
| mozallowfullscreen: 1, name: 1, referrerpolicy: 1, sandbox: 1, scrolling: 1, src: 1, srcdoc: 1, style: 1, |
| webkitallowfullscreen: 1, width: 1 |
| }; |
| |
| // Converts camelCased attributes to dasherized versions for given prefix: |
| // parseAttrs({hello: 1, hellFrozeOver: 2}, 'hell') => {froze-over: 2} |
| function parseAttrs(obj, prefix) { |
| var attrs = {}, |
| regex = new RegExp('^' + prefix + '([A-Z])(.*)'); |
| for (var key in obj) { |
| var match = key.match(regex); |
| if (match) { |
| var dasherized = (match[1] + match[2].replace(/([A-Z])/g, '-$1')).toLowerCase(); |
| attrs[dasherized] = obj[key]; |
| } |
| } |
| return attrs; |
| } |
| |
| /* document wide key handler */ |
| var eventMap = { keyup: 'onKeyUp', resize: 'onResize' }; |
| |
| var globalEventHandler = function(event) { |
| $.each(Featherlight.opened().reverse(), function() { |
| if (!event.isDefaultPrevented()) { |
| if (false === this[eventMap[event.type]](event)) { |
| event.preventDefault(); event.stopPropagation(); return false; |
| } |
| } |
| }); |
| }; |
| |
| var toggleGlobalEvents = function(set) { |
| if(set !== Featherlight._globalHandlerInstalled) { |
| Featherlight._globalHandlerInstalled = set; |
| var events = $.map(eventMap, function(_, name) { return name+'.'+Featherlight.prototype.namespace; } ).join(' '); |
| $(window)[set ? 'on' : 'off'](events, globalEventHandler); |
| } |
| }; |
| |
| Featherlight.prototype = { |
| constructor: Featherlight, |
| /*** defaults ***/ |
| /* extend featherlight with defaults and methods */ |
| namespace: 'featherlight', /* Name of the events and css class prefix */ |
| targetAttr: 'data-featherlight', /* Attribute of the triggered element that contains the selector to the lightbox content */ |
| variant: null, /* Class that will be added to change look of the lightbox */ |
| resetCss: false, /* Reset all css */ |
| background: null, /* Custom DOM for the background, wrapper and the closebutton */ |
| openTrigger: 'click', /* Event that triggers the lightbox */ |
| closeTrigger: 'click', /* Event that triggers the closing of the lightbox */ |
| filter: null, /* Selector to filter events. Think $(...).on('click', filter, eventHandler) */ |
| root: 'body', /* Where to append featherlights */ |
| openSpeed: 250, /* Duration of opening animation */ |
| closeSpeed: 250, /* Duration of closing animation */ |
| closeOnClick: 'background', /* Close lightbox on click ('background', 'anywhere' or false) */ |
| closeOnEsc: true, /* Close lightbox when pressing esc */ |
| closeIcon: '✕', /* Close icon */ |
| loading: '', /* Content to show while initial content is loading */ |
| persist: false, /* If set, the content will persist and will be shown again when opened again. 'shared' is a special value when binding multiple elements for them to share the same content */ |
| otherClose: null, /* Selector for alternate close buttons (e.g. "a.close") */ |
| beforeOpen: $.noop, /* Called before open. can return false to prevent opening of lightbox. Gets event as parameter, this contains all data */ |
| beforeContent: $.noop, /* Called when content is loaded. Gets event as parameter, this contains all data */ |
| beforeClose: $.noop, /* Called before close. can return false to prevent opening of lightbox. Gets event as parameter, this contains all data */ |
| afterOpen: $.noop, /* Called after open. Gets event as parameter, this contains all data */ |
| afterContent: $.noop, /* Called after content is ready and has been set. Gets event as parameter, this contains all data */ |
| afterClose: $.noop, /* Called after close. Gets event as parameter, this contains all data */ |
| onKeyUp: $.noop, /* Called on key up for the frontmost featherlight */ |
| onResize: $.noop, /* Called after new content and when a window is resized */ |
| type: null, /* Specify type of lightbox. If unset, it will check for the targetAttrs value. */ |
| contentFilters: ['jquery', 'image', 'html', 'ajax', 'iframe', 'text'], /* List of content filters to use to determine the content */ |
| |
| /*** methods ***/ |
| /* setup iterates over a single instance of featherlight and prepares the background and binds the events */ |
| setup: function(target, config){ |
| /* all arguments are optional */ |
| if (typeof target === 'object' && target instanceof $ === false && !config) { |
| config = target; |
| target = undefined; |
| } |
| |
| var self = $.extend(this, config, {target: target}), |
| css = !self.resetCss ? self.namespace : self.namespace+'-reset', /* by adding -reset to the classname, we reset all the default css */ |
| $background = $(self.background || [ |
| '<div class="'+css+'-loading '+css+'">', |
| '<div class="'+css+'-content">', |
| '<button class="'+css+'-close-icon '+ self.namespace + '-close" aria-label="Close">', |
| self.closeIcon, |
| '</button>', |
| '<div class="'+self.namespace+'-inner">' + self.loading + '</div>', |
| '</div>', |
| '</div>'].join('')), |
| closeButtonSelector = '.'+self.namespace+'-close' + (self.otherClose ? ',' + self.otherClose : ''); |
| |
| self.$instance = $background.clone().addClass(self.variant); /* clone DOM for the background, wrapper and the close button */ |
| |
| /* close when click on background/anywhere/null or closebox */ |
| self.$instance.on(self.closeTrigger+'.'+self.namespace, function(event) { |
| if(event.isDefaultPrevented()) { |
| return; |
| } |
| var $target = $(event.target); |
| if( ('background' === self.closeOnClick && $target.is('.'+self.namespace)) |
| || 'anywhere' === self.closeOnClick |
| || $target.closest(closeButtonSelector).length ){ |
| self.close(event); |
| event.preventDefault(); |
| } |
| }); |
| |
| return this; |
| }, |
| |
| /* this method prepares the content and converts it into a jQuery object or a promise */ |
| getContent: function(){ |
| if(this.persist !== false && this.$content) { |
| return this.$content; |
| } |
| var self = this, |
| filters = this.constructor.contentFilters, |
| readTargetAttr = function(name){ return self.$currentTarget && self.$currentTarget.attr(name); }, |
| targetValue = readTargetAttr(self.targetAttr), |
| data = self.target || targetValue || ''; |
| |
| /* Find which filter applies */ |
| var filter = filters[self.type]; /* check explicit type like {type: 'image'} */ |
| |
| /* check explicit type like data-featherlight="image" */ |
| if(!filter && data in filters) { |
| filter = filters[data]; |
| data = self.target && targetValue; |
| } |
| data = data || readTargetAttr('href') || ''; |
| |
| /* check explicity type & content like {image: 'photo.jpg'} */ |
| if(!filter) { |
| for(var filterName in filters) { |
| if(self[filterName]) { |
| filter = filters[filterName]; |
| data = self[filterName]; |
| } |
| } |
| } |
| |
| /* otherwise it's implicit, run checks */ |
| if(!filter) { |
| var target = data; |
| data = null; |
| $.each(self.contentFilters, function() { |
| filter = filters[this]; |
| if(filter.test) { |
| data = filter.test(target); |
| } |
| if(!data && filter.regex && target.match && target.match(filter.regex)) { |
| data = target; |
| } |
| return !data; |
| }); |
| if(!data) { |
| if('console' in window){ window.console.error('Featherlight: no content filter found ' + (target ? ' for "' + target + '"' : ' (no target specified)')); } |
| return false; |
| } |
| } |
| /* Process it */ |
| return filter.process.call(self, data); |
| }, |
| |
| /* sets the content of $instance to $content */ |
| setContent: function($content){ |
| this.$instance.removeClass(this.namespace+'-loading'); |
| |
| /* we need a special class for the iframe */ |
| this.$instance.toggleClass(this.namespace+'-iframe', $content.is('iframe')); |
| |
| /* replace content by appending to existing one before it is removed |
| this insures that featherlight-inner remain at the same relative |
| position to any other items added to featherlight-content */ |
| this.$instance.find('.'+this.namespace+'-inner') |
| .not($content) /* excluded new content, important if persisted */ |
| .slice(1).remove().end() /* In the unexpected event where there are many inner elements, remove all but the first one */ |
| .replaceWith($.contains(this.$instance[0], $content[0]) ? '' : $content); |
| |
| this.$content = $content.addClass(this.namespace+'-inner'); |
| |
| return this; |
| }, |
| |
| /* opens the lightbox. "this" contains $instance with the lightbox, and with the config. |
| Returns a promise that is resolved after is successfully opened. */ |
| open: function(event){ |
| var self = this; |
| self.$instance.hide().appendTo(self.root); |
| if((!event || !event.isDefaultPrevented()) |
| && self.beforeOpen(event) !== false) { |
| |
| if(event){ |
| event.preventDefault(); |
| } |
| var $content = self.getContent(); |
| |
| if($content) { |
| opened.push(self); |
| |
| toggleGlobalEvents(true); |
| |
| self.$instance.fadeIn(self.openSpeed); |
| self.beforeContent(event); |
| |
| /* Set content and show */ |
| return $.when($content) |
| .always(function($content){ |
| self.setContent($content); |
| self.afterContent(event); |
| }) |
| .then(self.$instance.promise()) |
| /* Call afterOpen after fadeIn is done */ |
| .done(function(){ self.afterOpen(event); }); |
| } |
| } |
| self.$instance.detach(); |
| return $.Deferred().reject().promise(); |
| }, |
| |
| /* closes the lightbox. "this" contains $instance with the lightbox, and with the config |
| returns a promise, resolved after the lightbox is successfully closed. */ |
| close: function(event){ |
| var self = this, |
| deferred = $.Deferred(); |
| |
| if(self.beforeClose(event) === false) { |
| deferred.reject(); |
| } else { |
| |
| if (0 === pruneOpened(self).length) { |
| toggleGlobalEvents(false); |
| } |
| |
| self.$instance.fadeOut(self.closeSpeed,function(){ |
| self.$instance.detach(); |
| self.afterClose(event); |
| deferred.resolve(); |
| }); |
| } |
| return deferred.promise(); |
| }, |
| |
| /* resizes the content so it fits in visible area and keeps the same aspect ratio. |
| Does nothing if either the width or the height is not specified. |
| Called automatically on window resize. |
| Override if you want different behavior. */ |
| resize: function(w, h) { |
| if (w && h) { |
| /* Reset apparent image size first so container grows */ |
| this.$content.css('width', '').css('height', ''); |
| /* Calculate the worst ratio so that dimensions fit */ |
| /* Note: -1 to avoid rounding errors */ |
| var ratio = Math.max( |
| w / (this.$content.parent().width()-1), |
| h / (this.$content.parent().height()-1)); |
| /* Resize content */ |
| if (ratio > 1) { |
| ratio = h / Math.floor(h / ratio); /* Round ratio down so height calc works */ |
| this.$content.css('width', '' + w / ratio + 'px').css('height', '' + h / ratio + 'px'); |
| } |
| } |
| }, |
| |
| /* Utility function to chain callbacks |
| [Warning: guru-level] |
| Used be extensions that want to let users specify callbacks but |
| also need themselves to use the callbacks. |
| The argument 'chain' has callback names as keys and function(super, event) |
| as values. That function is meant to call `super` at some point. |
| */ |
| chainCallbacks: function(chain) { |
| for (var name in chain) { |
| this[name] = $.proxy(chain[name], this, $.proxy(this[name], this)); |
| } |
| } |
| }; |
| |
| $.extend(Featherlight, { |
| id: 0, /* Used to id single featherlight instances */ |
| autoBind: '[data-featherlight]', /* Will automatically bind elements matching this selector. Clear or set before onReady */ |
| defaults: Featherlight.prototype, /* You can access and override all defaults using $.featherlight.defaults, which is just a synonym for $.featherlight.prototype */ |
| /* Contains the logic to determine content */ |
| contentFilters: { |
| jquery: { |
| regex: /^[#.]\w/, /* Anything that starts with a class name or identifiers */ |
| test: function(elem) { return elem instanceof $ && elem; }, |
| process: function(elem) { return this.persist !== false ? $(elem) : $(elem).clone(true); } |
| }, |
| image: { |
| regex: /\.(png|jpg|jpeg|gif|tiff?|bmp|svg)(\?\S*)?$/i, |
| process: function(url) { |
| var self = this, |
| deferred = $.Deferred(), |
| img = new Image(), |
| $img = $('<img src="'+url+'" alt="" class="'+self.namespace+'-image" />'); |
| img.onload = function() { |
| /* Store naturalWidth & height for IE8 */ |
| $img.naturalWidth = img.width; $img.naturalHeight = img.height; |
| deferred.resolve( $img ); |
| }; |
| img.onerror = function() { deferred.reject($img); }; |
| img.src = url; |
| return deferred.promise(); |
| } |
| }, |
| html: { |
| regex: /^\s*<[\w!][^<]*>/, /* Anything that starts with some kind of valid tag */ |
| process: function(html) { return $(html); } |
| }, |
| ajax: { |
| regex: /./, /* At this point, any content is assumed to be an URL */ |
| process: function(url) { |
| var self = this, |
| deferred = $.Deferred(); |
| /* we are using load so one can specify a target with: url.html #targetelement */ |
| var $container = $('<div></div>').load(url, function(response, status){ |
| if ( status !== "error" ) { |
| deferred.resolve($container.contents()); |
| } |
| deferred.fail(); |
| }); |
| return deferred.promise(); |
| } |
| }, |
| iframe: { |
| process: function(url) { |
| var deferred = new $.Deferred(); |
| var $content = $('<iframe/>'); |
| var css = parseAttrs(this, 'iframe'); |
| var attrs = slice(css, iFrameAttributeSet); |
| $content.hide() |
| .attr('src', url) |
| .attr(attrs) |
| .css(css) |
| .on('load', function() { deferred.resolve($content.show()); }) |
| // We can't move an <iframe> and avoid reloading it, |
| // so let's put it in place ourselves right now: |
| .appendTo(this.$instance.find('.' + this.namespace + '-content')); |
| return deferred.promise(); |
| } |
| }, |
| text: { |
| process: function(text) { return $('<div>', {text: text}); } |
| } |
| }, |
| |
| functionAttributes: ['beforeOpen', 'afterOpen', 'beforeContent', 'afterContent', 'beforeClose', 'afterClose'], |
| |
| /*** class methods ***/ |
| /* read element's attributes starting with data-featherlight- */ |
| readElementConfig: function(element, namespace) { |
| var Klass = this, |
| regexp = new RegExp('^data-' + namespace + '-(.*)'), |
| config = {}; |
| if (element && element.attributes) { |
| $.each(element.attributes, function(){ |
| var match = this.name.match(regexp); |
| if (match) { |
| var val = this.value, |
| name = $.camelCase(match[1]); |
| if ($.inArray(name, Klass.functionAttributes) >= 0) { /* jshint -W054 */ |
| val = new Function(val); /* jshint +W054 */ |
| } else { |
| try { val = JSON.parse(val); } |
| catch(e) {} |
| } |
| config[name] = val; |
| } |
| }); |
| } |
| return config; |
| }, |
| |
| /* Used to create a Featherlight extension |
| [Warning: guru-level] |
| Creates the extension's prototype that in turn |
| inherits Featherlight's prototype. |
| Could be used to extend an extension too... |
| This is pretty high level wizardy, it comes pretty much straight |
| from CoffeeScript and won't teach you anything about Featherlight |
| as it's not really specific to this library. |
| My suggestion: move along and keep your sanity. |
| */ |
| extend: function(child, defaults) { |
| /* Setup class hierarchy, adapted from CoffeeScript */ |
| var Ctor = function(){ this.constructor = child; }; |
| Ctor.prototype = this.prototype; |
| child.prototype = new Ctor(); |
| child.__super__ = this.prototype; |
| /* Copy class methods & attributes */ |
| $.extend(child, this, defaults); |
| child.defaults = child.prototype; |
| return child; |
| }, |
| |
| attach: function($source, $content, config) { |
| var Klass = this; |
| if (typeof $content === 'object' && $content instanceof $ === false && !config) { |
| config = $content; |
| $content = undefined; |
| } |
| /* make a copy */ |
| config = $.extend({}, config); |
| |
| /* Only for openTrigger, filter & namespace... */ |
| var namespace = config.namespace || Klass.defaults.namespace, |
| tempConfig = $.extend({}, Klass.defaults, Klass.readElementConfig($source[0], namespace), config), |
| sharedPersist; |
| var handler = function(event) { |
| var $target = $(event.currentTarget); |
| /* ... since we might as well compute the config on the actual target */ |
| var elemConfig = $.extend( |
| {$source: $source, $currentTarget: $target}, |
| Klass.readElementConfig($source[0], tempConfig.namespace), |
| Klass.readElementConfig(event.currentTarget, tempConfig.namespace), |
| config); |
| var fl = sharedPersist || $target.data('featherlight-persisted') || new Klass($content, elemConfig); |
| if(fl.persist === 'shared') { |
| sharedPersist = fl; |
| } else if(fl.persist !== false) { |
| $target.data('featherlight-persisted', fl); |
| } |
| if (elemConfig.$currentTarget.blur) { |
| elemConfig.$currentTarget.blur(); // Otherwise 'enter' key might trigger the dialog again |
| } |
| fl.open(event); |
| }; |
| |
| $source.on(tempConfig.openTrigger+'.'+tempConfig.namespace, tempConfig.filter, handler); |
| |
| return {filter: tempConfig.filter, handler: handler}; |
| }, |
| |
| current: function() { |
| var all = this.opened(); |
| return all[all.length - 1] || null; |
| }, |
| |
| opened: function() { |
| var klass = this; |
| pruneOpened(); |
| return $.grep(opened, function(fl) { return fl instanceof klass; } ); |
| }, |
| |
| close: function(event) { |
| var cur = this.current(); |
| if(cur) { return cur.close(event); } |
| }, |
| |
| /* Does the auto binding on startup. |
| Meant only to be used by Featherlight and its extensions |
| */ |
| _onReady: function() { |
| var Klass = this; |
| if(Klass.autoBind){ |
| var $autobound = $(Klass.autoBind); |
| /* Bind existing elements */ |
| $autobound.each(function(){ |
| Klass.attach($(this)); |
| }); |
| /* If a click propagates to the document level, then we have an item that was added later on */ |
| $(document).on('click', Klass.autoBind, function(evt) { |
| if (evt.isDefaultPrevented()) { |
| return; |
| } |
| var $cur = $(evt.currentTarget); |
| var len = $autobound.length; |
| $autobound = $autobound.add($cur); |
| if(len === $autobound.length) { |
| return; /* already bound */ |
| } |
| /* Bind featherlight */ |
| var data = Klass.attach($cur); |
| /* Dispatch event directly */ |
| if (!data.filter || $(evt.target).parentsUntil($cur, data.filter).length > 0) { |
| data.handler(evt); |
| } |
| }); |
| } |
| }, |
| |
| /* Featherlight uses the onKeyUp callback to intercept the escape key. |
| Private to Featherlight. |
| */ |
| _callbackChain: { |
| onKeyUp: function(_super, event){ |
| if(27 === event.keyCode) { |
| if (this.closeOnEsc) { |
| $.featherlight.close(event); |
| } |
| return false; |
| } else { |
| return _super(event); |
| } |
| }, |
| |
| beforeOpen: function(_super, event) { |
| // Used to disable scrolling |
| $(document.documentElement).addClass('with-featherlight'); |
| |
| // Remember focus: |
| this._previouslyActive = document.activeElement; |
| |
| // Disable tabbing: |
| // See http://stackoverflow.com/questions/1599660/which-html-elements-can-receive-focus |
| this._$previouslyTabbable = $("a, input, select, textarea, iframe, button, iframe, [contentEditable=true]") |
| .not('[tabindex]') |
| .not(this.$instance.find('button')); |
| |
| this._$previouslyWithTabIndex = $('[tabindex]').not('[tabindex="-1"]'); |
| this._previousWithTabIndices = this._$previouslyWithTabIndex.map(function(_i, elem) { |
| return $(elem).attr('tabindex'); |
| }); |
| |
| this._$previouslyWithTabIndex.add(this._$previouslyTabbable).attr('tabindex', -1); |
| |
| if (document.activeElement.blur) { |
| document.activeElement.blur(); |
| } |
| return _super(event); |
| }, |
| |
| afterClose: function(_super, event) { |
| var r = _super(event); |
| // Restore focus |
| var self = this; |
| this._$previouslyTabbable.removeAttr('tabindex'); |
| this._$previouslyWithTabIndex.each(function(i, elem) { |
| $(elem).attr('tabindex', self._previousWithTabIndices[i]); |
| }); |
| this._previouslyActive.focus(); |
| // Restore scroll |
| if(Featherlight.opened().length === 0) { |
| $(document.documentElement).removeClass('with-featherlight'); |
| } |
| return r; |
| }, |
| |
| onResize: function(_super, event){ |
| this.resize(this.$content.naturalWidth, this.$content.naturalHeight); |
| return _super(event); |
| }, |
| |
| afterContent: function(_super, event){ |
| var r = _super(event); |
| this.$instance.find('[autofocus]:not([disabled])').focus(); |
| this.onResize(event); |
| return r; |
| } |
| } |
| }); |
| |
| $.featherlight = Featherlight; |
| |
| /* bind jQuery elements to trigger featherlight */ |
| $.fn.featherlight = function($content, config) { |
| Featherlight.attach(this, $content, config); |
| return this; |
| }; |
| |
| /* bind featherlight on ready if config autoBind is set */ |
| $(document).ready(function(){ Featherlight._onReady(); }); |
| }(jQuery)); |