/*! Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */ ;(function(Icinga) { 'use strict'; Icinga.Behaviors = Icinga.Behaviors || {}; /** * Behavior for collapsible containers. * * @param icinga Icinga The current Icinga Object */ var Collapsible = function(icinga) { Icinga.EventListener.call(this, icinga); this.on('layout-change', this.onLayoutChange, this); this.on('rendered', '#main > .container, #modal-content', this.onRendered, this); this.on('click', '.collapsible + .collapsible-control, .collapsible > .collapsible-control', this.onControlClicked, this); this.icinga = icinga; this.defaultVisibleRows = 2; this.defaultVisibleHeight = 36; this.state = new Icinga.Storage.StorageAwareMap.withStorage( Icinga.Storage.BehaviorStorage('collapsible'), 'expanded' ) .on('add', this.onExpand, this) .on('delete', this.onCollapse, this); }; Collapsible.prototype = new Icinga.EventListener(); /** * Initializes all collapsibles. Triggered on rendering of a container. * * @param event Event The `onRender` event triggered by the rendered container */ Collapsible.prototype.onRendered = function(event) { let _this = event.data.self, toCollapse = [], toExpand = []; event.target.querySelectorAll('.collapsible').forEach(collapsible => { // Assumes that any newly rendered elements are expanded if (! ('canCollapse' in collapsible.dataset) && _this.canCollapse(collapsible)) { if (_this.setupCollapsible(collapsible)) { toCollapse.push([collapsible, _this.calculateCollapsedHeight(collapsible)]); } else if (_this.isDetails(collapsible)) { // Except if it's a
element, which may not be expanded by default toExpand.push(collapsible); } } }); // Elements are all collapsed in a row now, after height calculations are done. // This avoids reflows since instantly collapsing an element will cause one if // the height of the next element is being calculated. for (const collapseInfo of toCollapse) { _this.collapse(collapseInfo[0], collapseInfo[1]); } for (const collapsible of toExpand) { _this.expand(collapsible); } }; /** * Updates all collapsibles. * * @param event Event The `layout-change` event triggered by window resizing or column changes */ Collapsible.prototype.onLayoutChange = function(event) { let _this = event.data.self; let toCollapse = []; document.querySelectorAll('.collapsible').forEach(collapsible => { if ('canCollapse' in collapsible.dataset) { if (! _this.canCollapse(collapsible)) { let toggleSelector = collapsible.dataset.toggleElement; if (! toggleSelector && ! this.isDetails(collapsible)) { collapsible.nextElementSibling.remove(); } delete collapsible.dataset.canCollapse; _this.expand(collapsible); } } else if (_this.canCollapse(collapsible) && _this.setupCollapsible(collapsible)) { // It's expanded but shouldn't toCollapse.push([collapsible, _this.calculateCollapsedHeight(collapsible)]); } }); setTimeout(function () { for (const collapseInfo of toCollapse) { _this.collapse(collapseInfo[0], collapseInfo[1]); } }, 0); }; /** * A collapsible got expanded in another window, try to apply this here as well * * @param {string} collapsiblePath */ Collapsible.prototype.onExpand = function(collapsiblePath) { let collapsible = document.querySelector(collapsiblePath); if (collapsible && 'canCollapse' in collapsible.dataset) { if ('stateCollapses' in collapsible.dataset) { this.collapse(collapsible, this.calculateCollapsedHeight(collapsible)); } else { this.expand(collapsible); } } }; /** * A collapsible got collapsed in another window, try to apply this here as well * * @param {string} collapsiblePath */ Collapsible.prototype.onCollapse = function(collapsiblePath) { let collapsible = document.querySelector(collapsiblePath); if (collapsible && this.canCollapse(collapsible)) { if ('stateCollapses' in collapsible.dataset) { this.expand(collapsible); } else { this.collapse(collapsible, this.calculateCollapsedHeight(collapsible)); } } }; /** * Event handler for toggling collapsibles. Switches the collapsed state of the respective container. * * @param event Event The `onClick` event triggered by the clicked collapsible-control element */ Collapsible.prototype.onControlClicked = function(event) { let _this = event.data.self, target = event.currentTarget; let collapsible = target.previousElementSibling; if (! collapsible) { collapsible = target.closest('.collapsible'); } if (! collapsible) { _this.icinga.logger.error( '[Collapsible] Collapsible control has no associated .collapsible: ', target); return; } else if ('noPersistence' in collapsible.dataset) { if (collapsible.classList.contains('collapsed')) { _this.expand(collapsible); } else { _this.collapse(collapsible, _this.calculateCollapsedHeight(collapsible)); } } else { let collapsiblePath = _this.icinga.utils.getCSSPath(collapsible), stateCollapses = 'stateCollapses' in collapsible.dataset; if (_this.state.has(collapsiblePath)) { _this.state.delete(collapsiblePath); if (stateCollapses) { _this.expand(collapsible); } else { _this.collapse(collapsible, _this.calculateCollapsedHeight(collapsible)); } } else { _this.state.set(collapsiblePath); if (stateCollapses) { _this.collapse(collapsible, _this.calculateCollapsedHeight(collapsible)); } else { _this.expand(collapsible); } } } if (_this.isDetails(collapsible)) { // The browser handles these clicks as well, and would toggle the state again event.preventDefault(); } }; /** * Setup the given collapsible * * @param collapsible The given collapsible container element * * @returns {boolean} Whether it needs to collapse or not */ Collapsible.prototype.setupCollapsible = function (collapsible) { if (this.isDetails(collapsible)) { let summary = collapsible.querySelector(':scope > summary'); if (! summary.classList.contains('collapsible-control')) { summary.classList.add('collapsible-control'); } if (collapsible.open) { collapsible.dataset.stateCollapses = ''; } } else if (!! collapsible.dataset.toggleElement) { let toggleSelector = collapsible.dataset.toggleElement, toggle = collapsible.querySelector(toggleSelector); if (! toggle && collapsible.nextElementSibling && collapsible.nextElementSibling.matches(toggleSelector)) { toggle = collapsible.nextElementSibling; } if (! toggle) { this.icinga.logger.error( '[Collapsible] Control `' + toggleSelector + '` not found in .collapsible', collapsible); } else if (! toggle.classList.contains('collapsible-control')) { toggle.classList.add('collapsible-control'); } } else { setTimeout(function () { let collapsibleControl = document .getElementById('collapsible-control-ghost') .cloneNode(true); collapsibleControl.removeAttribute('id'); collapsible.parentNode.insertBefore(collapsibleControl, collapsible.nextElementSibling); }, 0); } collapsible.dataset.canCollapse = ''; if ('noPersistence' in collapsible.dataset) { return ! ('stateCollapses' in collapsible.dataset); } if ('stateCollapses' in collapsible.dataset) { return this.state.has(this.icinga.utils.getCSSPath(collapsible)); } else { return ! this.state.has(this.icinga.utils.getCSSPath(collapsible)); } }; /** * Return an appropriate row element selector * * @param collapsible The given collapsible container element * * @returns {string} */ Collapsible.prototype.getRowSelector = function(collapsible) { if (!! collapsible.dataset.visibleHeight) { return ''; } if (collapsible.tagName === 'TABLE') { return '> tbody > tr'; } else if (collapsible.tagName === 'UL' || collapsible.tagName === 'OL') { return '> li:not(.collapsible-control)'; } return ''; }; /** * Check whether the given collapsible needs to collapse * * @param collapsible The given collapsible container element * * @returns {boolean} */ Collapsible.prototype.canCollapse = function(collapsible) { if (this.isDetails(collapsible)) { return collapsible.querySelector(':scope > summary') !== null; } let rowSelector = this.getRowSelector(collapsible); if (!! rowSelector) { let visibleRows = Number(collapsible.dataset.visibleRows); if (isNaN(visibleRows)) { visibleRows = this.defaultVisibleRows; } else if (visibleRows === 0) { return true; } return collapsible.querySelectorAll(rowSelector).length > visibleRows * 2; } else { let maxHeight = Number(collapsible.dataset.visibleHeight); if (isNaN(maxHeight)) { maxHeight = this.defaultVisibleHeight; } else if (maxHeight === 0) { return true; } let actualHeight = collapsible.scrollHeight - parseFloat( window.getComputedStyle(collapsible).getPropertyValue('padding-top') ); return actualHeight >= maxHeight * 2; } }; /** * Calculate the height the given collapsible should have when collapsed * * @param collapsible */ Collapsible.prototype.calculateCollapsedHeight = function (collapsible) { let height; if (this.isDetails(collapsible)) { return -1; } let rowSelector = this.getRowSelector(collapsible); if (!! rowSelector) { height = collapsible.scrollHeight; height -= parseFloat(window.getComputedStyle(collapsible).getPropertyValue('padding-bottom')); let visibleRows = Number(collapsible.dataset.visibleRows); if (isNaN(visibleRows)) { visibleRows = this.defaultVisibleRows; } let rows = Array.from(collapsible.querySelectorAll(rowSelector)).slice(visibleRows); for (let i = 0; i < rows.length; i++) { let row = rows[i]; if (row.previousElementSibling === null) { // very first element height -= row.offsetHeight; height -= parseFloat(window.getComputedStyle(row).getPropertyValue('margin-top')); } else if (i < rows.length - 1) { // every element but the last one let prevBottomBorderAt = row.previousElementSibling.offsetTop; prevBottomBorderAt += row.previousElementSibling.offsetHeight; height -= row.offsetTop - prevBottomBorderAt + row.offsetHeight; } else { // the last element height -= row.offsetHeight; height -= parseFloat(window.getComputedStyle(row).getPropertyValue('margin-top')); height -= parseFloat(window.getComputedStyle(row).getPropertyValue('margin-bottom')); } } } else { height = Number(collapsible.dataset.visibleHeight); if (isNaN(height)) { height = this.defaultVisibleHeight; } height += parseFloat(window.getComputedStyle(collapsible).getPropertyValue('padding-top')); if ( !! collapsible.dataset.toggleElement && (! collapsible.nextElementSibling || ! collapsible.nextElementSibling.matches(collapsible.dataset.toggleElement)) ) { let toggle = collapsible.querySelector(collapsible.dataset.toggleElement); height += toggle.offsetHeight; // TODO: Very expensive at times. (50ms+) Check why! height += parseFloat(window.getComputedStyle(toggle).getPropertyValue('margin-top')); height += parseFloat(window.getComputedStyle(toggle).getPropertyValue('margin-bottom')); } } return height; }; /** * Collapse the given collapsible * * @param collapsible The given collapsible container element * @param toHeight {int} The height in pixels to collapse to */ Collapsible.prototype.collapse = function(collapsible, toHeight) { if (this.isDetails(collapsible)) { collapsible.open = false; } else { collapsible.style.cssText = 'display: block; height: ' + toHeight + 'px; padding-bottom: 0'; } collapsible.classList.add('collapsed'); }; /** * Expand the given collapsible * * @param collapsible The given collapsible container element */ Collapsible.prototype.expand = function(collapsible) { collapsible.classList.remove('collapsed'); if (this.isDetails(collapsible)) { collapsible.open = true; } else { collapsible.style.cssText = ''; } }; /** * Get whether the given collapsible is a
element * * @param collapsible * * @return {Boolean} */ Collapsible.prototype.isDetails = function (collapsible) { return collapsible.tagName === 'DETAILS'; }; Icinga.Behaviors.Collapsible = Collapsible; })(Icinga);