File "tec-a11y-dialog.js"
Full Path: /home/londdqdw/public_html/06/wp-content/plugins/the-events-calendar/common/src/resources/js/tec-a11y-dialog.js
File size: 16.05 KB
MIME-type: text/plain
Charset: utf-8
/* global NodeList, Element, define */
(function (global) {
'use strict';
var FOCUSABLE_ELEMENTS = ['a[href]', 'area[href]', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', 'button:not([disabled])', 'iframe', 'object', 'embed', '[contenteditable]', '[tabindex]:not([tabindex^="-"])'];
var TAB_KEY = 9;
var ESCAPE_KEY = 27;
var focusedBeforeDialog;
var browser = browserTests();
var scroll = 0;
var scroller = browser.ie || browser.firefox || (browser.chrome && !browser.edge) ? document.documentElement : document.body;
/**
* Define the constructor to instantiate a dialog
*
* @constructor
* @param {Object} options
*/
function A11yDialog(options) {
this.options = extend({
appendTarget: '',
bodyLock: true,
closeButtonAriaLabel: 'Close this dialog window',
closeButtonClasses: 'a11y-dialog__close-button',
contentClasses: 'a11y-dialog__content',
effect: 'none',
effectSpeed: 300,
effectEasing: 'ease-in-out',
overlayClasses: 'a11y-dialog__overlay',
overlayClickCloses: true,
trigger: null,
wrapperClasses: 'a11y-dialog',
ariaDescribedBy: '',
ariaLabel: '',
ariaLabelledBy: '',
}, options);
// Prebind the functions that will be bound in addEventListener and
// removeEventListener to avoid losing references
this._rendered = false;
this._show = this.show.bind(this);
this._hide = this.hide.bind(this);
this._maintainFocus = this._maintainFocus.bind(this);
this._bindKeypress = this._bindKeypress.bind(this);
this.trigger = isString(this.options.trigger) ? getNodes(this.options.trigger, true, document, true) : this.options.trigger;
this.node = null;
if (!this.trigger) {
console.warn('Lookup for a11y target node failed.');
return;
}
// Keep an object of listener types mapped to callback functions
this._listeners = {};
// Initialise everything needed for the dialog to work properly
this.create();
}
/**
* Set up everything necessary for the dialog to be functioning
*
* @return {this}
*/
A11yDialog.prototype.create = function () {
this.shown = false;
this.trigger.forEach(
function(opener) {
opener.addEventListener('click', this._show);
}.bind(this)
);
// Execute all callbacks registered for the `create` event
this._fire('create');
return this;
};
/**
* Render the dialog
*
* @return {this}
*/
A11yDialog.prototype.render = function (event) {
var contentNode = getNodes(this.trigger[0].dataset.content)[0];
if (!contentNode) {
return this;
}
var ariaDescribedBy = this.options.ariaDescribedBy ? 'aria-describedby="' + this.options.ariaDescribedBy + '" ' : '';
var ariaLabel = this.options.ariaLabel ? 'aria-label="' + this.options.ariaLabel + '"' : '';
var ariaLabelledBy = this.options.ariaLabelledBy ? 'aria-labelledby="' + this.options.ariaLabelledBy + '"' : '';
var node = document.createElement('div');
node.setAttribute('aria-hidden', 'true');
node.classList.add(this.options.wrapperClasses);
node.innerHTML = '<div data-js="a11y-overlay" tabindex="-1" class="' + this.options.overlayClasses + '"></div>\n' +
' <div class="' + this.options.contentClasses + '" role="dialog" aria-modal="true" ' + ariaLabelledBy + ariaDescribedBy + ariaLabel + '>\n' +
' <div role="document">\n' +
' <button ' +
' data-js="a11y-close-button"' +
' class="' + this.options.closeButtonClasses + '" ' +
' type="button" ' +
' aria-label="' + this.options.closeButtonAriaLabel + '"' +
' ></button>\n' +
' ' + contentNode.innerHTML +
' </div>\n' +
' </div>';
var appendTarget = this.trigger;
if (this.options.appendTarget.length) {
appendTarget = document.querySelectorAll(this.options.appendTarget)[0] || this.trigger;
}
insertAfter(node, appendTarget);
this.node = node;
this.overlay = getNodes('a11y-overlay', false, this.node)[0];
this.closeButton = getNodes('a11y-close-button', false, this.node)[0];
if (this.options.overlayClickCloses) {
this.overlay.addEventListener('click', this._hide);
}
this.closeButton.addEventListener('click', this._hide);
this._rendered = true;
this._fire('render', event);
return this;
};
/**
* Show the dialog element, disable all the targets (siblings), trap the
* current focus within it, listen for some specific key presses and fire all
* registered callbacks for `show` event
*
* @param {Event} event
* @return {this}
*/
A11yDialog.prototype.show = function (event) {
// If the dialog is already open, abort
if (this.shown) {
return this;
}
if (!this._rendered) {
this.render(event);
}
if (!this._rendered) {
return this;
}
this.shown = true;
this._applyOpenEffect();
this.node.setAttribute('aria-hidden', 'false');
if (this.options.bodyLock) {
lock();
}
// Keep a reference to the currently focused element to be able to restore
// it later, then set the focus to the first focusable child of the dialog
// element
focusedBeforeDialog = document.activeElement;
setFocusToFirstItem(this.node);
// Bind a focus event listener to the body element to make sure the focus
// stays trapped inside the dialog while open, and start listening for some
// specific key presses (TAB and ESC)
document.body.addEventListener('focus', this._maintainFocus, true);
document.addEventListener('keydown', this._bindKeypress);
// Execute all callbacks registered for the `show` event
this._fire('show', event);
return this;
};
/**
* Hide the dialog element, enable all the targets (siblings), restore the
* focus to the previously active element, stop listening for some specific
* key presses and fire all registered callbacks for `hide` event
*
* @param {Event} event
* @return {this}
*/
A11yDialog.prototype.hide = function (event) {
// If the dialog is already closed, abort
if (!this.shown) {
return this;
}
this.shown = false;
if (this.options.effect === 'none') {
this.node.setAttribute('aria-hidden', 'true');
}
this._applyCloseEffect();
if (this.options.bodyLock) {
unlock();
}
// If their was a focused element before the dialog was opened, restore the
// focus back to it
if (focusedBeforeDialog) {
focusedBeforeDialog.focus();
}
// Remove the focus event listener to the body element and stop listening
// for specific key presses
document.body.removeEventListener('focus', this._maintainFocus, true);
document.removeEventListener('keydown', this._bindKeypress);
// Execute all callbacks registered for the `hide` event
this._fire('hide', event);
return this;
};
/**
* Destroy the current instance (after making sure the dialog has been hidden)
* and remove all associated listeners from dialog openers and closers
*
* @return {this}
*/
A11yDialog.prototype.destroy = function () {
// Hide the dialog to avoid destroying an open instance
this.hide();
this.trigger.forEach(
function(opener) {
opener.removeEventListener('click', this._show);
}.bind(this)
);
if (this._rendered) {
if ( this.options.overlayClickCloses ) {
this.overlay.removeEventListener( 'click', this._hide );
}
this.closeButton.removeEventListener( 'click', this._hide );
}
// Execute all callbacks registered for the `destroy` event
this._fire('destroy');
// Keep an object of listener types mapped to callback functions
this._listeners = {};
return this;
};
/**
* Register a new callback for the given event type
*
* @param {string} type
* @param {Function} handler
*/
A11yDialog.prototype.on = function (type, handler) {
if (typeof this._listeners[type] === 'undefined') {
this._listeners[type] = [];
}
this._listeners[type].push(handler);
return this;
};
/**
* Unregister an existing callback for the given event type
*
* @param {string} type
* @param {Function} handler
*/
A11yDialog.prototype.off = function (type, handler) {
var index = this._listeners[type].indexOf(handler);
if (index > -1) {
this._listeners[type].splice(index, 1);
}
return this;
};
/**
* Iterate over all registered handlers for given type and call them all with
* the dialog element as first argument, event as second argument (if any).
*
* @access private
* @param {string} type
* @param {Event} event
*/
A11yDialog.prototype._fire = function (type, event) {
var listeners = this._listeners[type] || [];
listeners.forEach(function (listener) {
listener(this.node, event);
}.bind(this));
};
/**
* Private event handler used when listening to some specific key presses
* (namely ESCAPE and TAB)
*
* @access private
* @param {Event} event
*/
A11yDialog.prototype._bindKeypress = function (event) {
// If the dialog is shown and the ESCAPE key is being pressed, prevent any
// further effects from the ESCAPE key and hide the dialog
if (this.shown && event.which === ESCAPE_KEY) {
event.preventDefault();
this.hide();
}
// If the dialog is shown and the TAB key is being pressed, make sure the
// focus stays trapped within the dialog element
if (this.shown && event.which === TAB_KEY) {
trapTabKey(this.node, event);
}
};
/**
* Private event handler used when making sure the focus stays within the
* currently open dialog
*
* @access private
* @param {Event} event
*/
A11yDialog.prototype._maintainFocus = function (event) {
// If the dialog is shown and the focus is not within the dialog element,
// move it back to its first focusable child
if (this.shown && !this.node.contains(event.target)) {
setFocusToFirstItem(this.node);
}
};
/**
* Applies effects to the opening of the dialog.
*
* @access private
*/
A11yDialog.prototype._applyOpenEffect = function () {
var _this = this;
setTimeout(function() {
_this.node.classList.add('a11y-dialog--open');
}, 50);
if (this.options.effect === 'fade') {
this.node.style.opacity = '0';
this.node.style.transition = 'opacity ' + this.options.effectSpeed + 'ms ' + this.options.effectEasing;
setTimeout(function() {
_this.node.style.opacity = '1';
}, 50);
}
};
/**
* Applies effects to the closing of the dialog.
*
* @access private
*/
A11yDialog.prototype._applyCloseEffect = function () {
var _this = this;
this.node.classList.remove('a11y-dialog--open');
if (this.options.effect === 'fade') {
this.node.style.opacity = '0';
setTimeout(function() {
_this.node.style.transition = '';
_this.node.setAttribute('aria-hidden', 'true');
}, this.options.effectSpeed);
} else if (this.options.effect === 'css') {
setTimeout(function() {
_this.node.setAttribute('aria-hidden', 'true');
}, this.options.effectSpeed);
}
};
/**
* Highly efficient function to convert a nodelist into a standard array. Allows you to run Array.forEach
*
* @param {Element|NodeList} elements to convert
* @returns {Array} Of converted elements
*/
function convertElements(elements) {
var converted = [];
var i = elements.length;
for (i; i--; converted.unshift(elements[i])); // eslint-disable-line
return converted;
}
/**
* Should be used at all times for getting nodes throughout our app. Please use the data-js attribute whenever possible
*
* @param selector The selector string to search for. If arg 4 is false (default) then we search for [data-js="selector"]
* @param convert Convert the NodeList to an array? Then we can Array.forEach directly. Uses convertElements from above
* @param node Parent node to search from. Defaults to document
* @param custom Is this a custom selector where we don't want to use the data-js attribute?
* @returns {NodeList}
*/
function getNodes(selector, convert, node, custom) {
if (!node) {
node = document;
}
var selectorString = custom ? selector : '[data-js="' + selector + '"]';
var nodes = node.querySelectorAll(selectorString);
if (convert) {
nodes = convertElements(nodes);
}
return nodes;
}
/**
* Query the DOM for nodes matching the given selector, scoped to context (or
* the whole document)
*
* @param {String} selector
* @param {Element} [context = document]
* @return {Array<Element>}
*/
function $$(selector, context) {
return convertElements((context || document).querySelectorAll(selector));
}
/**
* Set the focus to the first focusable child of the given element
*
* @param {Element} node
*/
function setFocusToFirstItem(node) {
var focusableChildren = getFocusableChildren(node);
if (focusableChildren.length) {
focusableChildren[0].focus();
}
}
/**
* Insert a node after another node
*
* @param newNode {Element|NodeList}
* @param referenceNode {Element|NodeList}
*/
function insertAfter(newNode, referenceNode) {
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextElementSibling);
}
/**
* Get the focusable children of the given element
*
* @param {Element} node
* @return {Array<Element>}
*/
function getFocusableChildren(node) {
return $$(FOCUSABLE_ELEMENTS.join(','), node).filter(function (child) {
return !!(child.offsetWidth || child.offsetHeight || child.getClientRects().length);
});
}
function isString(x) {
return Object.prototype.toString.call(x) === "[object String]"
}
function extend(obj, src) {
Object.keys(src).forEach(function(key) { obj[key] = src[key]; });
return obj;
}
/**
* Trap the focus inside the given element
*
* @param {Element} node
* @param {Event} event
*/
function trapTabKey(node, event) {
var focusableChildren = getFocusableChildren(node);
var focusedItemIndex = focusableChildren.indexOf(document.activeElement);
// If the SHIFT key is being pressed while tabbing (moving backwards) and
// the currently focused item is the first one, move the focus to the last
// focusable item from the dialog element
if (event.shiftKey && focusedItemIndex === 0) {
focusableChildren[focusableChildren.length - 1].focus();
event.preventDefault();
// If the SHIFT key is not being pressed (moving forwards) and the currently
// focused item is the last one, move the focus to the first focusable item
// from the dialog element
} else if (!event.shiftKey && focusedItemIndex === focusableChildren.length - 1) {
focusableChildren[0].focus();
event.preventDefault();
}
}
/**
* @function lock
* @description Lock the body at a particular position and prevent scroll,
* use margin to simulate original scroll position.
*/
function lock() {
scroll = scroller.scrollTop;
document.body.classList.add('a11y-dialog__body-locked');
document.body.style.position = 'fixed';
document.body.style.width = '100%';
document.body.style.marginTop = '-' + scroll + 'px';
}
/**
* @function unlock
* @description Unlock the body and return it to its actual scroll position.
*/
function unlock() {
document.body.style.marginTop = '';
document.body.style.position = '';
document.body.style.width = '';
scroller.scrollTop = scroll;
document.body.classList.remove('a11y-dialog__body-locked');
}
function browserTests() {
var android = /(android)/i.test(navigator.userAgent);
var chrome = !!window.chrome;
var firefox = typeof InstallTrigger !== 'undefined';
var ie = document.documentMode;
var edge = !ie && !!window.StyleMedia;
var ios = !!navigator.userAgent.match(/(iPod|iPhone|iPad)/i);
var iosMobile = !!navigator.userAgent.match(/(iPod|iPhone)/i);
var opera = !!window.opera || navigator.userAgent.indexOf(' OPR/') >= 0;
var safari = Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0 || !chrome && !opera && window.webkitAudioContext !== 'undefined'; // eslint-disable-line
var os = navigator.platform;
return {
android: android,
chrome: chrome,
edge: edge,
firefox: firefox,
ie: ie,
ios: ios,
iosMobile: iosMobile,
opera: opera,
safari: safari,
os: os,
}
}
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
module.exports = A11yDialog;
} else if (typeof define === 'function' && define.amd) {
define('A11yDialog', [], function () {
return A11yDialog;
});
} else if (typeof global === 'object') {
global.A11yDialog = A11yDialog;
}
}(typeof global !== 'undefined' ? global : window));