diff options
Diffstat (limited to 'public/js/icinga/behavior/collapsible.js')
-rw-r--r-- | public/js/icinga/behavior/collapsible.js | 463 |
1 files changed, 463 insertions, 0 deletions
diff --git a/public/js/icinga/behavior/collapsible.js b/public/js/icinga/behavior/collapsible.js new file mode 100644 index 0000000..c0b5349 --- /dev/null +++ b/public/js/icinga/behavior/collapsible.js @@ -0,0 +1,463 @@ +/*! Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */ + +;(function(Icinga) { + + 'use strict'; + + Icinga.Behaviors = Icinga.Behaviors || {}; + + let $ = window.$; + + try { + $ = require('icinga/icinga-php-library/notjQuery'); + } catch (e) { + console.warn('[Collapsible] notjQuery unavailable. Using jQuery for now'); + } + + /** + * Behavior for collapsible containers. + * + * @param icinga Icinga The current Icinga Object + */ + class Collapsible extends Icinga.EventListener { + constructor(icinga) { + super(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); + } + + /** + * Initializes all collapsibles. Triggered on rendering of a container. + * + * @param event Event The `onRender` event triggered by the rendered container + */ + onRendered(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 <details> 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 + */ + onLayoutChange(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 (! this.isDetails(collapsible)) { + if (! toggleSelector) { + collapsible.nextElementSibling.remove(); + } else { + let toggle = document.getElementById(toggleSelector); + if (toggle) { + toggle.classList.remove('collapsed'); + delete toggle.dataset.canCollapse; + } + } + } + + 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 + */ + onExpand(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 + */ + onCollapse(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 + */ + onControlClicked(event) { + let _this = event.data.self, + target = event.currentTarget; + + let collapsible = target.previousElementSibling; + if ('collapsibleAt' in target.dataset) { + collapsible = document.querySelector(target.dataset.collapsibleAt); + } else 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 + */ + setupCollapsible(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), + externalToggle = false; + if (! toggle) { + if (collapsible.nextElementSibling && collapsible.nextElementSibling.matches(toggleSelector)) { + toggle = collapsible.nextElementSibling; + } else { + externalToggle = true; + toggle = document.getElementById(toggleSelector); + } + } + + if (! toggle) { + if (externalToggle) { + this.icinga.logger.error( + '[Collapsible] External control with id `' + + toggleSelector + + '` not found for .collapsible', + collapsible + ); + } else { + this.icinga.logger.error( + '[Collapsible] Control `' + toggleSelector + '` not found in .collapsible', collapsible); + } + + return false; + } else if (externalToggle) { + collapsible.dataset.hasExternalToggle = ''; + + toggle.dataset.canCollapse = ''; + toggle.dataset.collapsibleAt = this.icinga.utils.getCSSPath(collapsible); + $(toggle).on('click', e => { + // Only required as onControlClicked() is compatible with Icinga.EventListener + e.data = { self: this }; + this.onControlClicked(e); + }); + } 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} + */ + getRowSelector(collapsible) { + if (!! collapsible.dataset.visibleHeight) { + return ''; + } + + if (collapsible.tagName === 'TABLE') { + return ':scope > tbody > tr'; + } else if (collapsible.tagName === 'UL' || collapsible.tagName === 'OL') { + return ':scope > li:not(.collapsible-control)'; + } + + return ''; + } + + /** + * Check whether the given collapsible needs to collapse + * + * @param collapsible The given collapsible container element + * + * @returns {boolean} + */ + canCollapse(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 + */ + calculateCollapsedHeight(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 + && ! ('hasExternalToggle' in collapsible.dataset) + && (! 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 + */ + collapse(collapsible, toHeight) { + if (this.isDetails(collapsible)) { + collapsible.open = false; + } else { + collapsible.style.cssText = 'display: block; height: ' + toHeight + 'px; padding-bottom: 0'; + + if ('hasExternalToggle' in collapsible.dataset) { + document.getElementById(collapsible.dataset.toggleElement).classList.add('collapsed'); + } + } + + collapsible.classList.add('collapsed'); + } + + /** + * Expand the given collapsible + * + * @param collapsible The given collapsible container element + */ + expand(collapsible) { + collapsible.classList.remove('collapsed'); + + if (this.isDetails(collapsible)) { + collapsible.open = true; + } else { + collapsible.style.cssText = ''; + + if ('hasExternalToggle' in collapsible.dataset) { + document.getElementById(collapsible.dataset.toggleElement).classList.remove('collapsed'); + } + } + } + + /** + * Get whether the given collapsible is a <details> element + * + * @param collapsible + * + * @return {Boolean} + */ + isDetails(collapsible) { + return collapsible.tagName === 'DETAILS'; + } + } + + Icinga.Behaviors.Collapsible = Collapsible; + +})(Icinga); |