UNPKG

@openui5/sap.ui.core

Version:

OpenUI5 Core Library sap.ui.core

480 lines (411 loc) 15.6 kB
/*! * OpenUI5 * (c) Copyright 2026 SAP SE or an SAP affiliate company. * Licensed under the Apache License, Version 2.0 - see LICENSE.txt. */ sap.ui.define([ "sap/base/Log", "sap/base/i18n/LanguageFallback", "sap/base/i18n/Localization", "sap/ui/core/Element", "sap/ui/core/LabelEnablement", "sap/ui/core/Lib", "sap/ui/core/util/ShortcutHelper", "sap/ui/util/XMLHelper" ], function(Log, LanguageFallback, Localization, Element, LabelEnablement, Library, ShortcutHelper, XMLHelper) { "use strict"; // Endpoints for sending messages const POST_MESSAGE_ENDPOINT_UPDATE = "sap.ui.interaction.UpdateDisplay"; // Version number for the protocol const VERSION_NUMBER = "1.0.0"; const oProtocol = { _version: VERSION_NUMBER, elements: [], docs: {} }; // Cache to store loaded XML documents const oInteractionXMLCache = new Map(); /** * Translates and annotates all <kbd> elements. * * For each <kbd> element: * - If it doesn't already have a `data-sap-ui-kbd-raw` attribute, it computes a normalized * version of its text content using `ShortcutHelper.normalizeShortcutText()` and sets this attribute. * - Replaces the <kbd> element's text content with the translated shortcut via `translateShortcut()`. * * @param {Array<Element>} kbds An array of <kbd> elements to be translated and annotated. * @return {Array<Element>} The modified array of <kbd> elements with translated text and attributes. */ const annotateAndTranslateKbdTags = (kbds) => { kbds.forEach((kbd) => { if (!kbd.hasAttribute("data-sap-ui-kbd-raw")) { const sNormalized = ShortcutHelper.normalizeShortcutText(kbd.textContent); kbd.setAttribute("data-sap-ui-kbd-raw", sNormalized); kbd.textContent = ShortcutHelper.localizeKeys(sNormalized); } }); return kbds; }; /** * Translates the interaction XML document by annotating and translating all <kbd> tags * within the interaction nodes and their descriptions. * This function modifies the XML document in place. * * @param {XMLDocument} oInteractionXML The interaction XML document to translate. * @return {XMLDocument} The translated interaction XML document. */ const translateInteractionXML = (oInteractionXML) => { const oInteractionDoc = oInteractionXML.documentElement; const kbdElements = Array.from(oInteractionDoc.querySelectorAll("kbd")); annotateAndTranslateKbdTags(kbdElements); return oInteractionXML; }; /** * Retrieves the command information for a given control. * @param {sap.ui.core.Control} oControl The control to analyze. * @return {Array} List of command information objects. */ const getCommandInfosFor = (oControl) => { const aCommandInfos = []; const aDependents = oControl.getDependents?.() || []; for (const oDependent of aDependents) { if (oDependent.isA("sap.ui.core.CommandExecution") && oDependent.getVisible()) { const oCommandInfo = oDependent._getCommandInfo(); const sKbd = ShortcutHelper.normalizeShortcutText(oCommandInfo.shortcut); aCommandInfos.push({ name: oDependent.getCommand(), kbd: [{ raw: sKbd, translated: ShortcutHelper.localizeKeys(sKbd) }], description: oCommandInfo.description }); } } return aCommandInfos; }; /** * Attempts to retrieve a user-friendly label for a given control by examining associated field help, * accessibility info, ARIA attributes, and a provided interaction XML document. * * The method follows this priority order: * 1. Field help label via `getFieldHelpDisplay`. * 2. Label metadata via `LabelEnablement._getLabelTexts`. * 3. Control's accessibility info (`getAccessibilityInfo`). * 4. Label from interaction XML (if matching control metadata is found). * 5. ARIA `aria-labelledby` attribute from DOM or descendants. * 6. Fallback to control metadata name if no label is found. * * @param {sap.ui.core.Control} oControl The control to analayze. * @param {XMLDocument} oInteractionXML The interaction document * @return {string} The label associated with the control. */ const getLabelFor = (oControl, oInteractionXML) => { const sDisplayControlId = oControl.getFieldHelpDisplay(); const oLabelControl = sDisplayControlId ? Element.getElementById(sDisplayControlId) : oControl; // First, try to derive control label from field help, if available let sAccessibilityInfoLabel = LabelEnablement._getLabelTexts(oLabelControl)[0]; // Then, try derive control label from accessibility info, if available if (!sAccessibilityInfoLabel) { const oAccessibilityInfo = oControl.getAccessibilityInfo?.(); if (oAccessibilityInfo) { sAccessibilityInfoLabel = oAccessibilityInfo.description || oAccessibilityInfo.children?.[0]?.getAccessibilityInfo?.()?.description || null; } } const ARIA_LABELLED_BY_ATTR = "aria-labelledby"; if (!sAccessibilityInfoLabel) { let sAriaLabelledById; let oCurrent = oControl; let bCheckedInteractionDoc = false; while (oCurrent && !sAriaLabelledById && !sAccessibilityInfoLabel) { const oDomRef = oControl.getDomRef(); // Try to derive control label from DOM sAriaLabelledById = oDomRef?.getAttribute(ARIA_LABELLED_BY_ATTR); // Try interaction doc only once if (!sAriaLabelledById && !bCheckedInteractionDoc) { bCheckedInteractionDoc = true; const oInteractionDoc = oInteractionXML.documentElement; if (oInteractionDoc) { const aControlInteractionNodes = [...oInteractionDoc.querySelectorAll("control-interactions")]; const oMatchingControl = aControlInteractionNodes.find((oNode) => { return Array.from(oNode.querySelectorAll(`control[name]`)).find((oNode) => { return oNode.getAttribute("name") === oControl.getMetadata().getName(); }); }); sAccessibilityInfoLabel = oMatchingControl?.querySelector("control")?.querySelector("defaultLabel")?.textContent; } } // Try to find aria label from descendents if (!sAriaLabelledById && !sAccessibilityInfoLabel) { const oLabelledByElement = oDomRef.querySelector("[aria-labelledby]"); sAriaLabelledById = oLabelledByElement?.getAttribute(ARIA_LABELLED_BY_ATTR); } oCurrent = oCurrent.getParent?.(); } if (!sAccessibilityInfoLabel && sAriaLabelledById) { const oLabelElement = document.getElementById(sAriaLabelledById); sAccessibilityInfoLabel = oLabelElement?.textContent || null; } } return sAccessibilityInfoLabel || oControl.getMetadata().getName(); }; /** * Load and access interaction-documentation for given control. * @param {sap.ui.core.Control} oControl The control to load the interaction document for * @param {string} sLibrary The library name if already available * @return {Promise<null|XMLDocument>} The interaction document or 'null'. */ const loadInteractionXMLFor = async (oControl, sLibrary) => { let oCurrent = oControl; // Traverse up the control hierarchy to find the library name while (oCurrent && !sLibrary) { sLibrary = oCurrent.getMetadata().getLibraryName(); oCurrent = oCurrent.getParent(); } if (!sLibrary) { return null; } const oLibrary = Library._get(sLibrary); if (!oLibrary?.interactionDocumentation) { return null; } const sLanguage = Localization.getLanguage(); const aFallbackChain = LanguageFallback.getFallbackLocales(sLanguage); let oInteractionXML = null; while (aFallbackChain.length) { const sLocale = aFallbackChain.shift(); const sFileName = sLocale ? `interaction_${sLocale}.xml` : `interaction.xml`; const sResource = sap.ui.require.toUrl(`${sLibrary.replace(/\./g, "/")}/i18n/${sFileName}`); const sCacheKey = `${sLibrary}:${sLocale}`; if (oInteractionXMLCache.has(sCacheKey)) { const oCacheResult = oInteractionXMLCache.get(sCacheKey); if (oCacheResult === null) { Log.debug(`Skipping loading of previously failed interaction XML for library ${sLibrary}, locale ${sLocale}`); continue; } return oCacheResult; } try { const oResponse = await fetch(sResource); if (!oResponse.ok) { const statusMessage = oResponse.statusText || `HTTP status ${oResponse.status}`; throw new Error(`Failed to load resource: ${sResource} - ${statusMessage}`); } const text = await oResponse.text(); oInteractionXML = XMLHelper.parse(text); if (oInteractionXML) { // Translate kbds and descriptions in the interaction XML oInteractionXML = translateInteractionXML(oInteractionXML); // cache the loaded interaction document oInteractionXMLCache.set(sCacheKey, oInteractionXML); break; } } catch (error) { // Cache the failed loading attempt if not already cached if (!oInteractionXMLCache.has(sCacheKey)) { oInteractionXMLCache.set(sCacheKey, null); } Log.error(`Error loading interaction XML for library ${sLibrary}:`, error); } } return oInteractionXML; }; /** * Extracts interaction nodes from the given interaction document and retrieves "kbd" and "description" tags. * * @param {string} sControlName The control name. * @param {XMLDocument} oInteractionXML The interaction document containing the interaction details. * @return {Array} An array containing the control's interaction details, including "kbd" and "description". */ const getInteractions = (sControlName, oInteractionXML) => { const oInteractionDoc = oInteractionXML.documentElement; if (!oInteractionDoc) { return []; } const aControlInteractionNodes = [...oInteractionDoc.querySelectorAll("control-interactions")]; const oMatchingControl = aControlInteractionNodes.find((oNode) => { return Array.from(oNode.querySelectorAll(`control[name]`)).find((oNode) => { return oNode.getAttribute("name") === sControlName; }); }); if (!oMatchingControl) { return []; } return [...oMatchingControl.querySelectorAll("interaction")].map((oInteractionNode) => { const kbdElements = Array.from(oInteractionNode.children).filter((child) => child.tagName === "kbd"); const kbd = kbdElements.map((kbd) => { return { raw: kbd.getAttribute("data-sap-ui-kbd-raw"), translated: kbd.textContent }; }); return { kbd, description: oInteractionNode.querySelector("description")?.innerHTML || "" }; }); }; let oCurrentPort; let bThrottled = false; /** * Initializes the keyboard interaction information gathering. * @param {Event} event The 'focusin' or 'focusout' event triggering the initialization. */ const init = async (event) => { if (bThrottled) { return; } bThrottled = true; setTimeout(() => { bThrottled = false; }, 300); const aControlTree = []; const docs = {}; const oLabelMap = new Map(); let oTargetElement; if (event) { oTargetElement = event.type === "focusin" ? event.target : event.relatedTarget; } oTargetElement ??= document.activeElement; const oTargetControl = Element.closestTo(oTargetElement); // get generic key interactions from sap.ui.core const oCoreXML = await loadInteractionXMLFor(null, "sap.ui.core"); if (oCoreXML) { const oResourceBundle = Library.getResourceBundleFor("sap.ui.core"); const sLabel = oResourceBundle.getText("Generic.Keyboard.Interaction.Text"); docs["sap.ui.core.Control"] = { "interactions": getInteractions("sap.ui.core.Control", oCoreXML) }; oLabelMap.set(sLabel, { "index": -1, "class": "sap.ui.core.Control", "label": sLabel, "interactions": [{ "$ref": `docs/sap.ui.core.Control/interactions` }] }); } let oCurrent = oTargetControl; while (oCurrent) { aControlTree.push(oCurrent); oCurrent = oCurrent.getParent(); } for (let i = 0; i < aControlTree.length; i++) { const oControl = aControlTree[i]; const sControlName = oControl.getMetadata().getName(); // get command infos const aInteractions = getCommandInfosFor(oControl); // get interactions from interaction documentation const oInteractionXML = await loadInteractionXMLFor(oControl); const aDocs = oInteractionXML ? getInteractions(sControlName, oInteractionXML) : []; if (!aInteractions.length && !aDocs.length) { // no commands and no interaction documentation continue; } const sClassName = oControl.getMetadata().getName(); if (aDocs.length > 0) { docs[sClassName] = { "interactions": aDocs }; aInteractions.push({ "$ref": `docs/${sClassName}/interactions` }); } const sLabel = getLabelFor(oControl, oInteractionXML); if (!oLabelMap.has(sLabel)) { oLabelMap.set(sLabel, { interactions: [], label: sLabel }); } const oMapEntry = oLabelMap.get(sLabel); oMapEntry.index = i; oMapEntry.id = oControl.getId(); oMapEntry.class = sClassName; oMapEntry.interactions.unshift(...aInteractions.reverse()); } // Update protocol with gathered elements and documentation oProtocol.elements = Array.from(oLabelMap.values()) .sort((a, b) => { return a.index - b.index; }) .map((oEntry) => { delete oEntry.index; return oEntry; }); oProtocol.docs = docs; // Send protocol oCurrentPort?.postMessage(JSON.parse(JSON.stringify({ service: POST_MESSAGE_ENDPOINT_UPDATE, type: "request", payload: { ...oProtocol } }))); }; /** * Module that handles the gathering and sending of keyboard interaction information. * When active, it starts listening for focusin and focusout event to collect the keyboard interaction data. * The gathered data is then sent via a MessagePort to a connected entity. * * @private */ return { // Indicator if the interaction information gathering is active _isActive: false, /** * Activates the keyboard interaction information gathering. * This methods starts listening for focusin and focusout events to gather the keyboard interaction information. * * @param {MessagePort} oPort The MessagePort used to send the keyboard interaction information. * @private */ async activate(oPort) { oCurrentPort = oPort; if (this._isActive) { return; } this._isActive = true; await init(); // need to register for both focusin and focusout event // Browser fires: // * only focusin when focus is moved from <body> to a focusable element // * only focusout when focus is moved from a focused element to <body> // * first focusout then focusin when moved from a focused element to another focusable element document.addEventListener("focusin", init); document.addEventListener("focusout", init); Localization.attachChange(init); }, /** * Deactivates the keyboard interaction information gathering * This methods stops listening focusin and focusout events, effectively stopping the collection * of the keyboard interaction information. * * @private */ deactivate() { if (!this._isActive) { return; } this._isActive = false; document.removeEventListener("focusin", init); document.removeEventListener("focusout", init); Localization.detachChange(init); }, /** * Expose for testing. * @private */ _: { translateInteractionXML, annotateAndTranslateKbdTags, getInteractions, getCommandInfosFor, loadInteractionXMLFor, hasCacheEntry: (sCacheKey) => { return oInteractionXMLCache.has(sCacheKey); }, getInteractionXMLFromCache: (sCacheKey) => { return oInteractionXMLCache.get(sCacheKey); }, clearCache: () => { oInteractionXMLCache.clear(); } } }; });