/*! * SAPUI5 * Copyright (c) 2025 SAP SE or an SAP affiliate company. All rights reserved. */ import i18n from "../../i18n"; import Toolbar from "sap/m/Toolbar"; import { ToolbarDesign, ButtonType } from "sap/m/library"; import Button from "sap/m/Button"; import Event from "sap/ui/base/Event"; import ToolbarLayoutData from "sap/m/ToolbarLayoutData"; import ToolbarSpacer from "sap/m/ToolbarSpacer"; import ActionSheet from "sap/m/ActionSheet"; import { PlacementType } from "sap/m/library"; import InvisibleText from "sap/ui/core/InvisibleText"; import IconPool from "sap/ui/core/IconPool"; import Control, { $ControlSettings } from "sap/ui/core/Control"; import SearchModel from "sap/esh/search/ui/SearchModel"; import RenderManager from "sap/ui/core/RenderManager"; import { NavigationTarget } from "../../sinaNexTS/sina/NavigationTarget"; import SearchLink from "../SearchLink"; export interface $SearchRelatedObjectsToolbarSettings extends $ControlSettings { itemId: string; navigationObjects: Array; positionInList: number; } /** * @namespace sap.esh.search.ui.controls */ export default class SearchRelatedObjectsToolbar extends Control { private _overFlowButton: Button; private _overFlowActionSheet: ActionSheet; // items of overFlowButton private _resizeRequestAnimationFrame: number | null = null; private _resizeHandler: () => void; private _stopArrowPropagation: (e: KeyboardEvent) => void; toolbarWidth: number; static readonly metadata = { properties: { itemId: "string", navigationObjects: { type: "object", multiple: true, }, positionInList: "int", }, aggregations: { _relatedObjectActionsToolbar: { type: "sap.m.Toolbar", multiple: false, visibility: "hidden", }, _ariaDescriptionForLinks: { type: "sap.ui.core.InvisibleText", multiple: false, visibility: "hidden", }, }, }; constructor(sId?: string, settings?: $SearchRelatedObjectsToolbarSettings) { super(sId, settings); const relatedObjectActionsToolbar = new Toolbar(`${this.getId()}--toolbar`, { design: ToolbarDesign.Solid, }); relatedObjectActionsToolbar.data("sap-ui-fastnavgroup", "false", true /* write into DOM */); relatedObjectActionsToolbar.addStyleClass( "sapUshellSearchResultListItem-RelatedObjectsToolbar-Toolbar" ); this._stopArrowPropagation = (e: KeyboardEvent) => { if ( e.key === "ArrowRight" || e.key === "ArrowLeft" || e.key === "ArrowUp" || e.key === "ArrowDown" ) { e.stopPropagation(); } }; relatedObjectActionsToolbar.addEventDelegate({ onAfterRendering: () => { this.layoutToolbarElements(); this.syncOverflowVisibilityWithActionSheet(); const toolbarDom = relatedObjectActionsToolbar.getDomRef(); if (toolbarDom) { toolbarDom.removeEventListener("keydown", this._stopArrowPropagation); toolbarDom.addEventListener("keydown", this._stopArrowPropagation); } }, }); this.setAggregation("_relatedObjectActionsToolbar", relatedObjectActionsToolbar); this.setAggregation( "_ariaDescriptionForLinks", new InvisibleText({ text: i18n.getText("result_list_item_aria_has_more_links"), }) ); this._resizeHandler = () => { if (this._resizeRequestAnimationFrame !== null) { cancelAnimationFrame(this._resizeRequestAnimationFrame); } this._resizeRequestAnimationFrame = requestAnimationFrame(() => { this._resizeRequestAnimationFrame = null; this.layoutToolbarElements(); }); }; window.addEventListener("resize", this._resizeHandler); } exit(): void { window.removeEventListener("resize", this._resizeHandler); if (Control.prototype.exit) { Control.prototype.exit.apply(this); } this._overFlowActionSheet?.destroy(); } static renderer = { apiVersion: 2, render(oRm: RenderManager, oControl: SearchRelatedObjectsToolbar): void { oRm.openStart("div", oControl); oRm.class("sapUshellSearchResultListItem-RelatedObjectsToolbar"); const oModel = oControl.getModel() as SearchModel; if (oModel.config.optimizeForValueHelp) { oRm.class("sapUshellSearchResultListItem-RelatedObjectsToolbarValueHelp"); } oRm.openEnd(); oRm.renderControl(oControl.getAggregation("_ariaDescriptionForLinks") as InvisibleText); oControl._renderToolbar(oRm, oControl); oRm.close("div"); }, }; private _renderToolbar(oRm: RenderManager, oControl: SearchRelatedObjectsToolbar): void { const oModel = oControl.getModel() as SearchModel; const _relatedObjectActionsToolbar = oControl.getAggregation( "_relatedObjectActionsToolbar" ) as Toolbar; _relatedObjectActionsToolbar.destroyContent(); if (oModel.config.optimizeForValueHelp) { _relatedObjectActionsToolbar.addStyleClass( "sapUshellSearchResultListItem-RelatedObjectsToolbar-ToolbarValueHelp" ); } const navigationObjects = oControl.getProperty("navigationObjects"); if (Array.isArray(navigationObjects) && navigationObjects.length > 0) { const navigationObjectsLinks: Array = navigationObjects.map( (navigationObject: NavigationTarget, i: number) => { const link = new SearchLink(`${oControl.getId()}--link_${i}`, { text: navigationObject?.text || "", navigationTarget: navigationObject, layoutData: new ToolbarLayoutData({ shrinkable: false, }), }); link.addStyleClass("sapUshellSearchResultListItem-RelatedObjectsToolbar-Element"); return link; } ); // 1. spacer const toolbarSpacer = new ToolbarSpacer(); toolbarSpacer.addStyleClass("sapUshellSearchResultListItem-RelatedObjectsToolbar-Spacer"); _relatedObjectActionsToolbar.addContent(toolbarSpacer); // 2. navigation objects navigationObjectsLinks.forEach((link) => { _relatedObjectActionsToolbar.addContent(link); }); // 3. overFlowButton this._overFlowButton = new Button(`${this.getId()}--overflowButton`, { icon: IconPool.getIconURI("overflow"), }); this._overFlowButton.addStyleClass( "sapUshellSearchResultListItem-RelatedObjectsToolbar-OverFlowButton" ); if (oModel.config.optimizeForValueHelp) { this._overFlowButton.addStyleClass("sapUiSmallMarginBegin"); this._overFlowButton.addStyleClass("sapUiTinyMarginEnd"); this._overFlowButton.setType(ButtonType.Transparent); } _relatedObjectActionsToolbar.addContent(this._overFlowButton); // 4. overFlowActionSheet (items of overFlowButton) if (!this._overFlowActionSheet) { this._overFlowActionSheet = new ActionSheet(`${this.getId()}--actionSheet`, { placement: PlacementType.Top, }); } // Reset stale buttons from previous renders so the sheet's button count is the // source of truth for "this render needs overflow". The folding loop in // layoutToolbarElements repopulates it when overflow is actually required. this._overFlowActionSheet.destroyButtons(); this._overFlowButton.attachPress(() => { if (this._overFlowActionSheet.isOpen()) { this._overFlowActionSheet.close(); } else { this._overFlowActionSheet.openBy(this._overFlowButton); } }); oRm.renderControl(_relatedObjectActionsToolbar); } } // after rendering // =================================================================== onAfterRendering(): void { if (this.getAggregation("_relatedObjectActionsToolbar")) { this._addAriaInformation(); } } /** * Layout toolbar elements and move overflowing elements to the action sheet. * CAUTION: DO NOT CALL ANY UI5 METHODS HERE OR RERENDERING ENDLESS LOOP WILL HAPPEN!!! * @returns void */ public layoutToolbarElements(): void { if (this.isDestroyed() || this.isDestroyStarted()) { return; } const _relatedObjectActionsToolbar = this.getAggregation("_relatedObjectActionsToolbar") as Toolbar; if (!(this.getDomRef() && _relatedObjectActionsToolbar.getDomRef())) { return; } const ownDom = this.getDomRef() as HTMLElement; const toolbarElem = _relatedObjectActionsToolbar.getDomRef() as HTMLElement; const overFlowButtonElem = this._overFlowButton.getDomRef() as HTMLElement; // Skip when the toolbar can't be measured reliably. Two scenarios: // 1. Any ancestor has display:none — inner toolbar (display:flex) reports // offsetParent===null. Hits during FLP shell back-nav while the result list is // in a hidden subtree. // 2. Outer wrapper is visible (offsetParent!==null) but has transiently collapsed // to width 0 — observed during FLP back-nav where a re-render fires before the // parent flex container has laid the wrapper out. The inner toolbar then reports // a tiny rect width (just the overflow icon, ~16px) and the folding loop computes // a negative toolbarWidth and folds every link incorrectly. Excluded: // display:"contents" — value-help layout uses it legitimately and it always // reports offsetWidth=0 / offsetParent=null even when visible. const ownStyle = getComputedStyle(ownDom); if ( toolbarElem.offsetParent === null || (ownStyle.display !== "contents" && ownDom.offsetWidth === 0) ) { return; } let toolbarWidth = toolbarElem.getBoundingClientRect().width; if (toolbarWidth === 0) { return; } if (ownStyle.display === "none" || getComputedStyle(toolbarElem).display === "none") { return; } const expandButtonContainer = this.getDomRef() ?.closest(".sapUshellSearchResultListItem-Container") ?.querySelector(":scope > .sapUshellSearchResultListItem-ExpandButtonContainer") as HTMLElement; if (expandButtonContainer) { toolbarWidth -= expandButtonContainer.getBoundingClientRect().width; } this.toolbarWidth = toolbarWidth; // Hide overflow before measuring children — if it stays visible we'd double-count its width. // The folding loop below restores it when overflow is needed. overFlowButtonElem.style.display = "none"; let toolbarElementsWidth = 0; const pressButton = (event: Event, _navigationObject: NavigationTarget) => { if (_navigationObject instanceof NavigationTarget) { _navigationObject.performNavigation({ event: event }); } }; const toolbarElements = Array.from( toolbarElem.querySelectorAll(".sapUshellSearchResultListItem-RelatedObjectsToolbar-Element") ) as HTMLElement[]; const elementWidths: number[] = []; let element: HTMLElement; for (let i = 0; i < toolbarElements.length; i++) { element = toolbarElements[i]; element.style.display = ""; const elementStyle = getComputedStyle(element); const elementWidth = element.getBoundingClientRect().width + parseFloat(elementStyle.marginLeft) + parseFloat(elementStyle.marginRight); elementWidths[i] = elementWidth; const _toolbarElementsWidth = toolbarElementsWidth + elementWidth; if (_toolbarElementsWidth + 0.5 > toolbarWidth) { overFlowButtonElem.style.display = ""; const overFlowButtonStyle = getComputedStyle(overFlowButtonElem); const overFlowButtonWidth = overFlowButtonElem.getBoundingClientRect().width + parseFloat(overFlowButtonStyle.marginLeft) + parseFloat(overFlowButtonStyle.marginRight); toolbarElementsWidth = _toolbarElementsWidth; for (; i >= 0; i--) { toolbarElementsWidth -= elementWidths[i]; if (toolbarElementsWidth + overFlowButtonWidth <= toolbarWidth) { break; } } const navigationObjects = this.getProperty("navigationObjects"); this._overFlowActionSheet.destroyButtons(); i = i >= 0 ? i : 0; for (; i < toolbarElements.length; i++) { element = toolbarElements[i]; element.style.display = "none"; const navigationObject = navigationObjects[i]; const button = new Button({ text: navigationObject?.text || "", }); button.attachPress(navigationObject, pressButton); this._overFlowActionSheet.addButton(button); } break; } toolbarElementsWidth = _toolbarElementsWidth; } this._updateTabIndices(toolbarElements); } /** * Safety net for the cosmetic regression where the overflow button is left visible * after the toolbar re-renders inside a hidden subtree (e.g. FLP shell back navigation). * In that path layoutToolbarElements early-returns before its own folding loop can hide * the button. By making the action sheet's button count the source of truth (it is * cleared on every _renderToolbar and only repopulated by the folding loop when overflow * is actually needed), we can decide visibility unambiguously here. */ private syncOverflowVisibilityWithActionSheet(): void { const overFlowButtonElem = this._overFlowButton?.getDomRef() as HTMLElement | null; if (!overFlowButtonElem || !this._overFlowActionSheet) { return; } const needsOverflow = this._overFlowActionSheet.getButtons().length > 0; overFlowButtonElem.style.display = needsOverflow ? "" : "none"; } private _updateTabIndices(toolbarElements: HTMLElement[]): void { toolbarElements.forEach((element) => { if (element.style.display === "none") { element.setAttribute("tabindex", "-1"); } else { element.setAttribute("tabindex", "0"); } }); const overFlowButtonElem = this._overFlowButton.getDomRef() as HTMLElement | null; if (overFlowButtonElem) { if (overFlowButtonElem.style.display === "none") { overFlowButtonElem.setAttribute("tabindex", "-1"); } else { overFlowButtonElem.setAttribute("tabindex", "0"); } } } private _addAriaInformation(): void { const toolbarElem = ( this.getAggregation("_relatedObjectActionsToolbar") as Toolbar ).getDomRef() as HTMLElement; const navigationLinks = Array.from( toolbarElem.querySelectorAll(".sapUshellSearchResultListItem-RelatedObjectsToolbar-Element") ) as HTMLElement[]; const overFlowButtonElem = this._overFlowButton.getDomRef() as HTMLElement; const isOverFlowButtonVisible = overFlowButtonElem.offsetParent !== null; if (navigationLinks.length > 0 || isOverFlowButtonVisible) { const ariaDescriptionId = ( this.getAggregation("_ariaDescriptionForLinks") as InvisibleText ).getId(); navigationLinks.forEach(function (el) { let ariaDescription = el.getAttribute("aria-describedby") || ""; ariaDescription += " " + ariaDescriptionId; el.setAttribute("aria-describedby", ariaDescription); }); if (isOverFlowButtonVisible) { let ariaDescription = overFlowButtonElem.getAttribute("aria-describedby") || ""; ariaDescription += " " + ariaDescriptionId; overFlowButtonElem.setAttribute("aria-describedby", ariaDescription); } } } }