From 1b42b159ecfca6035530610b0e7d1649696a4eed Mon Sep 17 00:00:00 2001 From: Milen Karmidzhanov Date: Mon, 16 Mar 2026 18:35:37 +0200 Subject: [PATCH 1/5] feat(ui5-combobox): add suggestion highlight of matching characters --- .../generateHighlightedMarkupFirstMatch.ts | 43 +++ packages/main/cypress/specs/ComboBox.cy.tsx | 291 ++++++++++++++++++ .../main/cypress/specs/ComboBox.mobile.cy.tsx | 100 ++++++ packages/main/src/ComboBox.ts | 46 +++ packages/main/src/ComboBoxItemTemplate.tsx | 2 +- packages/main/src/ComboBoxPopoverTemplate.tsx | 14 +- packages/main/test/pages/ComboBox.html | 16 +- 7 files changed, 501 insertions(+), 11 deletions(-) create mode 100644 packages/base/src/util/generateHighlightedMarkupFirstMatch.ts diff --git a/packages/base/src/util/generateHighlightedMarkupFirstMatch.ts b/packages/base/src/util/generateHighlightedMarkupFirstMatch.ts new file mode 100644 index 000000000000..adafa163c797 --- /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 | undefined, 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 69862539d967..fc8ed5ad03d5 100644 --- a/packages/main/cypress/specs/ComboBox.cy.tsx +++ b/packages/main/cypress/specs/ComboBox.cy.tsx @@ -3542,3 +3542,294 @@ describe("Case-Insensitive Selection", () => { cy.get("[ui5-cb-item]").eq(1).should("have.prop", "selected", true); }); }); + +describe("Highlighting", () => { + it("should highlight first match when highlight is enabled", () => { + 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 not highlight when highlight is disabled", () => { + cy.mount( + + + + + ); + + cy.get("[ui5-combobox]") + .as("combobox") + .shadow() + .find("input") + .realClick(); + + cy.get("[ui5-combobox]") + .shadow() + .find("input") + .realType("A"); + + // Check no tags in the items + cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title") + .should("not.contain.html", ""); + }); + + 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", ""> @@ -3690,7 +3666,7 @@ describe("Highlighting", () => { it("should only highlight text, not additionalText", () => { cy.mount( - + @@ -3718,7 +3694,7 @@ describe("Highlighting", () => { it("should clear highlighting when input is cleared", () => { cy.mount( - + @@ -3751,7 +3727,7 @@ describe("Highlighting", () => { it("should highlight only the first match, not all occurrences", () => { cy.mount( - + ); @@ -3781,7 +3757,7 @@ describe("Highlighting", () => { it("should handle case-insensitive matching", () => { cy.mount( - + @@ -3812,7 +3788,7 @@ describe("Highlighting", () => { it("should preserve original case in highlighting", () => { cy.mount( - + ); diff --git a/packages/main/cypress/specs/ComboBox.mobile.cy.tsx b/packages/main/cypress/specs/ComboBox.mobile.cy.tsx index e76f731275c1..72ce92c0c276 100644 --- a/packages/main/cypress/specs/ComboBox.mobile.cy.tsx +++ b/packages/main/cypress/specs/ComboBox.mobile.cy.tsx @@ -551,7 +551,7 @@ describe("Mobile Highlighting", () => { it("Should highlight suggestions in mobile mode", () => { cy.mount( - + @@ -581,7 +581,7 @@ describe("Mobile Highlighting", () => { it("Should highlight grouped items in mobile mode", () => { cy.mount( - + @@ -617,29 +617,4 @@ describe("Mobile Highlighting", () => { .shadow().find(".ui5-li-title") .should("contain.html", "A"); }); - - it("Should not highlight in mobile mode when highlight is disabled", () => { - cy.mount( - - - - - ); - - cy.get("[ui5-combobox]").realClick(); - - cy.get("[ui5-combobox]") - .shadow() - .find("[ui5-responsive-popover]") - .as("popover") - .ui5ResponsivePopoverOpened(); - - // Type in mobile input - cy.get("@popover").find("[ui5-input]").shadow().find("input").realType("A"); - - // Should NOT contain tags - cy.get("@popover").find("[ui5-input]").find("[ui5-suggestion-item]").eq(0) - .shadow().find(".ui5-li-title") - .should("not.contain.html", ""); - }); }); diff --git a/packages/main/src/ComboBox.ts b/packages/main/src/ComboBox.ts index c8580b17ee9e..73153fe2aae2 100644 --- a/packages/main/src/ComboBox.ts +++ b/packages/main/src/ComboBox.ts @@ -388,17 +388,6 @@ class ComboBox extends UI5Element implements IFormInputElement { @property({ type: Boolean }) showClearIcon = false; - /** - * Defines if characters within the suggestions are to be highlighted - * in case the input value matches parts of the suggestions text. - * - * @default false - * @public - * @since 2.21.0 - */ - @property({ type: Boolean }) - highlight = false; - /** * Indicates whether the input is focused * @private @@ -1250,7 +1239,7 @@ class ComboBox extends UI5Element implements IFormInputElement { * @private */ _highlightItem(item: ComboBoxItem) { - item.markupText = generateHighlightedMarkupFirstMatch(item.text, this._highlightValue); + item.markupText = generateHighlightedMarkupFirstMatch(item.text, this.filterValue); } _getFirstMatchingItem(current: string): IComboBoxItem | void { @@ -1736,13 +1725,6 @@ class ComboBox extends UI5Element implements IFormInputElement { }; } - /** - * Getter that returns the filter value for highlighting when highlight is enabled. - * @private - */ - get _highlightValue() { - return this.highlight ? this.filterValue : ""; - } } ComboBox.define(); diff --git a/packages/main/src/ComboBoxPopoverTemplate.tsx b/packages/main/src/ComboBoxPopoverTemplate.tsx index 0a9f8403e4b0..9eb3f79ae584 100644 --- a/packages/main/src/ComboBoxPopoverTemplate.tsx +++ b/packages/main/src/ComboBoxPopoverTemplate.tsx @@ -60,11 +60,11 @@ export default function ComboBoxPopoverTemplate(this: ComboBox) { return item.items .filter(nestedItem => !!nestedItem) .map(nestedItem => - + ); } // For regular items - return ; + return ; })} From 81bad6acb0068e1a2b9eafdb2c4fb4c5a00d9722 Mon Sep 17 00:00:00 2001 From: Milen Karmidzhanov Date: Tue, 17 Mar 2026 12:07:14 +0200 Subject: [PATCH 4/5] feat(ui5-combobox): add suggestion highlight of matching characters --- packages/main/src/ComboBox.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/main/src/ComboBox.ts b/packages/main/src/ComboBox.ts index 8d02c50d2dcb..6d5542845dbd 100644 --- a/packages/main/src/ComboBox.ts +++ b/packages/main/src/ComboBox.ts @@ -1728,7 +1728,6 @@ class ComboBox extends UI5Element implements IFormInputElement { }, }; } - } ComboBox.define(); From 779e424a099e825dc54fc91eef449e9808da7261 Mon Sep 17 00:00:00 2001 From: Milen Karmidzhanov Date: Tue, 17 Mar 2026 16:18:12 +0200 Subject: [PATCH 5/5] feat(ui5-combobox): add suggestion highlight of matching characters --- packages/base/src/util/generateHighlightedMarkupFirstMatch.ts | 2 +- packages/main/src/ComboBox.ts | 2 +- packages/main/src/ComboBoxPopoverTemplate.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/base/src/util/generateHighlightedMarkupFirstMatch.ts b/packages/base/src/util/generateHighlightedMarkupFirstMatch.ts index adafa163c797..867ead7f6184 100644 --- a/packages/base/src/util/generateHighlightedMarkupFirstMatch.ts +++ b/packages/base/src/util/generateHighlightedMarkupFirstMatch.ts @@ -10,7 +10,7 @@ import encodeXML from "../sap/base/security/encodeXML.js"; * @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 | undefined, textToHighlight: string): string { +function generateHighlightedMarkupFirstMatch(text: string, textToHighlight: string): string { const normalizedText = text || ""; if (!normalizedText || !textToHighlight) { diff --git a/packages/main/src/ComboBox.ts b/packages/main/src/ComboBox.ts index 6d5542845dbd..1283237141ff 100644 --- a/packages/main/src/ComboBox.ts +++ b/packages/main/src/ComboBox.ts @@ -1243,7 +1243,7 @@ class ComboBox extends UI5Element implements IFormInputElement { * @private */ _highlightItem(item: ComboBoxItem) { - item.markupText = generateHighlightedMarkupFirstMatch(item.text, this.filterValue); + item.markupText = generateHighlightedMarkupFirstMatch(item.text || "", this.filterValue); } _getFirstMatchingItem(current: string): IComboBoxItem | void { diff --git a/packages/main/src/ComboBoxPopoverTemplate.tsx b/packages/main/src/ComboBoxPopoverTemplate.tsx index 9eb3f79ae584..a458e41bd0ce 100644 --- a/packages/main/src/ComboBoxPopoverTemplate.tsx +++ b/packages/main/src/ComboBoxPopoverTemplate.tsx @@ -60,11 +60,11 @@ export default function ComboBoxPopoverTemplate(this: ComboBox) { return item.items .filter(nestedItem => !!nestedItem) .map(nestedItem => - + ); } // For regular items - return ; + return ; })}