Skip to content

Commit 05c1e08

Browse files
authored
feat(ui5-combobox): add suggestion highlight of matching characters (#13282)
1 parent 685bd93 commit 05c1e08

File tree

7 files changed

+432
-10
lines changed

7 files changed

+432
-10
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import escapeRegex from "./escapeRegex.js";
2+
// @ts-expect-error
3+
import encodeXML from "../sap/base/security/encodeXML.js";
4+
5+
/**
6+
* Generate markup for a raw string where the first match following StartsWithPerTerm pattern is wrapped with `<b>` tag.
7+
* StartsWithPerTerm pattern: finds the first word (at start or after whitespace) that starts with the search text.
8+
* All inputs to this function are considered literal text, and special characters will always be escaped.
9+
* @param {string} text The text to add highlighting to
10+
* @param {string} textToHighlight The text which should be highlighted (case-insensitive)
11+
* @return {string} the markup HTML which contains the first match surrounded with a `<b>` tag.
12+
*/
13+
function generateHighlightedMarkupFirstMatch(text: string, textToHighlight: string): string {
14+
const normalizedText = text || "";
15+
16+
if (!normalizedText || !textToHighlight) {
17+
return encodeXML(normalizedText) as string;
18+
}
19+
20+
const filterValue = textToHighlight.toLowerCase();
21+
const lowerText = normalizedText.toLowerCase();
22+
const matchLength = textToHighlight.length;
23+
24+
// Find the first word that starts with textToHighlight (StartsWithPerTerm pattern)
25+
let matchIndex = lowerText.search(new RegExp(`(^|\\s)${escapeRegex(filterValue)}`));
26+
if (matchIndex !== -1 && lowerText[matchIndex] === " ") {
27+
matchIndex++; // Skip the space
28+
}
29+
30+
// If no match found, return encoded text
31+
if (matchIndex === -1) {
32+
return encodeXML(normalizedText) as string;
33+
}
34+
35+
// Build highlighted markup with only the first match
36+
const beforeMatch = encodeXML(normalizedText.substring(0, matchIndex)) as string;
37+
const match = encodeXML(normalizedText.substring(matchIndex, matchIndex + matchLength)) as string;
38+
const afterMatch = encodeXML(normalizedText.substring(matchIndex + matchLength)) as string;
39+
40+
return `${beforeMatch}<b>${match}</b>${afterMatch}`;
41+
}
42+
43+
export default generateHighlightedMarkupFirstMatch;

packages/main/cypress/specs/ComboBox.cy.tsx

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3685,3 +3685,270 @@ describe("Case-Insensitive Selection", () => {
36853685
cy.get("[ui5-cb-item]").eq(1).should("have.prop", "selected", true);
36863686
});
36873687
});
3688+
3689+
describe("Highlighting", () => {
3690+
it("should highlight first match when typing", () => {
3691+
cy.mount(
3692+
<ComboBox>
3693+
<ComboBoxItem text="Argentina"></ComboBoxItem>
3694+
<ComboBoxItem text="South Africa"></ComboBoxItem>
3695+
<ComboBoxItem text="Bulgaria"></ComboBoxItem>
3696+
</ComboBox>
3697+
);
3698+
3699+
cy.get("[ui5-combobox]")
3700+
.as("combobox")
3701+
.shadow()
3702+
.find("input")
3703+
.as("input");
3704+
3705+
// Type "A" - should highlight first word starting with "A"
3706+
cy.get("@input").realClick();
3707+
cy.get("@input").realType("A");
3708+
3709+
// Check Argentina is highlighted
3710+
cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title")
3711+
.should("contain.html", "<b>A</b>");
3712+
3713+
// Check South Africa is highlighted (second word)
3714+
cy.get("@combobox").find("[ui5-cb-item]").eq(1).shadow().find(".ui5-li-title")
3715+
.should("contain.html", "<b>A</b>");
3716+
});
3717+
3718+
it("should highlight with StartsWithPerTerm pattern regardless of filter mode", () => {
3719+
cy.mount(
3720+
<ComboBox filter="Contains">
3721+
<ComboBoxItem text="Bosnia and Herzegovina"></ComboBoxItem>
3722+
<ComboBoxItem text="South Africa"></ComboBoxItem>
3723+
</ComboBox>
3724+
);
3725+
3726+
cy.get("[ui5-combobox]")
3727+
.as("combobox")
3728+
.shadow()
3729+
.find("input")
3730+
.as("input");
3731+
3732+
// Type "Her" - with Contains filter, both items should show
3733+
// But highlighting should use StartsWithPerTerm (first word starting with "Her")
3734+
cy.get("@input").realClick();
3735+
cy.get("@input").realType("Her");
3736+
3737+
// Herzegovina should be highlighted (word starts with "Her")
3738+
cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title")
3739+
.should("contain.html", "<b>Her</b>");
3740+
});
3741+
3742+
it("should highlight grouped items", () => {
3743+
cy.mount(
3744+
<ComboBox>
3745+
<ComboBoxItemGroup header-text="Group A">
3746+
<ComboBoxItem text="Argentina"></ComboBoxItem>
3747+
<ComboBoxItem text="Australia"></ComboBoxItem>
3748+
</ComboBoxItemGroup>
3749+
<ComboBoxItemGroup header-text="Group B">
3750+
<ComboBoxItem text="South Africa"></ComboBoxItem>
3751+
<ComboBoxItem text="Brazil"></ComboBoxItem>
3752+
</ComboBoxItemGroup>
3753+
</ComboBox>
3754+
);
3755+
3756+
cy.get("[ui5-combobox]")
3757+
.as("combobox")
3758+
.shadow()
3759+
.find("input")
3760+
.realClick();
3761+
3762+
cy.get("[ui5-combobox]")
3763+
.shadow()
3764+
.find("input")
3765+
.realType("A");
3766+
3767+
// Check both items in Group A are highlighted
3768+
cy.get("@combobox").find("[ui5-cb-item-group]").eq(0)
3769+
.find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title")
3770+
.should("contain.html", "<b>A</b>");
3771+
3772+
cy.get("@combobox").find("[ui5-cb-item-group]").eq(0)
3773+
.find("[ui5-cb-item]").eq(1).shadow().find(".ui5-li-title")
3774+
.should("contain.html", "<b>A</b>");
3775+
3776+
// Check South Africa is highlighted (second word)
3777+
cy.get("@combobox").find("[ui5-cb-item-group]").eq(1)
3778+
.find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title")
3779+
.should("contain.html", "<b>A</b>");
3780+
});
3781+
3782+
it("should handle special characters safely", () => {
3783+
cy.mount(
3784+
<ComboBox>
3785+
<ComboBoxItem text="<script>alert('XSS')</script>"></ComboBoxItem>
3786+
<ComboBoxItem text="Price: $100 & Up"></ComboBoxItem>
3787+
</ComboBox>
3788+
);
3789+
3790+
cy.get("[ui5-combobox]")
3791+
.as("combobox")
3792+
.shadow()
3793+
.find("input")
3794+
.realClick();
3795+
3796+
cy.get("[ui5-combobox]")
3797+
.shadow()
3798+
.find("input")
3799+
.realType("P");
3800+
3801+
// Special characters should be escaped, no XSS
3802+
cy.get("@combobox").find("[ui5-cb-item]").eq(1).shadow().find(".ui5-li-title")
3803+
.should("contain.html", "<b>P</b>rice: $100 &amp; Up");
3804+
3805+
// Script tags should be escaped
3806+
cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title")
3807+
.should("not.contain.html", "<script>");
3808+
});
3809+
3810+
it("should only highlight text, not additionalText", () => {
3811+
cy.mount(
3812+
<ComboBox>
3813+
<ComboBoxItem text="Argentina" additional-text="AR"></ComboBoxItem>
3814+
<ComboBoxItem text="Australia" additional-text="AU"></ComboBoxItem>
3815+
</ComboBox>
3816+
);
3817+
3818+
cy.get("[ui5-combobox]")
3819+
.as("combobox")
3820+
.shadow()
3821+
.find("input")
3822+
.realClick();
3823+
3824+
cy.get("[ui5-combobox]")
3825+
.shadow()
3826+
.find("input")
3827+
.realType("A");
3828+
3829+
// Main text should be highlighted
3830+
cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title")
3831+
.should("contain.html", "<b>A</b>");
3832+
3833+
// Additional text should NOT be highlighted (no <b> tags)
3834+
cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-additional-text")
3835+
.should("not.contain.html", "<b>");
3836+
});
3837+
3838+
it("should clear highlighting when input is cleared", () => {
3839+
cy.mount(
3840+
<ComboBox showClearIcon>
3841+
<ComboBoxItem text="Argentina"></ComboBoxItem>
3842+
<ComboBoxItem text="Australia"></ComboBoxItem>
3843+
</ComboBox>
3844+
);
3845+
3846+
cy.get("[ui5-combobox]")
3847+
.as("combobox")
3848+
.shadow()
3849+
.find("input")
3850+
.as("input");
3851+
3852+
// Type to get highlighting
3853+
cy.get("@input").realClick();
3854+
cy.get("@input").realType("A");
3855+
3856+
// Should be highlighted
3857+
cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title")
3858+
.should("contain.html", "<b>A</b>");
3859+
3860+
// Clear input
3861+
cy.get("@combobox").shadow().find(".ui5-input-clear-icon-wrapper").realClick();
3862+
3863+
// Open dropdown again to check items
3864+
cy.get("@combobox").shadow().find("[ui5-icon]").last().realClick();
3865+
3866+
// Should not be highlighted anymore
3867+
cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title")
3868+
.should("not.contain.html", "<b>");
3869+
});
3870+
3871+
it("should highlight only the first match, not all occurrences", () => {
3872+
cy.mount(
3873+
<ComboBox>
3874+
<ComboBoxItem text="New New York"></ComboBoxItem>
3875+
</ComboBox>
3876+
);
3877+
3878+
cy.get("[ui5-combobox]")
3879+
.as("combobox")
3880+
.shadow()
3881+
.find("input")
3882+
.realClick();
3883+
3884+
cy.get("[ui5-combobox]")
3885+
.shadow()
3886+
.find("input")
3887+
.realType("New");
3888+
3889+
// Should only highlight the first "New", not the second
3890+
cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title")
3891+
.invoke("html")
3892+
.then((html) => {
3893+
// Count <b> tags - should be exactly 1 pair
3894+
const openTags = (html.match(/<b>/g) || []).length;
3895+
const closeTags = (html.match(/<\/b>/g) || []).length;
3896+
expect(openTags).to.equal(1);
3897+
expect(closeTags).to.equal(1);
3898+
});
3899+
});
3900+
3901+
it("should handle case-insensitive matching", () => {
3902+
cy.mount(
3903+
<ComboBox>
3904+
<ComboBoxItem text="ARGENTINA"></ComboBoxItem>
3905+
<ComboBoxItem text="argentina"></ComboBoxItem>
3906+
<ComboBoxItem text="ArGeNtInA"></ComboBoxItem>
3907+
</ComboBox>
3908+
);
3909+
3910+
cy.get("[ui5-combobox]")
3911+
.as("combobox")
3912+
.shadow()
3913+
.find("input")
3914+
.realClick();
3915+
3916+
cy.get("[ui5-combobox]")
3917+
.shadow()
3918+
.find("input")
3919+
.realType("arg");
3920+
3921+
// All three should be highlighted (case-insensitive)
3922+
cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title")
3923+
.should("contain.html", "<b>ARG</b>");
3924+
3925+
cy.get("@combobox").find("[ui5-cb-item]").eq(1).shadow().find(".ui5-li-title")
3926+
.should("contain.html", "<b>arg</b>");
3927+
3928+
cy.get("@combobox").find("[ui5-cb-item]").eq(2).shadow().find(".ui5-li-title")
3929+
.should("contain.html", "<b>ArG</b>");
3930+
});
3931+
3932+
it("should preserve original case in highlighting", () => {
3933+
cy.mount(
3934+
<ComboBox>
3935+
<ComboBoxItem text="South AFRICA"></ComboBoxItem>
3936+
</ComboBox>
3937+
);
3938+
3939+
cy.get("[ui5-combobox]")
3940+
.as("combobox")
3941+
.shadow()
3942+
.find("input")
3943+
.realClick();
3944+
3945+
cy.get("[ui5-combobox]")
3946+
.shadow()
3947+
.find("input")
3948+
.realType("afr");
3949+
3950+
// Should preserve original case "AFR" not "afr"
3951+
cy.get("@combobox").find("[ui5-cb-item]").eq(0).shadow().find(".ui5-li-title")
3952+
.should("contain.html", "<b>AFR</b>");
3953+
});
3954+
});

packages/main/cypress/specs/ComboBox.mobile.cy.tsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,3 +543,78 @@ describe("Value state header", () => {
543543
.should("be.visible");
544544
});
545545
});
546+
547+
describe("Mobile Highlighting", () => {
548+
beforeEach(() => {
549+
cy.ui5SimulateDevice("phone");
550+
});
551+
552+
it("Should highlight suggestions in mobile mode", () => {
553+
cy.mount(
554+
<ComboBox>
555+
<ComboBoxItem text="Argentina" />
556+
<ComboBoxItem text="South Africa" />
557+
<ComboBoxItem text="Bulgaria" />
558+
</ComboBox>
559+
);
560+
561+
cy.get("[ui5-combobox]").realClick();
562+
563+
cy.get("[ui5-combobox]")
564+
.shadow()
565+
.find<ResponsivePopover>("[ui5-responsive-popover]")
566+
.as("popover")
567+
.ui5ResponsivePopoverOpened();
568+
569+
// Type in mobile input
570+
cy.get("@popover").find("[ui5-input]").shadow().find("input").realType("A");
571+
572+
// Check that SuggestionItems are highlighted
573+
cy.get("@popover").find("[ui5-input]").find("[ui5-suggestion-item]").eq(0)
574+
.shadow().find(".ui5-li-title")
575+
.should("contain.html", "<b>A</b>");
576+
577+
cy.get("@popover").find("[ui5-input]").find("[ui5-suggestion-item]").eq(1)
578+
.shadow().find(".ui5-li-title")
579+
.should("contain.html", "<b>A</b>");
580+
});
581+
582+
it("Should highlight grouped items in mobile mode", () => {
583+
cy.mount(
584+
<ComboBox>
585+
<ComboBoxItemGroup header-text="Group A">
586+
<ComboBoxItem text="Argentina" />
587+
<ComboBoxItem text="Australia" />
588+
</ComboBoxItemGroup>
589+
<ComboBoxItemGroup header-text="Group B">
590+
<ComboBoxItem text="South Africa" />
591+
<ComboBoxItem text="Brazil" />
592+
</ComboBoxItemGroup>
593+
</ComboBox>
594+
);
595+
596+
cy.get("[ui5-combobox]").realClick();
597+
598+
cy.get("[ui5-combobox]")
599+
.shadow()
600+
.find<ResponsivePopover>("[ui5-responsive-popover]")
601+
.as("popover")
602+
.ui5ResponsivePopoverOpened();
603+
604+
// Type in mobile input
605+
cy.get("@popover").find("[ui5-input]").shadow().find("input").realType("A");
606+
607+
// Check that the first three suggestion items are highlighted (Argentina, Australia, South Africa)
608+
cy.get("@popover").find("[ui5-input]").find("[ui5-suggestion-item]").eq(0)
609+
.shadow().find(".ui5-li-title")
610+
.should("contain.html", "<b>A</b>");
611+
612+
cy.get("@popover").find("[ui5-input]").find("[ui5-suggestion-item]").eq(1)
613+
.shadow().find(".ui5-li-title")
614+
.should("contain.html", "<b>A</b>");
615+
616+
cy.get("@popover").find("[ui5-input]").find("[ui5-suggestion-item]").eq(2)
617+
.shadow().find(".ui5-li-title")
618+
.should("contain.html", "<b>A</b>");
619+
});
620+
});

0 commit comments

Comments
 (0)