Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
9547bf9
chore: add example
TeodorTaushanov Sep 16, 2026
0d236e2
chore: add filterSection slot
TeodorTaushanov Sep 16, 2026
efb3af7
chore: improve test page
TeodorTaushanov Sep 16, 2026
2d85ae1
feat: add text highlighting
TeodorTaushanov Sep 16, 2026
083c752
Merge remote-tracking branch 'origin/main' into sidenav_search
TeodorTaushanov Sep 16, 2026
6d2f8bd
Merge remote-tracking branch 'origin/main' into sidenav_search
TeodorTaushanov Sep 17, 2026
ba0b17b
feat: add SideNavigationSearchField skeleton
TeodorTaushanov Sep 17, 2026
055afb0
chore: add default placeholder
TeodorTaushanov Sep 18, 2026
26f5dd3
Merge remote-tracking branch 'origin/main' into sidenav_search
TeodorTaushanov Sep 18, 2026
ba26a31
chore: improve search field functionality
TeodorTaushanov Sep 18, 2026
78b1d8e
Merge remote-tracking branch 'origin/main' into sidenav_search
TeodorTaushanov Sep 18, 2026
cef0b9d
Merge remote-tracking branch 'origin/main' into sidenav_search
TeodorTaushanov Sep 21, 2026
767ca1c
chore: style improvements
TeodorTaushanov Sep 21, 2026
ae48865
chore: use internal Input and IconInput
TeodorTaushanov Sep 21, 2026
a3d92cf
Merge remote-tracking branch 'origin/main' into sidenav_search
TeodorTaushanov Sep 23, 2026
c1aad0c
feat: add announceSearchMatchCount method
TeodorTaushanov Sep 23, 2026
c725555
Merge remote-tracking branch 'origin/main' into sidenav_search
TeodorTaushanov Sep 23, 2026
74f5561
chore: fix errors
TeodorTaushanov Sep 23, 2026
75eb05d
chore: fix errors
TeodorTaushanov Sep 23, 2026
3dba144
chore: add more tests
TeodorTaushanov Sep 23, 2026
215dcbf
Merge remote-tracking branch 'origin/main' into sidenav_search
TeodorTaushanov Sep 23, 2026
c0dc530
chore: use encodeXML in highlightText util
TeodorTaushanov Sep 24, 2026
3f2f084
chore: fix slot name and highlightText tests
TeodorTaushanov Sep 24, 2026
f17fd63
Merge remote-tracking branch 'origin/main' into sidenav_search
TeodorTaushanov Sep 24, 2026
85bab2c
chore: add more tests
TeodorTaushanov Sep 24, 2026
50d96ab
chore: add public example
TeodorTaushanov Sep 24, 2026
396d08a
chore: improve the examples
TeodorTaushanov Sep 24, 2026
13d3ebc
chore: fix lint errors
TeodorTaushanov Sep 24, 2026
5bc8159
Merge remote-tracking branch 'origin/main' into sidenav_search
TeodorTaushanov Sep 25, 2026
18e83fa
chore: improve samples and hide search icon
TeodorTaushanov Sep 25, 2026
c80be4b
Merge remote-tracking branch 'origin/main' into sidenav_search
TeodorTaushanov Sep 25, 2026
a88a206
chore: improve `0 matches` item styles
TeodorTaushanov Sep 25, 2026
1d3d56e
Merge remote-tracking branch 'origin/main' into sidenav_search
TeodorTaushanov Sep 30, 2026
a01922e
chore: remove ariaControls as it should work in another way
TeodorTaushanov Sep 30, 2026
2e80549
chore: remove ariaControls from the tests
TeodorTaushanov Sep 30, 2026
a3b29b9
Merge remote-tracking branch 'origin/main' into sidenav_search
TeodorTaushanov Oct 5, 2026
42815dd
chore: address code comments
TeodorTaushanov Oct 5, 2026
f8d0ce5
Merge remote-tracking branch 'origin/main' into sidenav_search
TeodorTaushanov Oct 13, 2026
ec6116d
chore: fix hover/active no match style
TeodorTaushanov Oct 13, 2026
59cb5f6
chore: add more tests
TeodorTaushanov Oct 13, 2026
6ed1b68
chore: add new sample correctly
TeodorTaushanov Oct 13, 2026
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
124 changes: 124 additions & 0 deletions packages/base/cypress/specs/util/highlightText.cy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import highlightText from "../../../src/util/highlightText.js";

const CLASS = "hl";
const wrap = (text: string) => `<span class="${CLASS}">${text}</span>`;

// highlightText encodes its output via encodeXML, so spaces and other special
// characters are returned as XML numeric entities (e.g. " " -> "&#x20;").
const SP = "&#x20;";

describe("highlightText", () => {
describe("no highlighting", () => {
it("returns the encoded original text when there is no match", () => {
expect(highlightText("Hello World", "xyz", CLASS)).to.equal(`Hello${SP}World`);
});

it("returns the encoded original text when the highlight is empty", () => {
expect(highlightText("Hello World", "", CLASS)).to.equal(`Hello${SP}World`);
});

it("returns an empty string when the text is null", () => {
expect(highlightText(null, "abc", CLASS)).to.equal("");
});

it("returns an empty string when the text is empty", () => {
expect(highlightText("", "abc", CLASS)).to.equal("");
});

it("returns the encoded original text when the highlight is null", () => {
expect(highlightText("Hello World", null, CLASS)).to.equal(`Hello${SP}World`);
});

it("returns an empty string when both arguments are null", () => {
expect(highlightText(null, null, CLASS)).to.equal("");
});
});

describe("single match", () => {
it("highlights a match in the middle of the text", () => {
expect(highlightText("Hello World", "lo W", CLASS)).to.equal(`Hel${wrap(`lo${SP}W`)}orld`);
});

it("highlights a match at the start of the text", () => {
expect(highlightText("Hello World", "Hello", CLASS)).to.equal(`${wrap("Hello")}${SP}World`);
});

it("highlights a match at the end of the text", () => {
expect(highlightText("Hello World", "World", CLASS)).to.equal(`Hello${SP}${wrap("World")}`);
});

it("highlights the whole text when it fully matches", () => {
expect(highlightText("Hello", "Hello", CLASS)).to.equal(wrap("Hello"));
});
});

describe("case insensitivity", () => {
it("matches regardless of case and preserves the original casing", () => {
expect(highlightText("Hello World", "hello", CLASS)).to.equal(`${wrap("Hello")}${SP}World`);
});

it("matches an upper-case query against lower-case text", () => {
expect(highlightText("hello world", "WORLD", CLASS)).to.equal(`hello${SP}${wrap("world")}`);
});
});

describe("multiple matches", () => {
it("highlights all non-overlapping occurrences", () => {
expect(highlightText("aXaXa", "a", CLASS)).to.equal(`${wrap("a")}X${wrap("a")}X${wrap("a")}`);
});

it("highlights consecutive occurrences", () => {
expect(highlightText("aaa", "a", CLASS)).to.equal(`${wrap("a")}${wrap("a")}${wrap("a")}`);
});

it("does not re-match inside an already matched region (overlapping)", () => {
// "aa" is searched again from the end of the previous match, so only two matches in "aaaa"
expect(highlightText("aaaa", "aa", CLASS)).to.equal(`${wrap("aa")}${wrap("aa")}`);
});

it("highlights multiple occurrences of a word", () => {
expect(highlightText("cat dog cat", "cat", CLASS)).to.equal(`${wrap("cat")}${SP}dog${SP}${wrap("cat")}`);
});
});

describe("special characters", () => {
it("treats the highlight as a literal string, not a regex", () => {
expect(highlightText("a.b.c", ".", CLASS)).to.equal(`a${wrap(".")}b${wrap(".")}c`);
});

it("highlights regex special characters literally and encodes the output", () => {
expect(highlightText("price is $5 (approx)", "$5", CLASS)).to.equal(`price${SP}is${SP}${wrap("&#x24;5")}${SP}&#x28;approx&#x29;`);
});

it("does not treat the highlight as a wildcard", () => {
expect(highlightText("abc", "a*c", CLASS)).to.equal("abc");
});
});

describe("unicode handling", () => {
it("keeps the original casing aligned when lowercasing expands a character (Turkish dotted İ)", () => {
// "İ".toLowerCase() expands to two code units; the index map keeps slices aligned.
expect(highlightText("İstanbul", "i", CLASS)).to.equal(`${wrap("İ")}stanbul`);
});

it("highlights an emoji made of surrogate pairs", () => {
expect(highlightText("a😀b", "😀", CLASS)).to.equal(`a${wrap("😀")}b`);
});
});

describe("XML encoding (XSS safety)", () => {
it("encodes HTML in the non-matching parts", () => {
expect(highlightText("<b>hi</b>", "zzz", CLASS)).to.equal("&lt;b&gt;hi&lt;&#x2f;b&gt;");
});

it("encodes HTML around and inside a match", () => {
expect(highlightText("<script>alert(1)</script>", "alert", CLASS))
.to.equal(`&lt;script&gt;${wrap("alert")}&#x28;1&#x29;&lt;&#x2f;script&gt;`);
});

it("encodes quotes and ampersands", () => {
expect(highlightText(`a"b`, "z", CLASS)).to.equal("a&quot;b");
expect(highlightText("a&b", "z", CLASS)).to.equal("a&amp;b");
});
});
});
69 changes: 69 additions & 0 deletions packages/base/src/util/highlightText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// @ts-expect-error
import encodeXML from "../sap/base/security/encodeXML.js";

function createLowerCaseIndexMap(text: string) {
const originalStart: number[] = [];
const originalEnd: number[] = [];
let originalIndex = 0;

Array.from(text).forEach(character => {
const lowerCharacter = character.toLowerCase();
const end = originalIndex + character.length;

Array.from({ length: lowerCharacter.length }).forEach(() => {
originalStart.push(originalIndex);
originalEnd.push(end);
});

originalIndex = end;
});

return {
originalStart,
originalEnd,
};
}

/**
* Highlights the occurrences of the highlightedText in the text by wrapping them in a span with the provided class.
* @param text The original text in which to highlight occurrences.
* @param highlightedText The text to highlight within the original text.
* @param sClass The CSS class to apply to the highlighted spans.
* @returns The text with the highlighted occurrences wrapped in a span with the specified class.
* @since 2.28.0
*/
const highlightText = (text: string | null | undefined, highlightedText: string | null | undefined, sClass: string) => {
text = text || "";
highlightedText = highlightedText || "";

const lowerText = text.toLowerCase();
const lowerHighlight = highlightedText.toLowerCase();
const highlightLength = lowerHighlight.length;
let index = lowerText.indexOf(lowerHighlight);

if (!highlightLength || index === -1) {
return encodeXML(text) as string;
}

// Lowercasing can expand a single character (for example Turkish dotted İ).
// The index map keeps the lowercase search positions aligned with the original text slices.
const indexMap = createLowerCaseIndexMap(text);

let result = "";
let lastEnd = 0;

while (index > -1) {
const start = indexMap.originalStart[index];
const end = indexMap.originalEnd[index + highlightLength - 1];

result += `${encodeXML(text.slice(lastEnd, start))}<span class="${sClass}">${encodeXML(text.slice(start, end))}</span>`;
lastEnd = end;
index = lowerText.indexOf(lowerHighlight, index + highlightLength);
}

result += encodeXML(text.slice(lastEnd));

return result;
};

export default highlightText;
Loading
Loading