/* * Copyright (C) 2013-2024 Combodo SAS * * This file is part of iTop. * * iTop is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * iTop 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License */ class ButtonBar extends HTMLElement { static get observedAttributes() { return [ "overflow-mode", "overflow-count", "overflow-start-after-button-id", "data-overflow-mode", "data-overflow-count", "data-overflow-start-after-button-id", ]; } constructor() { super(); // Guard against duplicate setup if the node is re-attached. this._initialized = false; // Retry counter for delayed jQuery plugin availability. this._popoverInitAttempts = 0; // Recompute distribution on resize. this._onResize = this.refresh.bind(this); this._overflowMode = "fit"; this._count = 3; this._overflowStartAfterButtonId = null; this._initialInlineDisplay = null; } attributeChangedCallback() { this._updateOverflowConfigFromAttributes(); this._initialInlineDisplay = this.style.display; if (this._initialized) { this.refresh(); } } connectedCallback() { if (this._initialized) { return; } this._initialized = true; // Core DOM references rendered by the Twig template. this._track = this.querySelector('[data-role="ibo-button-bar--track"]'); this._extra = this.querySelector('[data-role="ibo-button-bar--extra"]'); this._popover = this.querySelector('.ibo-button-bar--popover[data-role="ibo-popover-menu"]'); // Toggler id is deterministic: --toggler. const sTogglerId = `${this.id}--toggler`; const oToggler = document.getElementById(sTogglerId); this._toggler = oToggler && this.contains(oToggler) ? oToggler : null; if (!this._track || !this._extra || !this._popover || !this._toggler) { return; } this._updateOverflowConfigFromAttributes(); if (window.ResizeObserver) { this._resizeObserver = new ResizeObserver(this._onResize); this._resizeObserver.observe(this); this._resizeObserver.observe(this._track); } else { window.addEventListener("resize", this._onResize); } // Observe both source actions and popover entries (both can be updated dynamically). this._mutationObserver = new MutationObserver(() => this.refresh()); this._mutationObserver.observe(this._track, { childList: true, subtree: true, characterData: true }); this._mutationObserver.observe(this._popover, { childList: true, subtree: true }); this.refresh(); } _getAttributeValue(...aNames) { for (const sName of aNames) { const sValue = this.getAttribute(sName); if (sValue !== null) { return sValue; } } return null; } _updateOverflowConfigFromAttributes() { const sModeRaw = this._getAttributeValue("overflow-mode", "data-overflow-mode"); const sMode = (sModeRaw || "fit").trim().toLowerCase(); const aAllowedModes = ["fit", "count", "after-marker"]; this._overflowMode = aAllowedModes.includes(sMode) ? sMode : "fit"; const sCountRaw = this._getAttributeValue("overflow-count", "data-overflow-count"); const iParsedCount = Number.parseInt(sCountRaw ?? "3", 10); this._count = Number.isFinite(iParsedCount) && iParsedCount >= 0 ? iParsedCount : 3; const sStartAfterButtonId = this._getAttributeValue("overflow-start-after-button-id", "data-overflow-start-after-button-id"); this._overflowStartAfterButtonId = sStartAfterButtonId && sStartAfterButtonId.trim() !== "" ? sStartAfterButtonId : null; } disconnectedCallback() { this._resizeObserver?.disconnect(); this._mutationObserver?.disconnect(); if (this._popoverInitTimer) { clearTimeout(this._popoverInitTimer); } this._popover?.removeEventListener("click", this._onPopoverClick); window.removeEventListener("resize", this._onResize); this._initialized = false; } _refreshCollections() { // Source actions are direct children of the track. this._items = Array.from(this._track.children).filter((oItem) => Boolean(oItem.dataset.overflowItemId)); this._itemsById = {}; this._items.forEach((oItem) => { if (oItem.dataset.overflowItemId) { this._itemsById[oItem.dataset.overflowItemId] = oItem; } }); // Popover entries are generated server-side with the same mapping id. this._menuItems = Array.from(this._popover.querySelectorAll('[data-role~="ibo-popover-menu--item"][data-overflow-item-id]')); this._menuItemsById = {}; this._menuItems.forEach((oItem) => { this._menuItemsById[oItem.dataset.overflowItemId] = oItem; }); } _outerWidth(oElem) { const oRect = oElem.getBoundingClientRect(); const oStyle = getComputedStyle(oElem); return oRect.width + (parseFloat(oStyle.marginLeft) || 0) + (parseFloat(oStyle.marginRight) || 0); } _flexGap(oElem) { const oStyle = getComputedStyle(oElem); const iGap = parseFloat(oStyle.columnGap); return Number.isFinite(iGap) ? iGap : 0; } _closePopoverIfOpen() { if (window.jQuery && window.jQuery(this._popover).data("itop-popover_menu")) { window.jQuery(this._popover).popover_menu("closePopup"); } } refresh() { this._refreshCollections(); this.layout(); } _applyDisplayMode() { if (this._overflowMode === "fit") { this.style.display = this._initialInlineDisplay || ""; return; } this.style.display = "inline-flex"; } layout() { this._applyDisplayMode(); if (!this._items || this._items.length === 0) { this._extra.hidden = true; return; } // 1) Reset visibility before computing overflow. this._items.forEach((oItem) => { oItem.hidden = false; }); this._menuItems.forEach((oItem) => { oItem.hidden = true; }); // 2) No mapping => keep source actions visible and hide overflow controls. if (this._menuItems.length === 0) { this._extra.hidden = true; this._closePopoverIfOpen(); return; } this._extra.hidden = false; const bHasHiddenItems = this._layoutByMode(); this._extra.hidden = !bHasHiddenItems; if (!bHasHiddenItems) { this._closePopoverIfOpen(); } } _layoutByMode() { switch (this._overflowMode) { case "count": return this._layoutCountMode(); case "after-marker": return this._layoutAfterMarkerMode(); case "fit": default: return this._layoutFitMode(); } } _setOverflowForItem(oItem, bOverflow) { oItem.hidden = bOverflow; const sItemId = oItem.dataset.overflowItemId; const oMenuItem = sItemId ? this._menuItemsById[sItemId] : null; if (oMenuItem) { oMenuItem.hidden = !bOverflow; } return Boolean(oMenuItem) && bOverflow; } _layoutCountMode() { let bHasHiddenItems = false; for (let i = 0; i < this._items.length; i++) { if (i < this._count) { continue; } if (this._setOverflowForItem(this._items[i], true)) { bHasHiddenItems = true; } } return bHasHiddenItems; } _layoutAfterMarkerMode() { let iMarkerIndex = -1; if (this._overflowStartAfterButtonId) { iMarkerIndex = this._items.findIndex((oItem) => oItem.dataset.sourceButtonId === this._overflowStartAfterButtonId); } if (iMarkerIndex < 0) { iMarkerIndex = this._items.findIndex((oItem) => oItem.dataset.overflowStartAfter === "1"); } if (iMarkerIndex < 0) { return false; } let bHasHiddenItems = false; for (let i = iMarkerIndex + 1; i < this._items.length; i++) { if (this._setOverflowForItem(this._items[i], true)) { bHasHiddenItems = true; } } return bHasHiddenItems; } _layoutFitMode() { const iHostWidth = this.clientWidth; if (iHostWidth <= 0) { return false; } const aWidths = this._items.map((oItem) => this._outerWidth(oItem)); const iTrackGap = this._flexGap(this._track); const iTotalWidth = aWidths.reduce((iSum, iWidth) => iSum + iWidth, 0) + Math.max(0, this._items.length - 1) * iTrackGap; if (iTotalWidth <= iHostWidth) { return false; } const iHostGap = this._flexGap(this); const iAvailableWidth = Math.max(0, iHostWidth - this._outerWidth(this._extra) - iHostGap); let iUsedWidth = 0; let bHasHiddenItems = false; let bOverflowStarted = false; for (let i = 0; i < this._items.length; i++) { const iWidth = aWidths[i]; const iGapBeforeItem = i > 0 ? iTrackGap : 0; if (!bOverflowStarted && iUsedWidth + iGapBeforeItem + iWidth <= iAvailableWidth) { iUsedWidth += iGapBeforeItem + iWidth; continue; } bOverflowStarted = true; if (this._setOverflowForItem(this._items[i], true)) { bHasHiddenItems = true; } } return bHasHiddenItems; } } if (!customElements.get("ibo-button-bar")) { customElements.define("ibo-button-bar", ButtonBar); }