Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/base/src/util/generateHighlightedMarkupFirstMatch.ts
Original file line number Diff line number Diff line change
@@ -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 `<b>` 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 `<b>` 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}<b>${match}</b>${afterMatch}`;
}

export default generateHighlightedMarkupFirstMatch;
267 changes: 267 additions & 0 deletions packages/main/cypress/specs/ComboBox.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ComboBox>
<ComboBoxItem text="Argentina"></ComboBoxItem>
<ComboBoxItem text="South Africa"></ComboBoxItem>
<ComboBoxItem text="Bulgaria"></ComboBoxItem>
</ComboBox>
);

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", "<b>A</b>");

// Check South Africa is highlighted (second word)
cy.get("@combobox").find("[ui5-cb-item]").eq(1).shadow().find(".ui5-li-title")
.should("contain.html", "<b>A</b>");
});

it("should highlight with StartsWithPerTerm pattern regardless of filter mode", () => {
cy.mount(
<ComboBox filter="Contains">
<ComboBoxItem text="Bosnia and Herzegovina"></ComboBoxItem>
<ComboBoxItem text="South Africa"></ComboBoxItem>
</ComboBox>
);

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", "<b>Her</b>");
});

it("should highlight grouped items", () => {
cy.mount(
<ComboBox>
<ComboBoxItemGroup header-text="Group A">
<ComboBoxItem text="Argentina"></ComboBoxItem>
<ComboBoxItem text="Australia"></ComboBoxItem>
</ComboBoxItemGroup>
<ComboBoxItemGroup header-text="Group B">
<ComboBoxItem text="South Africa"></ComboBoxItem>
<ComboBoxItem text="Brazil"></ComboBoxItem>
</ComboBoxItemGroup>
</ComboBox>
);

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", "<b>A</b>");

cy.get("@combobox").find("[ui5-cb-item-group]").eq(0)
.find("[ui5-cb-item]").eq(1).shadow().find(".ui5-li-title")
.should("contain.html", "<b>A</b>");

// 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", "<b>A</b>");
});

it("should handle special characters safely", () => {
cy.mount(
<ComboBox>
<ComboBoxItem text="<script>alert('XSS')</script>"></ComboBoxItem>
<ComboBoxItem text="Price: $100 & Up"></ComboBoxItem>
</ComboBox>
);

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", "<b>P</b>rice: $100 &amp; Up");

// Script tags should be escaped
cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title")
.should("not.contain.html", "<script>");
});

it("should only highlight text, not additionalText", () => {
cy.mount(
<ComboBox>
<ComboBoxItem text="Argentina" additional-text="AR"></ComboBoxItem>
<ComboBoxItem text="Australia" additional-text="AU"></ComboBoxItem>
</ComboBox>
);

cy.get("[ui5-combobox]")
.as("combobox")
.shadow()
.find("input")
.realClick();

cy.get("[ui5-combobox]")
.shadow()
.find("input")
.realType("A");

// Main text should be highlighted
cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title")
.should("contain.html", "<b>A</b>");

// Additional text should NOT be highlighted (no <b> tags)
cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-additional-text")
.should("not.contain.html", "<b>");
});

it("should clear highlighting when input is cleared", () => {
cy.mount(
<ComboBox showClearIcon>
<ComboBoxItem text="Argentina"></ComboBoxItem>
<ComboBoxItem text="Australia"></ComboBoxItem>
</ComboBox>
);

cy.get("[ui5-combobox]")
.as("combobox")
.shadow()
.find("input")
.as("input");

// Type to get highlighting
cy.get("@input").realClick();
cy.get("@input").realType("A");

// Should be highlighted
cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title")
.should("contain.html", "<b>A</b>");

// Clear input
cy.get("@combobox").shadow().find(".ui5-input-clear-icon-wrapper").realClick();

// Open dropdown again to check items
cy.get("@combobox").shadow().find("[ui5-icon]").last().realClick();

// Should not be highlighted anymore
cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title")
.should("not.contain.html", "<b>");
});

it("should highlight only the first match, not all occurrences", () => {
cy.mount(
<ComboBox>
<ComboBoxItem text="New New York"></ComboBoxItem>
</ComboBox>
);

cy.get("[ui5-combobox]")
.as("combobox")
.shadow()
.find("input")
.realClick();

cy.get("[ui5-combobox]")
.shadow()
.find("input")
.realType("New");

// Should only highlight the first "New", not the second
cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title")
.invoke("html")
.then((html) => {
// Count <b> tags - should be exactly 1 pair
const openTags = (html.match(/<b>/g) || []).length;
const closeTags = (html.match(/<\/b>/g) || []).length;
expect(openTags).to.equal(1);
expect(closeTags).to.equal(1);
});
});

it("should handle case-insensitive matching", () => {
cy.mount(
<ComboBox>
<ComboBoxItem text="ARGENTINA"></ComboBoxItem>
<ComboBoxItem text="argentina"></ComboBoxItem>
<ComboBoxItem text="ArGeNtInA"></ComboBoxItem>
</ComboBox>
);

cy.get("[ui5-combobox]")
.as("combobox")
.shadow()
.find("input")
.realClick();

cy.get("[ui5-combobox]")
.shadow()
.find("input")
.realType("arg");

// All three should be highlighted (case-insensitive)
cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title")
.should("contain.html", "<b>ARG</b>");

cy.get("@combobox").find("[ui5-cb-item]").eq(1).shadow().find(".ui5-li-title")
.should("contain.html", "<b>arg</b>");

cy.get("@combobox").find("[ui5-cb-item]").eq(2).shadow().find(".ui5-li-title")
.should("contain.html", "<b>ArG</b>");
});

it("should preserve original case in highlighting", () => {
cy.mount(
<ComboBox>
<ComboBoxItem text="South AFRICA"></ComboBoxItem>
</ComboBox>
);

cy.get("[ui5-combobox]")
.as("combobox")
.shadow()
.find("input")
.realClick();

cy.get("[ui5-combobox]")
.shadow()
.find("input")
.realType("afr");

// Should preserve original case "AFR" not "afr"
cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title")
.should("contain.html", "<b>AFR</b>");
});
});
75 changes: 75 additions & 0 deletions packages/main/cypress/specs/ComboBox.mobile.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -543,3 +543,78 @@ describe("Value state header", () => {
.should("be.visible");
});
});

describe("Mobile Highlighting", () => {
beforeEach(() => {
cy.ui5SimulateDevice("phone");
});

it("Should highlight suggestions in mobile mode", () => {
cy.mount(
<ComboBox>
<ComboBoxItem text="Argentina" />
<ComboBoxItem text="South Africa" />
<ComboBoxItem text="Bulgaria" />
</ComboBox>
);

cy.get("[ui5-combobox]").realClick();

cy.get("[ui5-combobox]")
.shadow()
.find<ResponsivePopover>("[ui5-responsive-popover]")
.as("popover")
.ui5ResponsivePopoverOpened();

// Type in mobile input
cy.get("@popover").find("[ui5-input]").shadow().find("input").realType("A");

// Check that SuggestionItems are highlighted
cy.get("@popover").find("[ui5-input]").find("[ui5-suggestion-item]").eq(0)
.shadow().find(".ui5-li-title")
.should("contain.html", "<b>A</b>");

cy.get("@popover").find("[ui5-input]").find("[ui5-suggestion-item]").eq(1)
.shadow().find(".ui5-li-title")
.should("contain.html", "<b>A</b>");
});

it("Should highlight grouped items in mobile mode", () => {
cy.mount(
<ComboBox>
<ComboBoxItemGroup header-text="Group A">
<ComboBoxItem text="Argentina" />
<ComboBoxItem text="Australia" />
</ComboBoxItemGroup>
<ComboBoxItemGroup header-text="Group B">
<ComboBoxItem text="South Africa" />
<ComboBoxItem text="Brazil" />
</ComboBoxItemGroup>
</ComboBox>
);

cy.get("[ui5-combobox]").realClick();

cy.get("[ui5-combobox]")
.shadow()
.find<ResponsivePopover>("[ui5-responsive-popover]")
.as("popover")
.ui5ResponsivePopoverOpened();

// Type in mobile input
cy.get("@popover").find("[ui5-input]").shadow().find("input").realType("A");

// Check that the first three suggestion items are highlighted (Argentina, Australia, South Africa)
cy.get("@popover").find("[ui5-input]").find("[ui5-suggestion-item]").eq(0)
.shadow().find(".ui5-li-title")
.should("contain.html", "<b>A</b>");

cy.get("@popover").find("[ui5-input]").find("[ui5-suggestion-item]").eq(1)
.shadow().find(".ui5-li-title")
.should("contain.html", "<b>A</b>");

cy.get("@popover").find("[ui5-input]").find("[ui5-suggestion-item]").eq(2)
.shadow().find(".ui5-li-title")
.should("contain.html", "<b>A</b>");
});
});
Loading
Loading