just-add-juice
Version:
A responsive frontend framework with modular components.
802 lines (684 loc) • 30.2 kB
JavaScript
/* ========================================================================
JUICE -> COMPONENTS -> POPOVER
======================================================================== */
;(function (root, factory) {
// Set the plugin name
const plugin_name = 'Popover';
// Check if instantiation should be via amd, commonjs or the browser
if (typeof define === 'function' && define.amd) {
define([], factory(plugin_name));
} else if (typeof exports === 'object') {
module.exports = factory(plugin_name);
} else {
root[plugin_name] = factory(plugin_name);
}
}((window || module || {}), function(plugin_name) {
// Use strict mode
'use strict';
// Create an empty plugin object
const plugin = {};
// Set the plugin defaults
const defaults = {
animation: true,
animationClass: 'has-animation',
animationIn: 'fade-in',
animationOut: 'fade-out',
close: '<button type="button" class="button--component is-small has-large-font-size js-popover-close"><i class="fas fa-times"></i></button>',
color: null,
delayIn: 0,
delayOut: 0,
feedback: null,
next: '<button type="button" class="button--component is-small has-large-font-size js-popover-next"><i class="fas fa-chevron-right"></i></button>',
position: 'top',
prev: '<button type="button" class="button--component is-small has-large-font-size js-popover-prev"><i class="fas fa-chevron-left"></i></button>',
size: null,
callbackInitializeBefore: () => {
console.log('Popover: callbackInitializeBefore');
},
callbackInitializeAfter: () => {
console.log('Popover: callbackInitializeAfter');
},
callbackOpenBefore: () => {
console.log('Popover: callbackOpenBefore');
},
callbackOpenAfter: () => {
console.log('Popover: callbackOpenAfter');
},
callbackCloseBefore: () => {
console.log('Popover: callbackCloseBefore');
},
callbackCloseAfter: () => {
console.log('Popover: callbackCloseAfter');
},
callbackRefreshBefore: () => {
console.log('Popover: callbackRefreshBefore');
},
callbackRefreshAfter: () => {
console.log('Popover: callbackRefreshAfter');
},
callbackDestroyBefore: () => {
console.log('Popover: callbackDestroyBefore');
},
callbackDestroyAfter: () => {
console.log('Popover: callbackDestroyAfter');
},
callbackPrev: () => {
console.log('Popover: callbackPrev');
},
callbackNext: () => {
console.log('Popover: callbackNext');
}
};
/**
* Constructor.
* @param {element} element The initialized element.
* @param {object} options The plugin options.
* @return {void}
*/
function Plugin(element, options) {
// Set the plugin object
plugin.this = this;
plugin.name = plugin_name;
plugin.element = element;
plugin.defaults = defaults;
plugin.options = options;
plugin.settings = Object.assign({}, defaults, options);
// Initialize the plugin
plugin.this.initialize();
}
/**
* Build the popover.
* @param {element} $trigger The trigger.
* @return {element} The popover.
*/
const buildPopover = ($trigger) => {
// Create the popover elements
const $popover = document.createElement('div');
const $content = document.createElement('div');
const $head = document.createElement('div');
const $heading = document.createElement('div');
const $actions = document.createElement('div');
const $body = document.createElement('div');
// Add the popover classes
$popover.classList.add('popover');
$content.classList.add('popover__content');
$head.classList.add('popover__head');
$heading.classList.add('popover__heading');
$actions.classList.add('popover__actions');
$body.classList.add('popover__body');
// Construct the popover
$popover.append($content);
$content.append($head);
$content.append($body);
$head.append($heading);
$head.append($actions);
$heading.insertAdjacentHTML('beforeend', $trigger.dataset.popoverTitle);
$actions.insertAdjacentHTML('beforeend', plugin.settings.close);
$body.insertAdjacentHTML('beforeend', $trigger.dataset.popoverText);
// Check if the popover is grouped
if ($trigger.dataset.popoverGroup) {
// Set the group
const group = $trigger.dataset.popoverGroup;
// Set the triggers
const $triggers = document.querySelectorAll('.has-popover[data-popover-group="' + group + '"]');
// Check if any triggers exist
if ($triggers) {
// Set the triggers total
const triggers_total = $triggers.length;
// Check if there is more than on trigger in the group
if (triggers_total > 1) {
// Check if the current trigger is not the last trigger in the group
if ($trigger != $triggers[triggers_total - 1]) {
// Construct the group navigation
$actions.insertAdjacentHTML('afterbegin', plugin.settings.next);
}
// Check if the current trigger is not the first trigger in the group
if ($trigger != $triggers[0]) {
// Construct the group navigation
$actions.insertAdjacentHTML('afterbegin', plugin.settings.prev);
}
}
}
}
// Set the popover modifiers
const color = $trigger.dataset.popoverColor || plugin.settings.color;
const feedback = $trigger.dataset.popoverFeedback || plugin.settings.feedback;
const position = $trigger.dataset.popoverPosition || plugin.settings.position;
const size = $trigger.dataset.popoverSize || plugin.settings.size;
// Check if a color modifier exists
if (color) {
// Add the color modifier class to the popover
$popover.classList.add(`is-${color}`);
}
// Check if a feedback modifier exists
if (feedback) {
// Add the feedback modifier class to the popover
$popover.classList.add(`has-${feedback}`);
}
// Check if a position modifier exists
if (position) {
// Add the position modifier class to the popover
$popover.classList.add(`popover--${position}`);
}
// Check if a size modifier exists
if (size) {
// Add the size modifier class to the popover
$popover.classList.add(`is-${size}`);
}
// Return the popover
return $popover;
};
/**
* Click event handler to close a popover.
* @param {object} event The event object.
* @return {void}
*/
const clickPopoverCloseEventListener = (event) => {
// Check if the event target is the close or a descendant of the close
if (isTargetSelector(event.target, 'class', 'js-popover-close')) {
// Prevent the default action
event.preventDefault();
// Set the close and popover
const $close = event.target;
const $popover = $close.closest('.popover');
// Close the popover
plugin.this.close($popover);
}
};
/**
* Click event handler to open the previous popover.
* @param {object} event The event object.
* @return {void}
*/
const clickPopoverPrevEventListener = (event) => {
// Check if the event target is the previous or a descendant of the previous
if (isTargetSelector(event.target, 'class', 'js-popover-prev')) {
// Prevent the default action
event.preventDefault();
// Call the prev callback
plugin.settings.callbackPrev.call();
// Set the previous, popover and trigger
const $prev = event.target;
const $popover = $prev.closest('.popover');
const $trigger = $popover.data.trigger;
// Set the group properties
const group = $trigger.dataset.popoverGroup;
const current = parseInt($trigger.dataset.popoverGroupOrder);
const prev = current - 1;
// Set the previous trigger
const $trigger_prev = document.querySelector('.has-popover[data-popover-group="' + group + '"][data-popover-group-order="' + prev + '"]');
// Open the previous popover
plugin.this.open($trigger_prev);
}
};
/**
* Click event handler to open the next popover.
* @param {object} event The event object.
* @return {void}
*/
const clickPopoverNextEventListener = (event) => {
// Check if the event target is the next or a descendant of the next
if (isTargetSelector(event.target, 'class', 'js-popover-next')) {
// Prevent the default action
event.preventDefault();
// Call the next callback
plugin.settings.callbackNext.call();
// Set the next, popover and trigger
const $next = event.target;
const $popover = $next.closest('.popover');
const $trigger = $popover.data.trigger;
// Set the group properties
const group = $trigger.dataset.popoverGroup;
const current = parseInt($trigger.dataset.popoverGroupOrder);
const next = current + 1;
// Set the next trigger
const $trigger_next = document.querySelector('.has-popover[data-popover-group="' + group + '"][data-popover-group-order="' + next + '"]');
// Open the next popover
plugin.this.open($trigger_next);
}
};
/**
* Click event handler to toggle a popover.
* @param {object} event The event object.
* @return {void}
*/
const clickPopoverTriggerEventHandler = (event) => {
// Check if the event target is the trigger or a descendant of the trigger
if (isTargetSelector(event.target, 'class', 'has-popover')) {
// Prevent the default action
event.preventDefault();
// Set the trigger
const $trigger = event.target;
// Check if the trigger data doesn't have an assigned popover
if (!$trigger.data || !('popover' in $trigger.data)) {
// Open the popover
plugin.this.open($trigger);
} else {
// Check if the trigger data doesn't have an assigned popover
if (!('popover' in $trigger.data)) {
// Open the popover
plugin.this.open($trigger);
} else {
// Set the popover
const $popover = $trigger.data.popover;
// Close the popover
plugin.this.close($popover);
}
}
}
};
/**
* Get an elements offset.
* @param {element} $element The element.
* @return {object} The element offset.
*/
const getElementOffset = ($element) => {
// Set the top and left positions
let left = 0;
let top = 0;
// Loop through the dom tree and calculate each parent offset
do {
// Set the top and left positions
top += $element.offsetTop || 0;
left += $element.offsetLeft || 0;
// Set the element to the parent element
$element = $element.offsetParent;
} while ($element);
// Return the top and left positions
return {
top,
left
};
};
/**
* Check if an event target is a target selector or a descendant of a target selector.
* @param {element} target The event target.
* @param {string} attribute The event target attribute to check.
* @param {string} selector The id/class selector.
* @return {bool} True if event target, false otherwise.
*/
const isTargetSelector = (target, attribute, selector) => {
// Check if the target is an element node
if (target.nodeType !== Node.ELEMENT_NODE) {
// Return false
return false;
}
// Start a switch statement for the attribute
switch (attribute) {
// Default
default:
// Return false
return false;
// Class
case 'class':
// Return true if event target, false otherwise
return ((target.classList.contains(selector)) || target.closest(`.${selector}`));
// Id
case ('id'):
// Return true if event target, false otherwise
return ((target.id == selector) || target.closest(`#${selector}`));
}
};
/**
* Set the popover position.
* @param {element} $trigger The trigger.
* @param {element} $popover The popover.
* @return {void}
*/
const positionPopover = ($trigger, $popover) => {
// Set the popover trigger offset
const trigger_offset = getElementOffset($trigger);
// Set the popover trigger left and top positions
const trigger_left = trigger_offset.left;
const trigger_top = trigger_offset.top;
// Set the popover trigger dimensions
const trigger_width = $trigger.offsetWidth;
const trigger_height = $trigger.offsetHeight;
// Set the popover dimensions
const popover_width = $popover.offsetWidth;
const popover_height = $popover.offsetHeight;
// Set the popover left and top positions
let popover_left;
let popover_top;
// Set the popover position modifier
const position = $trigger.dataset.popoverPosition || plugin.settings.position;
// Start a switch statement for the popover position
switch (position) {
// Top (default)
default:
// Set the popover left and top positions and break the switch
popover_left = trigger_left + ((trigger_width - popover_width) / 2);
popover_top = trigger_top - popover_height;
break;
// Right
case 'right':
// Set the popover left and top positions and break the switch
popover_left = trigger_left + trigger_width;
popover_top = trigger_top + ((trigger_height - popover_height) / 2);
break;
// Bottom
case 'bottom':
// Set the popover left and top positions and break the switch
popover_left = trigger_left + ((trigger_width - popover_width) / 2);
popover_top = trigger_top + trigger_height;
break;
// Left
case 'left':
// Set the popover left and top positions and break the switch
popover_left = trigger_left - popover_width;
popover_top = trigger_top + ((trigger_height - popover_height) / 2);
break;
}
// Set the inline top and left positions
$popover.style.left = `${Math.round(popover_left)}px`;
$popover.style.top = `${Math.round(popover_top)}px`;
};
/**
* Trap focus to the popover.
* @param {element} $popover The popover.
* @return {void}
*/
const trapFocus = ($popover) => {
// Set the focusable elements
const $focusables = $popover.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href]:not([disabled]), [tabindex]:not([tabindex="-1"])');
const $focusable_first = $focusables[0];
const $focusable_last = $focusables[$focusables.length - 1];
// Set the keycodes
const keycode_tab = 9;
const keycode_esc = 27;
// Add a keydown event listener to the popover to trap focus
$popover.addEventListener('keydown', function(event) {
// Start a switch event for the keycode
switch (event.keyCode) {
// Tab
case keycode_tab:
// Check if the shift key was pressed
if (event.shiftKey) {
// Check if the active element is the first focusable element
if (document.activeElement === $focusable_first) {
// Prevent the default action
event.preventDefault();
// Focus on the last focusable element
$focusable_last.focus();
}
} else {
if (document.activeElement === $focusable_last) {
// Prevent the default action
event.preventDefault();
// Focus on the first focusable element
$focusable_first.focus();
}
}
// Break the switch
break;
}
});
};
/**
* Public variables and methods.
* @type {object}
*/
Plugin.prototype = {
/**
* Initialize the plugin.
* @param {bool} silent Suppress callbacks.
* @return {void}
*/
initialize: (silent = false) => {
// Destroy the existing initialization silently
plugin.this.destroySilently();
// Check if the callbacks should not be suppressed
if (!silent) {
// Call the initialize before callback
plugin.settings.callbackInitializeBefore.call();
}
// Check if the device is not a touch device
if (document.documentElement.classList.contains('has-no-touch')) {
// Add a click event handler to toggle a popover
document.addEventListener('click', clickPopoverTriggerEventHandler);
// Add a click event handler to open the previous popover
document.addEventListener('click', clickPopoverPrevEventListener);
// Add a click event handler to open the previous popover
document.addEventListener('click', clickPopoverNextEventListener);
// Add a click event handler to the close a popover
document.addEventListener('click', clickPopoverCloseEventListener);
}
// Check if the callbacks should not be suppressed
if (!silent) {
// Call the initialize after callback
plugin.settings.callbackInitializeAfter.call();
}
},
/**
* Open a popover.
* @param {element} $trigger The trigger.
* @return {void}
*/
open: ($trigger, silent = false) => {
// Check if the trigger data doesn't have an assigned popover
if ($trigger && (!$trigger.data || !('popover' in $trigger.data))) {
// Check if the callbacks should not be suppressed
if (!silent) {
// Call the open before callback
plugin.settings.callbackOpenBefore.call(silent);
}
// Set the popovers
const $popovers = document.querySelectorAll('.popover');
// Check if any popovers exist
if ($popovers) {
// Cycle through all of the popovers
$popovers.forEach(($popover) => {
// Close the popover
plugin.this.close($popover);
});
}
// Set the popover
const $popover = buildPopover($trigger);
// Start a timer
setTimeout(() => {
// Append the popover to the body
document.body.appendChild($popover);
// Set the popover tabindex and focus on the popover
$popover.setAttribute('tabindex', -1);
$popover.focus({
preventScroll: true
});
// Trap focus inside the popover
trapFocus($popover);
// Position the popover
positionPopover($trigger, $popover);
// Assign the popover to the trigger data object
$trigger.data = {
popover: $popover
};
// Assign the trigger to the popover data object
$popover.data = {
trigger: $trigger
};
// Show the popover
$popover.style.display = 'block';
// Add the active state hook to the trigger
$trigger.classList.add('is-active');
// Check if the popover is animated
if (plugin.settings.animation) {
// Set the animation in
const animation_in = $trigger.dataset.popoverAnimationIn || plugin.settings.animationIn;
// Set the popover animation classes
$popover.classList.add('is-animating-in', plugin.settings.animationClass, animation_in);
// Add an animation end event listener to the popover
$popover.addEventListener('animationend', (event) => {
// Set the popover animation classes
$popover.classList.remove('is-animating-in', plugin.settings.animationClass, animation_in);
$popover.classList.add('has-animated');
// Check if the callbacks should not be suppressed
if (!silent) {
// Call the open after callback
plugin.settings.callbackOpenAfter.call();
}
}, {
once: true
});
} else {
// Check if the callbacks should not be suppressed
if (!silent) {
// Call the open after callback
plugin.settings.callbackOpenAfter.call();
}
}
}, $trigger.dataset.popoverDelayIn || plugin.settings.delayIn);
}
},
/**
* Close a popover.
* @param {element} $popover The popover.
* @param {bool} silent Suppress callbacks.
* @return {void}
*/
close: ($popover, silent = false) => {
// Check if the popover exists and isn't animating out
if ($popover && !$popover.classList.contains('is-animating-out')) {
// Check if the callbacks should not be suppressed
if (!silent) {
// Call the close before callback
plugin.settings.callbackCloseBefore.call();
}
// Set the trigger
const $trigger = $popover.data.trigger;
// Start a timer
setTimeout(() => {
// Check if the popover is animated
if (plugin.settings.animation) {
// Set the animation out
const animation_out = $trigger.dataset.popoverAnimationOut || plugin.settings.animationOut;
// Set the popover animation classes
$popover.classList.remove('has-animated');
$popover.classList.add('is-animating-out', plugin.settings.animationClass, animation_out);
// Add an animation end event listener to the popover
$popover.addEventListener('animationend', (event) => {
// Remove the popover
$popover.remove();
// Remove the active state hook from the trigger
$trigger.classList.remove('is-active');
// Remove the assigned popover from the trigger data
delete $trigger.data['popover'];
// Check if the callbacks should not be suppressed
if (!silent) {
// Call the close after callback
plugin.settings.callbackCloseAfter.call();
}
}, {
once: true
});
} else {
// Remove the popover
$popover.remove();
// Remove the active state hook from the trigger
$trigger.classList.remove('is-active');
// Remove the assigned popover from the trigger data
delete $trigger.data['popover'];
// Check if the callbacks should not be suppressed
if (!silent) {
// Call the close after callback
plugin.settings.callbackCloseAfter.call();
}
}
}, $trigger.dataset.popoverDelayOut || plugin.settings.delayOut);
}
},
/**
* Refresh the plugins initialization.
* @param {bool} silent Suppress callbacks.
* @return {void}
*/
refresh: (silent = false) => {
// Check if the callbacks should not be suppressed
if (!silent) {
// Call the refresh before callback
plugin.settings.callbackRefreshBefore.call();
}
// Destroy the existing initialization
plugin.this.destroy(silent);
// Initialize the plugin
plugin.this.initialize(silent);
// Check if the callbacks should not be suppressed
if (!silent) {
// Call the refresh after callback
plugin.settings.callbackRefreshAfter.call();
}
},
/**
* Destroy an existing initialization.
* @param {bool} silent Suppress callbacks.
* @return {void}
*/
destroy: (silent = false) => {
// Check if the callbacks should not be suppressed
if (!silent) {
// Call the destroy before callback
plugin.settings.callbackDestroyBefore.call();
}
// Check if the device is not a touch device
if (document.documentElement.classList.contains('has-no-touch')) {
// Set the popovers
const $popovers = document.querySelectorAll('.popover');
// Check if any popovers exist
if ($popovers) {
// Cycle through all of the popovers
$popovers.forEach(($popover) => {
// Close the popover
plugin.this.close($popover);
});
}
// Remove the click event handler to toggle a popover
document.removeEventListener('click', clickPopoverTriggerEventHandler);
// Remove the click event handler to open the previous popover
document.removeEventListener('click', clickPopoverPrevEventListener);
// Remove the click event handler to open the previous popover
document.removeEventListener('click', clickPopoverNextEventListener);
// Remove the click event handler to the close a popover
document.removeEventListener('click', clickPopoverCloseEventListener);
}
// Check if the callbacks should not be suppressed
if (!silent) {
// Call the destroy after callback
plugin.settings.callbackDestroyAfter.call();
}
},
/**
* Call the open method silently.
* @param {element} $trigger The trigger.
* @return {void}
*/
openSilently: ($trigger, silent = false) => {
// Call the open method silently
plugin.this.open(true);
},
/**
* Call the close method silently.
* @param {element} $trigger The trigger.
* @return {void}
*/
closeSilently: ($trigger, silent = false) => {
// Call the close method silently
plugin.this.close(true);
},
/**
* Call the refresh method silently.
* @return {void}
*/
refreshSilently: () => {
// Call the refresh method silently
plugin.this.refresh(true);
},
/**
* Call the destroy method silently.
* @return {void}
*/
destroySilently: () => {
// Call the destroy method silently
plugin.this.destroy(true);
}
};
// Return the plugin
return Plugin;
}));