/**
* Copyright (c) 2000-present Liferay, Inc. All rights reserved.
*
* This library is free software; you can redistribute it and/or modify it under
* the terms of the GNU Lesser General Public License as published by the Free
* Software Foundation; either version 2.1 of the License, or (at your option)
* any later version.
*
* This library is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
* details.
*/
import EventEmitter from './events/EventEmitter';
/**
* Options
*
* @property {String|Number} breakpoint The window width that defines the desktop size.
* @property {String} content The class or ID of the content container.
* @property {String} container The class or ID of the sidenav container.
* @property {String|Number} gutter The space between the sidenav-slider and the sidenav-content.
* @property {String} navigation The class or ID of the navigation container.
* @property {String} position The position of the sidenav-slider. Possible values: left, right
* @property {String} type The type of sidenav in desktop. Possible values: relative, fixed, fixed-push
* @property {String} typeMobile The type of sidenav in mobile. Possible values: relative, fixed, fixed-push
* @property {String|Object} url The URL to fetch the content to inject into .sidebar-body
* @property {String|Number} width The width of the side navigation.
*/
const DEFAULTS = {
breakpoint: 768,
content: '.sidenav-content',
gutter: '0px',
loadingIndicatorTPL:
'<div class="loading-animation loading-animation-md"></div>',
navigation: '.sidenav-menu-slider',
position: 'left',
type: 'relative',
typeMobile: 'relative',
url: null,
width: '225px',
};
/**
* Map from toggler DOM nodes to sidenav instances.
*/
const INSTANCE_MAP = new WeakMap();
/**
* Utility function that strips off a possible jQuery and Metal
* component wrappers from a DOM element.
*/
function getElement(element) {
// Remove jQuery wrapper, if any.
if (element && element.jquery) {
if (element.length > 1) {
throw new Error(
`getElement(): Expected at most one element, got ${element.length}`
);
}
element = element.get(0);
}
// Remove Metal wrapper, if any.
if (element && !(element instanceof HTMLElement)) {
element = element.element;
}
return element;
}
function getInstance(element) {
element = getElement(element);
const instance = INSTANCE_MAP.get(element);
return instance;
}
/**
* A list of attributes that can contribute to the "identity" of an element, for
* the purposes of delegated event handling.
*/
const IDENTITY_ATTRIBUTES = [/^aria-/, /^data-/, /^type$/];
/**
* Returns a unique selector for the supplied element. Ideally we'd just use
* the "id" attribute (assigning one if necessary), but the use of Metal
* components means that any "id" we assign will be blown away on the next state
* change.
*/
function getUniqueSelector(element) {
element = getElement(element);
if (element.id) {
return `#${element.id}`;
}
let ancestorWithId = element.parentNode;
while (ancestorWithId) {
if (ancestorWithId.id) {
break;
}
ancestorWithId = ancestorWithId.parentNode;
}
const attributes = Array.from(element.attributes)
.map(({name, value}) => {
const isIdentifying = IDENTITY_ATTRIBUTES.some((regExp) => {
return regExp.test(name);
});
return isIdentifying ? `[${name}=${JSON.stringify(value)}]` : null;
})
.filter(Boolean)
.sort();
return [
ancestorWithId ? `#${ancestorWithId.id} ` : '',
element.tagName.toLowerCase(),
...attributes,
].join('');
}
function addClass(element, className) {
setClasses(element, {
[className]: true,
});
}
function removeClass(element, className) {
setClasses(element, {
[className]: false,
});
}
function setClasses(element, classes) {
element = getElement(element);
if (element) {
// One at a time because IE 11: https://caniuse.com/#feat=classlist
Object.entries(classes).forEach(([className, present]) => {
// Some callers use multiple space-separated classNames for
// `openClass`/`data-open-class`. (Looking at you,
// product-navigation-simulation-web...)
className.split(/\s+/).forEach((name) => {
if (present) {
element.classList.add(name);
}
else {
element.classList.remove(name);
}
});
});
}
}
function hasClass(element, className) {
element = getElement(element);
// Again, product-navigation-simulation-web passes multiple classNames.
return className.split(/\s+/).every((name) => {
return element.classList.contains(name);
});
}
function setStyles(element, styles) {
element = getElement(element);
if (element) {
Object.entries(styles).forEach(([property, value]) => {
element.style[property] = value;
});
}
}
/**
* For compatibility with jQuery, which will treat "100" as "100px".
*/
function px(dimension) {
if (typeof dimension === 'number') {
return dimension + 'px';
}
else if (
typeof dimension === 'string' &&
dimension.match(/^\s*\d+\s*$/)
) {
return dimension.trim() + 'px';
}
else {
return dimension;
}
}
/**
* Replacement for jQuery's `offset().left`.
*
* @see: https://github.com/jquery/jquery/blob/438b1a3e8a52/src/offset.js#L94-L100
*/
function offsetLeft(element) {
const elementLeft = element.getBoundingClientRect().left;
const documentOffset = element.ownerDocument.defaultView.pageOffsetX || 0;
return elementLeft + documentOffset;
}
/**
* Keys are event names (eg. "click").
* Values are objects mapping selectors to EventEmitters.
*/
const eventNamesToSelectors = {};
function handleEvent(eventName, event) {
Object.keys(eventNamesToSelectors[eventName]).forEach((selector) => {
let matches = false;
let target = event.target;
while (target) {
// In IE11 SVG elements have no `parentElement`, only a
// `parentNode`, so we have to search up the DOM using
// the latter. This in turn requires us to check for the
// existence of `target.matches` before using it.
//
// See: https://stackoverflow.com/a/36270354/2103996
matches = target.matches && target.matches(selector);
if (matches) {
break;
}
target = target.parentNode;
}
if (matches) {
const emitter = eventNamesToSelectors[eventName][selector];
emitter.emit('click', event);
}
});
}
/**
* Creates a delegated event listener for `eventName` events on
* `elementOrSelector`.
*/
function subscribe(elementOrSelector, eventName, handler) {
if (elementOrSelector) {
// Add only one listener per `eventName`.
if (!eventNamesToSelectors[eventName]) {
eventNamesToSelectors[eventName] = {};
document.body.addEventListener(eventName, (event) =>
handleEvent(eventName, event)
);
}
const emitters = eventNamesToSelectors[eventName];
const selector =
typeof elementOrSelector === 'string'
? elementOrSelector
: getUniqueSelector(elementOrSelector);
if (!emitters[selector]) {
emitters[selector] = new EventEmitter();
}
const emitter = emitters[selector];
const subscription = emitter.on(eventName, (event) => {
if (!event.defaultPrevented) {
handler(event);
}
});
return {
dispose() {
subscription.dispose();
},
};
}
return null;
}
function toInt(str) {
return parseInt(str, 10) || 0;
}
function SideNavigation(toggler, options) {
toggler = getElement(toggler);
this.init(toggler, options);
}
SideNavigation.TRANSITION_DURATION = 500;
SideNavigation.prototype = {
_bindUI() {
const instance = this;
instance._subscribeClickTrigger();
instance._subscribeClickSidenavClose();
},
_emit(event) {
this._emitter.emit(event, this);
},
_getSidenavWidth() {
const instance = this;
const options = instance.options;
const widthOriginal = options.widthOriginal;
let width = widthOriginal;
const winWidth = window.innerWidth;
if (winWidth < widthOriginal + 40) {
width = winWidth - 40;
}
return width;
},
_getSimpleSidenavType() {
const instance = this;
const options = instance.options;
const desktop = instance._isDesktop();
const type = options.type;
const typeMobile = options.typeMobile;
if (desktop && type === 'fixed-push') {
return 'desktop-fixed-push';
}
else if (!desktop && typeMobile === 'fixed-push') {
return 'mobile-fixed-push';
}
return 'fixed';
},
_isDesktop() {
return window.innerWidth >= this.options.breakpoint;
},
_isSidenavRight() {
const instance = this;
const options = instance.options;
const container = document.querySelector(options.container);
const isSidenavRight = hasClass(container, 'sidenav-right');
return isSidenavRight;
},
_isSimpleSidenavClosed() {
const instance = this;
const options = instance.options;
const openClass = options.openClass;
const container = document.querySelector(options.container);
return !hasClass(container, openClass);
},
_loadUrl(element, url) {
const instance = this;
const sidebar = element.querySelector('.sidebar-body');
if (!instance._fetchPromise && sidebar) {
while (sidebar.firstChild) {
sidebar.removeChild(sidebar.firstChild);
}
const loading = document.createElement('div');
addClass(loading, 'sidenav-loading');
loading.innerHTML = instance.options.loadingIndicatorTPL;
sidebar.appendChild(loading);
instance._fetchPromise = Liferay.Util.fetch(url);
instance._fetchPromise
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to fetch ${url}`);
}
return response.text();
})
.then((text) => {
const range = document.createRange();
range.selectNode(sidebar);
// Unlike `.innerHTML`, this will eval scripts.
const fragment = range.createContextualFragment(text);
sidebar.removeChild(loading);
sidebar.appendChild(fragment);
instance.setHeight();
})
.catch((err) => {
console.error(err);
});
}
},
_renderNav() {
const instance = this;
const options = instance.options;
const container = document.querySelector(options.container);
const navigation = container.querySelector(options.navigation);
const menu = navigation.querySelector('.sidenav-menu');
const closed = hasClass(container, 'closed');
const sidenavRight = instance._isSidenavRight();
const width = instance._getSidenavWidth();
if (closed) {
setStyles(menu, {
width: px(width),
});
if (sidenavRight) {
const positionDirection = options.rtl ? 'left' : 'right';
setStyles(menu, {
[positionDirection]: px(width),
});
}
}
else {
instance.showSidenav();
instance.setHeight();
}
},
_renderUI() {
const instance = this;
const options = instance.options;
const container = document.querySelector(options.container);
const toggler = instance.toggler;
const mobile = instance.mobile;
const type = mobile ? options.typeMobile : options.type;
if (!instance.useDataAttribute) {
if (mobile) {
setClasses(container, {
closed: true,
open: false,
});
setClasses(toggler, {
active: false,
open: false,
});
}
if (options.position === 'right') {
addClass(container, 'sidenav-right');
}
if (type !== 'relative') {
addClass(container, 'sidenav-fixed');
}
instance._renderNav();
}
// Force Reflow for IE11 Browser Bug
setStyles(container, {
display: '',
});
},
_subscribeClickSidenavClose() {
const instance = this;
const options = instance.options;
const containerSelector = options.container;
if (!instance._sidenavCloseSubscription) {
const closeButtonSelector = `${containerSelector} .sidenav-close`;
instance._sidenavCloseSubscription = subscribe(
closeButtonSelector,
'click',
function handleSidenavClose(event) {
event.preventDefault();
instance.toggle();
}
);
}
},
_subscribeClickTrigger() {
const instance = this;
if (!instance._togglerSubscription) {
const toggler = instance.toggler;
instance._togglerSubscription = subscribe(
toggler,
'click',
function handleTogglerClick(event) {
instance.toggle();
event.preventDefault();
}
);
}
},
_subscribeSidenavTransitionEnd(element, fn) {
setTimeout(() => {
removeClass(element, 'sidenav-transition');
fn();
}, SideNavigation.TRANSITION_DURATION);
},
clearHeight() {
const instance = this;
const options = instance.options;
const container = document.querySelector(options.container);
if (container) {
const content = container.querySelector(options.content);
const navigation = container.querySelector(options.navigation);
const menu = container.querySelector('.sidenav-menu');
[content, navigation, menu].forEach((element) => {
setStyles(element, {
height: '',
'min-height': '',
});
});
}
},
destroy() {
const instance = this;
if (instance._sidenavCloseSubscription) {
instance._sidenavCloseSubscription.dispose();
instance._sidenavCloseSubscription = null;
}
if (instance._togglerSubscription) {
instance._togglerSubscription.dispose();
instance._togglerSubscription = null;
}
INSTANCE_MAP.delete(instance.toggler);
},
hide() {
const instance = this;
if (instance.useDataAttribute) {
instance.hideSimpleSidenav();
}
else {
instance.toggleNavigation(false);
}
},
hideSidenav() {
const instance = this;
const options = instance.options;
const container = document.querySelector(options.container);
if (container) {
const content = container.querySelector(options.content);
const navigation = container.querySelector(options.navigation);
const menu = navigation.querySelector('.sidenav-menu');
const sidenavRight = instance._isSidenavRight();
let positionDirection = options.rtl ? 'right' : 'left';
if (sidenavRight) {
positionDirection = options.rtl ? 'left' : 'right';
}
const paddingDirection = 'padding-' + positionDirection;
setStyles(content, {
[paddingDirection]: '',
[positionDirection]: '',
});
setStyles(navigation, {
width: '',
});
if (sidenavRight) {
setStyles(menu, {
[positionDirection]: px(instance._getSidenavWidth()),
});
}
}
},
hideSimpleSidenav() {
const instance = this;
const options = instance.options;
const simpleSidenavClosed = instance._isSimpleSidenavClosed();
if (!simpleSidenavClosed) {
const content = document.querySelector(options.content);
const container = document.querySelector(options.container);
const closedClass = options.closedClass;
const openClass = options.openClass;
const toggler = instance.toggler;
const target =
toggler.dataset.target || toggler.getAttribute('href');
instance._emit('closedStart.lexicon.sidenav');
instance._subscribeSidenavTransitionEnd(content, () => {
removeClass(container, 'sidenav-transition');
removeClass(toggler, 'sidenav-transition');
instance._emit('closed.lexicon.sidenav');
});
if (hasClass(content, openClass)) {
setClasses(content, {
[closedClass]: true,
[openClass]: false,
'sidenav-transition': true,
});
}
addClass(container, 'sidenav-transition');
addClass(toggler, 'sidenav-transition');
setClasses(container, {
[closedClass]: true,
[openClass]: false,
});
const nodes = document.querySelectorAll(
`[data-target="${target}"], [href="${target}"]`
);
Array.from(nodes).forEach((node) => {
setClasses(node, {
active: false,
[openClass]: false,
});
setClasses(node, {
active: false,
[openClass]: false,
});
});
}
},
init(toggler, options) {
const instance = this;
/**
* For compatibility, we use a data-toggle attribute of
* "liferay-sidenav" to distinguish our internal uses from
* possible external uses of the old jQuery plugin (which used
* "sidenav').
*/
const useDataAttribute = toggler.dataset.toggle === 'liferay-sidenav';
options = {...DEFAULTS, ...options};
options.breakpoint = toInt(options.breakpoint);
options.container =
options.container ||
toggler.dataset.target ||
toggler.getAttribute('href');
options.gutter = toInt(options.gutter);
options.rtl = document.dir === 'rtl';
options.width = toInt(options.width);
options.widthOriginal = options.width;
// instantiate using data attribute
if (useDataAttribute) {
options.closedClass = toggler.dataset.closedClass || 'closed';
options.content = toggler.dataset.content;
options.loadingIndicatorTPL =
toggler.dataset.loadingIndicatorTpl ||
options.loadingIndicatorTPL;
options.openClass = toggler.dataset.openClass || 'open';
options.type = toggler.dataset.type;
options.typeMobile = toggler.dataset.typeMobile;
options.url = toggler.dataset.url;
options.width = '';
}
instance.toggler = toggler;
instance.options = options;
instance.useDataAttribute = useDataAttribute;
instance._emitter = new EventEmitter();
instance._bindUI();
instance._renderUI();
},
on(event, listener) {
return this._emitter.on(event, listener);
},
setHeight() {
const instance = this;
const options = instance.options;
const container = document.querySelector(options.container);
const type = instance.mobile ? options.typeMobile : options.type;
if (type !== 'fixed' && type !== 'fixed-push') {
const content = container.querySelector(options.content);
const navigation = container.querySelector(options.navigation);
const menu = container.querySelector('.sidenav-menu');
const contentHeight = content.getBoundingClientRect().height;
const navigationHeight = navigation.getBoundingClientRect().height;
const tallest = px(Math.max(contentHeight, navigationHeight));
setStyles(content, {
'min-height': tallest,
});
setStyles(navigation, {
height: '100%',
'min-height': tallest,
});
setStyles(menu, {
height: '100%',
'min-height': tallest,
});
}
},
show() {
const instance = this;
if (instance.useDataAttribute) {
instance.showSimpleSidenav();
}
else {
instance.toggleNavigation(true);
}
},
showSidenav() {
const instance = this;
const mobile = instance.mobile;
const options = instance.options;
const container = document.querySelector(options.container);
const content = container.querySelector(options.content);
const navigation = container.querySelector(options.navigation);
const menu = navigation.querySelector('.sidenav-menu');
const sidenavRight = instance._isSidenavRight();
const width = instance._getSidenavWidth();
const offset = width + options.gutter;
const url = options.url;
if (url) {
instance._loadUrl(menu, url);
}
setStyles(navigation, {
width: px(width),
});
setStyles(menu, {
width: px(width),
});
let positionDirection = options.rtl ? 'right' : 'left';
if (sidenavRight) {
positionDirection = options.rtl ? 'left' : 'right';
}
const paddingDirection = 'padding-' + positionDirection;
const pushContentCssProperty = mobile
? positionDirection
: paddingDirection;
const type = mobile ? options.typeMobile : options.type;
if (type !== 'fixed') {
let navigationStartX = hasClass(container, 'open')
? offsetLeft(navigation) - options.gutter
: offsetLeft(navigation) - offset;
const contentStartX = offsetLeft(content);
const contentWidth = toInt(getComputedStyle(content).width);
let padding = '';
if (
(options.rtl && sidenavRight) ||
(!options.rtl && options.position === 'left')
) {
navigationStartX = offsetLeft(navigation) + offset;
if (navigationStartX > contentStartX) {
padding = navigationStartX - contentStartX;
}
}
else if (
(options.rtl && options.position === 'left') ||
(!options.rtl && sidenavRight)
) {
if (navigationStartX < contentStartX + contentWidth) {
padding = contentStartX + contentWidth - navigationStartX;
if (padding >= offset) {
padding = offset;
}
}
}
setStyles(content, {
[pushContentCssProperty]: px(padding),
});
}
},
showSimpleSidenav() {
const instance = this;
const options = instance.options;
const simpleSidenavClosed = instance._isSimpleSidenavClosed();
if (simpleSidenavClosed) {
const content = document.querySelector(options.content);
const container = document.querySelector(options.container);
const closedClass = options.closedClass;
const openClass = options.openClass;
const toggler = instance.toggler;
const url = toggler.dataset.url;
if (url) {
instance._loadUrl(container, url);
}
instance._emit('openStart.lexicon.sidenav');
instance._subscribeSidenavTransitionEnd(content, () => {
removeClass(container, 'sidenav-transition');
removeClass(toggler, 'sidenav-transition');
instance._emit('open.lexicon.sidenav');
});
setClasses(content, {
[closedClass]: false,
[openClass]: true,
'sidenav-transition': true,
});
setClasses(container, {
[closedClass]: false,
[openClass]: true,
'sidenav-transition': true,
});
setClasses(toggler, {
active: true,
[openClass]: true,
'sidenav-transition': true,
});
}
},
toggle() {
const instance = this;
if (instance.useDataAttribute) {
instance.toggleSimpleSidenav();
}
else {
instance.toggleNavigation();
}
},
toggleNavigation(force) {
const instance = this;
const options = instance.options;
const container = document.querySelector(options.container);
const menu = container.querySelector('.sidenav-menu');
const toggler = instance.toggler;
const width = options.width;
const closed =
typeof force === 'boolean' ? force : hasClass(container, 'closed');
const sidenavRight = instance._isSidenavRight();
if (closed) {
instance._emit('openStart.lexicon.sidenav');
}
else {
instance._emit('closedStart.lexicon.sidenav');
}
instance._subscribeSidenavTransitionEnd(container, () => {
const menu = container.querySelector('.sidenav-menu');
if (hasClass(container, 'closed')) {
instance.clearHeight();
setClasses(toggler, {
open: false,
'sidenav-transition': false,
});
instance._emit('closed.lexicon.sidenav');
}
else {
setClasses(toggler, {
open: true,
'sidenav-transition': false,
});
instance._emit('open.lexicon.sidenav');
}
if (instance.mobile) {
// ios 8 fixed element disappears when trying to scroll
menu.focus();
}
});
if (closed) {
instance.setHeight();
setStyles(menu, {
width: px(width),
});
const positionDirection = options.rtl ? 'left' : 'right';
if (sidenavRight) {
setStyles(menu, {
[positionDirection]: '',
});
}
}
addClass(container, 'sidenav-transition');
addClass(toggler, 'sidenav-transition');
if (closed) {
instance.showSidenav();
}
else {
instance.hideSidenav();
}
setClasses(container, {
closed: !closed,
open: closed,
});
setClasses(toggler, {
active: closed,
open: closed,
});
},
toggleSimpleSidenav() {
const instance = this;
const simpleSidenavClosed = instance._isSimpleSidenavClosed();
if (simpleSidenavClosed) {
instance.showSimpleSidenav();
}
else {
instance.hideSimpleSidenav();
}
},
visible() {
const instance = this;
let closed;
if (instance.useDataAttribute) {
closed = instance._isSimpleSidenavClosed();
}
else {
const container = document.querySelector(
instance.options.container
);
closed = hasClass(container, 'sidenav-transition')
? !hasClass(container, 'closed')
: hasClass(container, 'closed');
}
return !closed;
},
};
SideNavigation.destroy = function destroy(element) {
const instance = getInstance(element);
if (instance) {
instance.destroy();
}
};
SideNavigation.hide = function hide(element) {
const instance = getInstance(element);
if (instance) {
instance.hide();
}
};
SideNavigation.initialize = function initialize(toggler, options = {}) {
toggler = getElement(toggler);
let instance = INSTANCE_MAP.get(toggler);
if (!instance) {
instance = new SideNavigation(toggler, options);
INSTANCE_MAP.set(toggler, instance);
}
return instance;
};
SideNavigation.instance = getInstance;
function onReady() {
const togglers = document.querySelectorAll(
'[data-toggle="liferay-sidenav"]'
);
Array.from(togglers).forEach(SideNavigation.initialize);
}
if (document.readyState !== 'loading') {
// readyState is "interactive" or "complete".
onReady();
}
else {
document.addEventListener('DOMContentLoaded', () => {
onReady();
});
}
export default SideNavigation;