630 lines
17 KiB
JavaScript
630 lines
17 KiB
JavaScript
|
'use strict';
|
||
|
|
||
|
import $ from 'jquery';
|
||
|
import { onLoad } from './foundation.core.utils';
|
||
|
import { Keyboard } from './foundation.util.keyboard';
|
||
|
import { MediaQuery } from './foundation.util.mediaQuery';
|
||
|
import { Motion } from './foundation.util.motion';
|
||
|
import { Plugin } from './foundation.core.plugin';
|
||
|
import { Triggers } from './foundation.util.triggers';
|
||
|
|
||
|
/**
|
||
|
* Reveal module.
|
||
|
* @module foundation.reveal
|
||
|
* @requires foundation.util.keyboard
|
||
|
* @requires foundation.util.triggers
|
||
|
* @requires foundation.util.mediaQuery
|
||
|
* @requires foundation.util.motion if using animations
|
||
|
*/
|
||
|
|
||
|
class Reveal extends Plugin {
|
||
|
/**
|
||
|
* Creates a new instance of Reveal.
|
||
|
* @class
|
||
|
* @name Reveal
|
||
|
* @param {jQuery} element - jQuery object to use for the modal.
|
||
|
* @param {Object} options - optional parameters.
|
||
|
*/
|
||
|
_setup(element, options) {
|
||
|
this.$element = element;
|
||
|
this.options = $.extend({}, Reveal.defaults, this.$element.data(), options);
|
||
|
this.className = 'Reveal'; // ie9 back compat
|
||
|
this._init();
|
||
|
|
||
|
// Triggers init is idempotent, just need to make sure it is initialized
|
||
|
Triggers.init($);
|
||
|
|
||
|
Keyboard.register('Reveal', {
|
||
|
'ESCAPE': 'close',
|
||
|
});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Initializes the modal by adding the overlay and close buttons, (if selected).
|
||
|
* @private
|
||
|
*/
|
||
|
_init() {
|
||
|
MediaQuery._init();
|
||
|
this.id = this.$element.attr('id');
|
||
|
this.isActive = false;
|
||
|
this.cached = {mq: MediaQuery.current};
|
||
|
|
||
|
this.$anchor = $(`[data-open="${this.id}"]`).length ? $(`[data-open="${this.id}"]`) : $(`[data-toggle="${this.id}"]`);
|
||
|
this.$anchor.attr({
|
||
|
'aria-controls': this.id,
|
||
|
'aria-haspopup': true,
|
||
|
'tabindex': 0
|
||
|
});
|
||
|
|
||
|
if (this.options.fullScreen || this.$element.hasClass('full')) {
|
||
|
this.options.fullScreen = true;
|
||
|
this.options.overlay = false;
|
||
|
}
|
||
|
if (this.options.overlay && !this.$overlay) {
|
||
|
this.$overlay = this._makeOverlay(this.id);
|
||
|
}
|
||
|
|
||
|
this.$element.attr({
|
||
|
'role': 'dialog',
|
||
|
'aria-hidden': true,
|
||
|
'data-yeti-box': this.id,
|
||
|
'data-resize': this.id
|
||
|
});
|
||
|
|
||
|
if(this.$overlay) {
|
||
|
this.$element.detach().appendTo(this.$overlay);
|
||
|
} else {
|
||
|
this.$element.detach().appendTo($(this.options.appendTo));
|
||
|
this.$element.addClass('without-overlay');
|
||
|
}
|
||
|
this._events();
|
||
|
if (this.options.deepLink && window.location.hash === ( `#${this.id}`)) {
|
||
|
this.onLoadListener = onLoad($(window), () => this.open());
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Creates an overlay div to display behind the modal.
|
||
|
* @private
|
||
|
*/
|
||
|
_makeOverlay() {
|
||
|
var additionalOverlayClasses = '';
|
||
|
|
||
|
if (this.options.additionalOverlayClasses) {
|
||
|
additionalOverlayClasses = ' ' + this.options.additionalOverlayClasses;
|
||
|
}
|
||
|
|
||
|
return $('<div></div>')
|
||
|
.addClass('reveal-overlay' + additionalOverlayClasses)
|
||
|
.appendTo(this.options.appendTo);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Updates position of modal
|
||
|
* TODO: Figure out if we actually need to cache these values or if it doesn't matter
|
||
|
* @private
|
||
|
*/
|
||
|
_updatePosition() {
|
||
|
var width = this.$element.outerWidth();
|
||
|
var outerWidth = $(window).width();
|
||
|
var height = this.$element.outerHeight();
|
||
|
var outerHeight = $(window).height();
|
||
|
var left, top = null;
|
||
|
if (this.options.hOffset === 'auto') {
|
||
|
left = parseInt((outerWidth - width) / 2, 10);
|
||
|
} else {
|
||
|
left = parseInt(this.options.hOffset, 10);
|
||
|
}
|
||
|
if (this.options.vOffset === 'auto') {
|
||
|
if (height > outerHeight) {
|
||
|
top = parseInt(Math.min(100, outerHeight / 10), 10);
|
||
|
} else {
|
||
|
top = parseInt((outerHeight - height) / 4, 10);
|
||
|
}
|
||
|
} else if (this.options.vOffset !== null) {
|
||
|
top = parseInt(this.options.vOffset, 10);
|
||
|
}
|
||
|
|
||
|
if (top !== null) {
|
||
|
this.$element.css({top: top + 'px'});
|
||
|
}
|
||
|
|
||
|
// only worry about left if we don't have an overlay or we have a horizontal offset,
|
||
|
// otherwise we're perfectly in the middle
|
||
|
if (!this.$overlay || (this.options.hOffset !== 'auto')) {
|
||
|
this.$element.css({left: left + 'px'});
|
||
|
this.$element.css({margin: '0px'});
|
||
|
}
|
||
|
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Adds event handlers for the modal.
|
||
|
* @private
|
||
|
*/
|
||
|
_events() {
|
||
|
var _this = this;
|
||
|
|
||
|
this.$element.on({
|
||
|
'open.zf.trigger': this.open.bind(this),
|
||
|
'close.zf.trigger': (event, $element) => {
|
||
|
if ((event.target === _this.$element[0]) ||
|
||
|
($(event.target).parents('[data-closable]')[0] === $element)) { // only close reveal when it's explicitly called
|
||
|
return this.close.apply(this);
|
||
|
}
|
||
|
},
|
||
|
'toggle.zf.trigger': this.toggle.bind(this),
|
||
|
'resizeme.zf.trigger': function() {
|
||
|
_this._updatePosition();
|
||
|
}
|
||
|
});
|
||
|
|
||
|
if (this.options.closeOnClick && this.options.overlay) {
|
||
|
this.$overlay.off('.zf.reveal').on('click.zf.reveal', function(e) {
|
||
|
if (e.target === _this.$element[0] ||
|
||
|
$.contains(_this.$element[0], e.target) ||
|
||
|
!$.contains(document, e.target)) {
|
||
|
return;
|
||
|
}
|
||
|
_this.close();
|
||
|
});
|
||
|
}
|
||
|
if (this.options.deepLink) {
|
||
|
$(window).on(`hashchange.zf.reveal:${this.id}`, this._handleState.bind(this));
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Handles modal methods on back/forward button clicks or any other event that triggers hashchange.
|
||
|
* @private
|
||
|
*/
|
||
|
_handleState(e) {
|
||
|
if(window.location.hash === ( '#' + this.id) && !this.isActive){ this.open(); }
|
||
|
else{ this.close(); }
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Disables the scroll when Reveal is shown to prevent the background from shifting
|
||
|
* @param {number} scrollTop - Scroll to visually apply, window current scroll by default
|
||
|
*/
|
||
|
_disableScroll(scrollTop) {
|
||
|
scrollTop = scrollTop || $(window).scrollTop();
|
||
|
if ($(document).height() > $(window).height()) {
|
||
|
$("html")
|
||
|
.css("top", -scrollTop);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Reenables the scroll when Reveal closes
|
||
|
* @param {number} scrollTop - Scroll to restore, html "top" property by default (as set by `_disableScroll`)
|
||
|
*/
|
||
|
_enableScroll(scrollTop) {
|
||
|
scrollTop = scrollTop || parseInt($("html").css("top"));
|
||
|
if ($(document).height() > $(window).height()) {
|
||
|
$("html")
|
||
|
.css("top", "");
|
||
|
$(window).scrollTop(-scrollTop);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Opens the modal controlled by `this.$anchor`, and closes all others by default.
|
||
|
* @function
|
||
|
* @fires Reveal#closeme
|
||
|
* @fires Reveal#open
|
||
|
*/
|
||
|
open() {
|
||
|
// either update or replace browser history
|
||
|
const hash = `#${this.id}`;
|
||
|
if (this.options.deepLink && window.location.hash !== hash) {
|
||
|
|
||
|
if (window.history.pushState) {
|
||
|
if (this.options.updateHistory) {
|
||
|
window.history.pushState({}, '', hash);
|
||
|
} else {
|
||
|
window.history.replaceState({}, '', hash);
|
||
|
}
|
||
|
} else {
|
||
|
window.location.hash = hash;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Remember anchor that opened it to set focus back later, have general anchors as fallback
|
||
|
this.$activeAnchor = $(document.activeElement).is(this.$anchor) ? $(document.activeElement) : this.$anchor;
|
||
|
|
||
|
this.isActive = true;
|
||
|
|
||
|
// Make elements invisible, but remove display: none so we can get size and positioning
|
||
|
this.$element
|
||
|
.css({ 'visibility': 'hidden' })
|
||
|
.show()
|
||
|
.scrollTop(0);
|
||
|
if (this.options.overlay) {
|
||
|
this.$overlay.css({'visibility': 'hidden'}).show();
|
||
|
}
|
||
|
|
||
|
this._updatePosition();
|
||
|
|
||
|
this.$element
|
||
|
.hide()
|
||
|
.css({ 'visibility': '' });
|
||
|
|
||
|
if(this.$overlay) {
|
||
|
this.$overlay.css({'visibility': ''}).hide();
|
||
|
if(this.$element.hasClass('fast')) {
|
||
|
this.$overlay.addClass('fast');
|
||
|
} else if (this.$element.hasClass('slow')) {
|
||
|
this.$overlay.addClass('slow');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
|
||
|
if (!this.options.multipleOpened) {
|
||
|
/**
|
||
|
* Fires immediately before the modal opens.
|
||
|
* Closes any other modals that are currently open
|
||
|
* @event Reveal#closeme
|
||
|
*/
|
||
|
this.$element.trigger('closeme.zf.reveal', this.id);
|
||
|
}
|
||
|
|
||
|
this._disableScroll();
|
||
|
|
||
|
var _this = this;
|
||
|
|
||
|
// Motion UI method of reveal
|
||
|
if (this.options.animationIn) {
|
||
|
function afterAnimation(){
|
||
|
_this.$element
|
||
|
.attr({
|
||
|
'aria-hidden': false,
|
||
|
'tabindex': -1
|
||
|
})
|
||
|
.focus();
|
||
|
_this._addGlobalClasses();
|
||
|
Keyboard.trapFocus(_this.$element);
|
||
|
}
|
||
|
if (this.options.overlay) {
|
||
|
Motion.animateIn(this.$overlay, 'fade-in');
|
||
|
}
|
||
|
Motion.animateIn(this.$element, this.options.animationIn, () => {
|
||
|
if(this.$element) { // protect against object having been removed
|
||
|
this.focusableElements = Keyboard.findFocusable(this.$element);
|
||
|
afterAnimation();
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
// jQuery method of reveal
|
||
|
else {
|
||
|
if (this.options.overlay) {
|
||
|
this.$overlay.show(0);
|
||
|
}
|
||
|
this.$element.show(this.options.showDelay);
|
||
|
}
|
||
|
|
||
|
// handle accessibility
|
||
|
this.$element
|
||
|
.attr({
|
||
|
'aria-hidden': false,
|
||
|
'tabindex': -1
|
||
|
})
|
||
|
.focus();
|
||
|
Keyboard.trapFocus(this.$element);
|
||
|
|
||
|
this._addGlobalClasses();
|
||
|
|
||
|
this._addGlobalListeners();
|
||
|
|
||
|
/**
|
||
|
* Fires when the modal has successfully opened.
|
||
|
* @event Reveal#open
|
||
|
*/
|
||
|
this.$element.trigger('open.zf.reveal');
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Adds classes and listeners on document required by open modals.
|
||
|
*
|
||
|
* The following classes are added and updated:
|
||
|
* - `.is-reveal-open` - Prevents the scroll on document
|
||
|
* - `.zf-has-scroll` - Displays a disabled scrollbar on document if required like if the
|
||
|
* scroll was not disabled. This prevent a "shift" of the page content due
|
||
|
* the scrollbar disappearing when the modal opens.
|
||
|
*
|
||
|
* @private
|
||
|
*/
|
||
|
_addGlobalClasses() {
|
||
|
const updateScrollbarClass = () => {
|
||
|
$('html').toggleClass('zf-has-scroll', !!($(document).height() > $(window).height()));
|
||
|
};
|
||
|
|
||
|
this.$element.on('resizeme.zf.trigger.revealScrollbarListener', () => updateScrollbarClass());
|
||
|
updateScrollbarClass();
|
||
|
$('html').addClass('is-reveal-open');
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Removes classes and listeners on document that were required by open modals.
|
||
|
* @private
|
||
|
*/
|
||
|
_removeGlobalClasses() {
|
||
|
this.$element.off('resizeme.zf.trigger.revealScrollbarListener');
|
||
|
$('html').removeClass('is-reveal-open');
|
||
|
$('html').removeClass('zf-has-scroll');
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Adds extra event handlers for the body and window if necessary.
|
||
|
* @private
|
||
|
*/
|
||
|
_addGlobalListeners() {
|
||
|
var _this = this;
|
||
|
if(!this.$element) { return; } // If we're in the middle of cleanup, don't freak out
|
||
|
this.focusableElements = Keyboard.findFocusable(this.$element);
|
||
|
|
||
|
if (!this.options.overlay && this.options.closeOnClick && !this.options.fullScreen) {
|
||
|
$('body').on('click.zf.reveal', function(e) {
|
||
|
if (e.target === _this.$element[0] ||
|
||
|
$.contains(_this.$element[0], e.target) ||
|
||
|
!$.contains(document, e.target)) { return; }
|
||
|
_this.close();
|
||
|
});
|
||
|
}
|
||
|
|
||
|
if (this.options.closeOnEsc) {
|
||
|
$(window).on('keydown.zf.reveal', function(e) {
|
||
|
Keyboard.handleKey(e, 'Reveal', {
|
||
|
close: function() {
|
||
|
if (_this.options.closeOnEsc) {
|
||
|
_this.close();
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Closes the modal.
|
||
|
* @function
|
||
|
* @fires Reveal#closed
|
||
|
*/
|
||
|
close() {
|
||
|
if (!this.isActive || !this.$element.is(':visible')) {
|
||
|
return false;
|
||
|
}
|
||
|
var _this = this;
|
||
|
|
||
|
// Motion UI method of hiding
|
||
|
if (this.options.animationOut) {
|
||
|
if (this.options.overlay) {
|
||
|
Motion.animateOut(this.$overlay, 'fade-out');
|
||
|
}
|
||
|
|
||
|
Motion.animateOut(this.$element, this.options.animationOut, finishUp);
|
||
|
}
|
||
|
// jQuery method of hiding
|
||
|
else {
|
||
|
this.$element.hide(this.options.hideDelay);
|
||
|
|
||
|
if (this.options.overlay) {
|
||
|
this.$overlay.hide(0, finishUp);
|
||
|
}
|
||
|
else {
|
||
|
finishUp();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Conditionals to remove extra event listeners added on open
|
||
|
if (this.options.closeOnEsc) {
|
||
|
$(window).off('keydown.zf.reveal');
|
||
|
}
|
||
|
|
||
|
if (!this.options.overlay && this.options.closeOnClick) {
|
||
|
$('body').off('click.zf.reveal');
|
||
|
}
|
||
|
|
||
|
this.$element.off('keydown.zf.reveal');
|
||
|
|
||
|
function finishUp() {
|
||
|
|
||
|
// Get the current top before the modal is closed and restore the scroll after.
|
||
|
// TODO: use component properties instead of HTML properties
|
||
|
// See https://github.com/zurb/foundation-sites/pull/10786
|
||
|
var scrollTop = parseInt($("html").css("top"));
|
||
|
|
||
|
if ($('.reveal:visible').length === 0) {
|
||
|
_this._removeGlobalClasses(); // also remove .is-reveal-open from the html element when there is no opened reveal
|
||
|
}
|
||
|
|
||
|
Keyboard.releaseFocus(_this.$element);
|
||
|
|
||
|
_this.$element.attr('aria-hidden', true);
|
||
|
|
||
|
_this._enableScroll(scrollTop);
|
||
|
|
||
|
/**
|
||
|
* Fires when the modal is done closing.
|
||
|
* @event Reveal#closed
|
||
|
*/
|
||
|
_this.$element.trigger('closed.zf.reveal');
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Resets the modal content
|
||
|
* This prevents a running video to keep going in the background
|
||
|
*/
|
||
|
if (this.options.resetOnClose) {
|
||
|
this.$element.html(this.$element.html());
|
||
|
}
|
||
|
|
||
|
this.isActive = false;
|
||
|
// If deepLink and we did not switched to an other modal...
|
||
|
if (_this.options.deepLink && window.location.hash === `#${this.id}`) {
|
||
|
// Remove the history hash
|
||
|
if (window.history.replaceState) {
|
||
|
const urlWithoutHash = window.location.pathname + window.location.search;
|
||
|
if (this.options.updateHistory) {
|
||
|
window.history.pushState({}, '', urlWithoutHash); // remove the hash
|
||
|
} else {
|
||
|
window.history.replaceState('', document.title, urlWithoutHash);
|
||
|
}
|
||
|
} else {
|
||
|
window.location.hash = '';
|
||
|
}
|
||
|
}
|
||
|
|
||
|
this.$activeAnchor.focus();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Toggles the open/closed state of a modal.
|
||
|
* @function
|
||
|
*/
|
||
|
toggle() {
|
||
|
if (this.isActive) {
|
||
|
this.close();
|
||
|
} else {
|
||
|
this.open();
|
||
|
}
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Destroys an instance of a modal.
|
||
|
* @function
|
||
|
*/
|
||
|
_destroy() {
|
||
|
if (this.options.overlay) {
|
||
|
this.$element.appendTo($(this.options.appendTo)); // move $element outside of $overlay to prevent error unregisterPlugin()
|
||
|
this.$overlay.hide().off().remove();
|
||
|
}
|
||
|
this.$element.hide().off();
|
||
|
this.$anchor.off('.zf');
|
||
|
$(window).off(`.zf.reveal:${this.id}`)
|
||
|
if (this.onLoadListener) $(window).off(this.onLoadListener);
|
||
|
|
||
|
if ($('.reveal:visible').length === 0) {
|
||
|
this._removeGlobalClasses(); // also remove .is-reveal-open from the html element when there is no opened reveal
|
||
|
}
|
||
|
};
|
||
|
}
|
||
|
|
||
|
Reveal.defaults = {
|
||
|
/**
|
||
|
* Motion-UI class to use for animated elements. If none used, defaults to simple show/hide.
|
||
|
* @option
|
||
|
* @type {string}
|
||
|
* @default ''
|
||
|
*/
|
||
|
animationIn: '',
|
||
|
/**
|
||
|
* Motion-UI class to use for animated elements. If none used, defaults to simple show/hide.
|
||
|
* @option
|
||
|
* @type {string}
|
||
|
* @default ''
|
||
|
*/
|
||
|
animationOut: '',
|
||
|
/**
|
||
|
* Time, in ms, to delay the opening of a modal after a click if no animation used.
|
||
|
* @option
|
||
|
* @type {number}
|
||
|
* @default 0
|
||
|
*/
|
||
|
showDelay: 0,
|
||
|
/**
|
||
|
* Time, in ms, to delay the closing of a modal after a click if no animation used.
|
||
|
* @option
|
||
|
* @type {number}
|
||
|
* @default 0
|
||
|
*/
|
||
|
hideDelay: 0,
|
||
|
/**
|
||
|
* Allows a click on the body/overlay to close the modal.
|
||
|
* @option
|
||
|
* @type {boolean}
|
||
|
* @default true
|
||
|
*/
|
||
|
closeOnClick: true,
|
||
|
/**
|
||
|
* Allows the modal to close if the user presses the `ESCAPE` key.
|
||
|
* @option
|
||
|
* @type {boolean}
|
||
|
* @default true
|
||
|
*/
|
||
|
closeOnEsc: true,
|
||
|
/**
|
||
|
* If true, allows multiple modals to be displayed at once.
|
||
|
* @option
|
||
|
* @type {boolean}
|
||
|
* @default false
|
||
|
*/
|
||
|
multipleOpened: false,
|
||
|
/**
|
||
|
* Distance, in pixels, the modal should push down from the top of the screen.
|
||
|
* @option
|
||
|
* @type {number|string}
|
||
|
* @default auto
|
||
|
*/
|
||
|
vOffset: 'auto',
|
||
|
/**
|
||
|
* Distance, in pixels, the modal should push in from the side of the screen.
|
||
|
* @option
|
||
|
* @type {number|string}
|
||
|
* @default auto
|
||
|
*/
|
||
|
hOffset: 'auto',
|
||
|
/**
|
||
|
* Allows the modal to be fullscreen, completely blocking out the rest of the view. JS checks for this as well.
|
||
|
* @option
|
||
|
* @type {boolean}
|
||
|
* @default false
|
||
|
*/
|
||
|
fullScreen: false,
|
||
|
/**
|
||
|
* Allows the modal to generate an overlay div, which will cover the view when modal opens.
|
||
|
* @option
|
||
|
* @type {boolean}
|
||
|
* @default true
|
||
|
*/
|
||
|
overlay: true,
|
||
|
/**
|
||
|
* Allows the modal to remove and reinject markup on close. Should be true if using video elements w/o using provider's api, otherwise, videos will continue to play in the background.
|
||
|
* @option
|
||
|
* @type {boolean}
|
||
|
* @default false
|
||
|
*/
|
||
|
resetOnClose: false,
|
||
|
/**
|
||
|
* Link the location hash to the modal.
|
||
|
* Set the location hash when the modal is opened/closed, and open/close the modal when the location changes.
|
||
|
* @option
|
||
|
* @type {boolean}
|
||
|
* @default false
|
||
|
*/
|
||
|
deepLink: false,
|
||
|
/**
|
||
|
* If `deepLink` is enabled, update the browser history with the open modal
|
||
|
* @option
|
||
|
* @default false
|
||
|
*/
|
||
|
updateHistory: false,
|
||
|
/**
|
||
|
* Allows the modal to append to custom div.
|
||
|
* @option
|
||
|
* @type {string}
|
||
|
* @default "body"
|
||
|
*/
|
||
|
appendTo: "body",
|
||
|
/**
|
||
|
* Allows adding additional class names to the reveal overlay.
|
||
|
* @option
|
||
|
* @type {string}
|
||
|
* @default ''
|
||
|
*/
|
||
|
additionalOverlayClasses: ''
|
||
|
};
|
||
|
|
||
|
export {Reveal};
|