diff --git a/packages/base/src/util/generateHighlightedMarkupFirstMatch.ts b/packages/base/src/util/generateHighlightedMarkupFirstMatch.ts new file mode 100644 index 000000000000..867ead7f6184 --- /dev/null +++ b/packages/base/src/util/generateHighlightedMarkupFirstMatch.ts @@ -0,0 +1,43 @@ +import escapeRegex from "./escapeRegex.js"; +// @ts-expect-error +import encodeXML from "../sap/base/security/encodeXML.js"; + +/** + * Generate markup for a raw string where the first match following StartsWithPerTerm pattern is wrapped with `` tag. + * StartsWithPerTerm pattern: finds the first word (at start or after whitespace) that starts with the search text. + * All inputs to this function are considered literal text, and special characters will always be escaped. + * @param {string} text The text to add highlighting to + * @param {string} textToHighlight The text which should be highlighted (case-insensitive) + * @return {string} the markup HTML which contains the first match surrounded with a `` tag. + */ +function generateHighlightedMarkupFirstMatch(text: string, textToHighlight: string): string { + const normalizedText = text || ""; + + if (!normalizedText || !textToHighlight) { + return encodeXML(normalizedText) as string; + } + + const filterValue = textToHighlight.toLowerCase(); + const lowerText = normalizedText.toLowerCase(); + const matchLength = textToHighlight.length; + + // Find the first word that starts with textToHighlight (StartsWithPerTerm pattern) + let matchIndex = lowerText.search(new RegExp(`(^|\\s)${escapeRegex(filterValue)}`)); + if (matchIndex !== -1 && lowerText[matchIndex] === " ") { + matchIndex++; // Skip the space + } + + // If no match found, return encoded text + if (matchIndex === -1) { + return encodeXML(normalizedText) as string; + } + + // Build highlighted markup with only the first match + const beforeMatch = encodeXML(normalizedText.substring(0, matchIndex)) as string; + const match = encodeXML(normalizedText.substring(matchIndex, matchIndex + matchLength)) as string; + const afterMatch = encodeXML(normalizedText.substring(matchIndex + matchLength)) as string; + + return `${beforeMatch}${match}${afterMatch}`; +} + +export default generateHighlightedMarkupFirstMatch; diff --git a/packages/main/cypress/specs/ComboBox.cy.tsx b/packages/main/cypress/specs/ComboBox.cy.tsx index 3e8c38a5ec36..55d7fb7c44fb 100644 --- a/packages/main/cypress/specs/ComboBox.cy.tsx +++ b/packages/main/cypress/specs/ComboBox.cy.tsx @@ -3553,3 +3553,270 @@ describe("Case-Insensitive Selection", () => { cy.get("[ui5-cb-item]").eq(1).should("have.prop", "selected", true); }); }); + +describe("Highlighting", () => { + it("should highlight first match when typing", () => { + cy.mount( + + + + + + ); + + cy.get("[ui5-combobox]") + .as("combobox") + .shadow() + .find("input") + .as("input"); + + // Type "A" - should highlight first word starting with "A" + cy.get("@input").realClick(); + cy.get("@input").realType("A"); + + // Check Argentina is highlighted + cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title") + .should("contain.html", "A"); + + // Check South Africa is highlighted (second word) + cy.get("@combobox").find("[ui5-cb-item]").eq(1).shadow().find(".ui5-li-title") + .should("contain.html", "A"); + }); + + it("should highlight with StartsWithPerTerm pattern regardless of filter mode", () => { + cy.mount( + + + + + ); + + cy.get("[ui5-combobox]") + .as("combobox") + .shadow() + .find("input") + .as("input"); + + // Type "Her" - with Contains filter, both items should show + // But highlighting should use StartsWithPerTerm (first word starting with "Her") + cy.get("@input").realClick(); + cy.get("@input").realType("Her"); + + // Herzegovina should be highlighted (word starts with "Her") + cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title") + .should("contain.html", "Her"); + }); + + it("should highlight grouped items", () => { + cy.mount( + + + + + + + + + + + ); + + cy.get("[ui5-combobox]") + .as("combobox") + .shadow() + .find("input") + .realClick(); + + cy.get("[ui5-combobox]") + .shadow() + .find("input") + .realType("A"); + + // Check both items in Group A are highlighted + cy.get("@combobox").find("[ui5-cb-item-group]").eq(0) + .find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title") + .should("contain.html", "A"); + + cy.get("@combobox").find("[ui5-cb-item-group]").eq(0) + .find("[ui5-cb-item]").eq(1).shadow().find(".ui5-li-title") + .should("contain.html", "A"); + + // Check South Africa is highlighted (second word) + cy.get("@combobox").find("[ui5-cb-item-group]").eq(1) + .find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title") + .should("contain.html", "A"); + }); + + it("should handle special characters safely", () => { + cy.mount( + + + + + ); + + cy.get("[ui5-combobox]") + .as("combobox") + .shadow() + .find("input") + .realClick(); + + cy.get("[ui5-combobox]") + .shadow() + .find("input") + .realType("P"); + + // Special characters should be escaped, no XSS + cy.get("@combobox").find("[ui5-cb-item]").eq(1).shadow().find(".ui5-li-title") + .should("contain.html", "Price: $100 & Up"); + + // Script tags should be escaped + cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title") + .should("not.contain.html", "