diff --git a/packages/base/cypress/specs/util/highlightText.cy.tsx b/packages/base/cypress/specs/util/highlightText.cy.tsx new file mode 100644 index 0000000000000..890f358439c7e --- /dev/null +++ b/packages/base/cypress/specs/util/highlightText.cy.tsx @@ -0,0 +1,124 @@ +import highlightText from "../../../src/util/highlightText.js"; + +const CLASS = "hl"; +const wrap = (text: string) => `${text}`; + +// highlightText encodes its output via encodeXML, so spaces and other special +// characters are returned as XML numeric entities (e.g. " " -> " "). +const SP = " "; + +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("$5")}${SP}(approx)`); + }); + + 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("hi", "zzz", CLASS)).to.equal("<b>hi</b>"); + }); + + it("encodes HTML around and inside a match", () => { + expect(highlightText("", "alert", CLASS)) + .to.equal(`<script>${wrap("alert")}(1)</script>`); + }); + + it("encodes quotes and ampersands", () => { + expect(highlightText(`a"b`, "z", CLASS)).to.equal("a"b"); + expect(highlightText("a&b", "z", CLASS)).to.equal("a&b"); + }); + }); +}); diff --git a/packages/base/src/util/highlightText.ts b/packages/base/src/util/highlightText.ts new file mode 100644 index 0000000000000..53f6982cb185e --- /dev/null +++ b/packages/base/src/util/highlightText.ts @@ -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))}${encodeXML(text.slice(start, end))}`; + lastEnd = end; + index = lowerText.indexOf(lowerHighlight, index + highlightLength); + } + + result += encodeXML(text.slice(lastEnd)); + + return result; +}; + +export default highlightText; diff --git a/packages/fiori/cypress/specs/SideNavigation.cy.tsx b/packages/fiori/cypress/specs/SideNavigation.cy.tsx index b6221d1b109c1..dd4a157392aae 100644 --- a/packages/fiori/cypress/specs/SideNavigation.cy.tsx +++ b/packages/fiori/cypress/specs/SideNavigation.cy.tsx @@ -2,6 +2,7 @@ import SideNavigation from "../../src/SideNavigation.js"; import SideNavigationItem from "../../src/SideNavigationItem.js"; import SideNavigationGroup from "../../src/SideNavigationGroup.js"; import SideNavigationSubItem from "../../src/SideNavigationSubItem.js"; +import SideNavigationSearchField from "../../src/SideNavigationSearchField.js"; import group from "@ui5/webcomponents-icons/dist/group.js"; import home from "@ui5/webcomponents-icons/dist/home.js"; import employeeApprovals from "@ui5/webcomponents-icons/dist/employee-approvals.js"; @@ -2051,3 +2052,387 @@ describe("Focusable items", () => { .should("have.text", "New"); }); }); + +describe("Side Navigation no match", () => { + it("shows a '0 matches found' item in the tab chain when there are no items and a highlightedText is set", () => { + cy.mount( + + ); + + cy.get("#sideNav") + .shadow() + .find(".ui5-sn-item-no-match") + .should("exist") + .should("have.attr", "text", "0 matches found") + .should("have.attr", "unselectable"); + + cy.get("#sideNav") + .shadow() + .find(".ui5-sn-item-no-match") + .shadow() + .find(".ui5-sn-item") + .should("have.attr", "tabindex", "0"); + }); + + it("does not show the no match item when there are items", () => { + cy.mount( + + + + ); + + cy.get("#sideNav") + .shadow() + .find(".ui5-sn-item-no-match") + .should("not.exist"); + }); + + it("does not show the no match item when there are fixed items", () => { + cy.mount( + + + + ); + + cy.get("#sideNav") + .shadow() + .find(".ui5-sn-item-no-match") + .should("not.exist"); + }); + + it("does not show the no match item when there is no highlightedText", () => { + cy.mount( + + ); + + cy.get("#sideNav") + .shadow() + .find(".ui5-sn-item-no-match") + .should("not.exist"); + }); + + it("ignores key presses on the no match item (no selection-change fired)", () => { + cy.mount( + + ); + + cy.get("#sideNav").then($el => { + $el[0].addEventListener("ui5-selection-change", cy.stub().as("selectionChange")); + }); + + // Focus the no match item and press the activation keys. + cy.get("#sideNav") + .shadow() + .find(".ui5-sn-item-no-match") + .shadow() + .find(".ui5-sn-item") + .as("noMatchItem"); + + cy.get("@noMatchItem").focus(); + cy.realPress("Enter"); + cy.realPress("Space"); + + cy.get("@selectionChange").should("not.be.called"); + + // The no match item stays present and is never marked selected. + cy.get("#sideNav") + .shadow() + .find(".ui5-sn-item-no-match") + .should("exist") + .should("not.have.attr", "selected"); + }); +}); + +describe("Side Navigation filterSection slot", () => { + it("renders the filter section slot when expanded", () => { + cy.mount( + + + + + ); + + cy.get("#sideNav") + .shadow() + .find(".ui5-sn-filter-section") + .should("exist"); + + cy.get("#sideNav") + .shadow() + .find(".ui5-sn-filter-section slot[name='filterSection']") + .should("exist"); + + // the slotted search field is assigned to the filterSection slot + cy.get("#search") + .should("have.attr", "slot", "filterSection"); + }); + + it("does not render the filter section when collapsed", () => { + cy.mount( + + + + + ); + + cy.get("#sideNav") + .shadow() + .find(".ui5-sn-filter-section") + .should("not.exist"); + }); + + it("does not render the filter section when the slot is empty", () => { + cy.mount( + + + + ); + + cy.get("#sideNav") + .shadow() + .find(".ui5-sn-filter-section") + .should("not.exist"); + }); + + it("hides the filter section when the component is collapsed at runtime", () => { + cy.mount( + + + + + ); + + cy.get("#sideNav") + .shadow() + .find(".ui5-sn-filter-section") + .should("exist"); + + cy.get("#sideNav").then($el => { + $el[0].collapsed = true; + }); + + cy.get("#sideNav") + .shadow() + .find(".ui5-sn-filter-section") + .should("not.exist"); + }); +}); + +describe("Side Navigation highlightedText", () => { + it("wraps the matching part of an item's text in a highlight span", () => { + cy.mount( + + + + ); + + cy.get("#item1") + .shadow() + .find(".ui5-sn-item-text .ui5-sn-item-highlight") + .should("have.length", 1) + .and("have.text", "Doc"); + + // the whole visible text is preserved + cy.get("#item1") + .shadow() + .find(".ui5-sn-item-text") + .should("have.text", "Documents"); + }); + + it("highlights all non-overlapping matches in an item's text", () => { + cy.mount( + + + + ); + + cy.get("#item1") + .shadow() + .find(".ui5-sn-item-text .ui5-sn-item-highlight") + .should("have.length", 3) + .each($el => { + cy.wrap($el).should("have.text", "a"); + }); + }); + + it("matches case-insensitively and preserves the original casing", () => { + cy.mount( + + + + ); + + cy.get("#item1") + .shadow() + .find(".ui5-sn-item-text .ui5-sn-item-highlight") + .should("have.length", 1) + .and("have.text", "Home"); + }); + + it("does not add any highlight span when the term does not match", () => { + cy.mount( + + + + ); + + cy.get("#item1") + .shadow() + .find(".ui5-sn-item-text .ui5-sn-item-highlight") + .should("not.exist"); + + cy.get("#item1") + .shadow() + .find(".ui5-sn-item-text") + .should("have.text", "Documents"); + }); + + it("does not add any highlight span when there is no highlightedText", () => { + cy.mount( + + + + ); + + cy.get("#item1") + .shadow() + .find(".ui5-sn-item-text .ui5-sn-item-highlight") + .should("not.exist"); + }); + + it("propagates highlightedText to sub items", () => { + cy.mount( + + + + + + ); + + cy.get("#sub1") + .shadow() + .find(".ui5-sn-item-text .ui5-sn-item-highlight") + .should("have.length", 1) + .and("have.text", "Report"); + }); + + it("propagates highlightedText to groups", () => { + cy.mount( + + + + + + ); + + cy.get("#group1") + .shadow() + .find(".ui5-sn-item-text .ui5-sn-item-highlight") + .should("have.length", 1) + .and("have.text", "Group"); + }); + + it("re-renders the highlight when highlightedText changes", () => { + cy.mount( + + + + ); + + cy.get("#item1") + .shadow() + .find(".ui5-sn-item-text .ui5-sn-item-highlight") + .should("have.text", "Doc"); + + cy.get("#sideNav").invoke("attr", "highlighted-text", "ments"); + + cy.get("#item1") + .shadow() + .find(".ui5-sn-item-text .ui5-sn-item-highlight") + .should("have.length", 1) + .and("have.text", "ments"); + }); + + it("removes the highlight when highlightedText is cleared", () => { + cy.mount( + + + + ); + + cy.get("#item1") + .shadow() + .find(".ui5-sn-item-text .ui5-sn-item-highlight") + .should("exist"); + + cy.get("#sideNav").invoke("removeAttr", "highlighted-text"); + + cy.get("#item1") + .shadow() + .find(".ui5-sn-item-text .ui5-sn-item-highlight") + .should("not.exist"); + + cy.get("#item1") + .shadow() + .find(".ui5-sn-item-text") + .should("have.text", "Documents"); + }); + + it("renders potentially unsafe text as inert markup (XSS safety)", () => { + cy.mount( + + click me`} /> + + ); + + cy.get("#item1") + .shadow() + .find(".ui5-sn-item-text .ui5-sn-item-highlight") + .should("have.length", 1) + .and("have.text", "click"); + + // the injected markup must not have produced a real element + cy.get("#item1") + .shadow() + .find(".ui5-sn-item-text img") + .should("not.exist"); + + cy.window().its("__xss").should("not.exist"); + }); +}); + +describe("Side Navigation search match announcement", () => { + it("announceSearchMatchCount method", () => { + cy.mount( + + + + + + ); + + cy.get("#sideNav").then($el => { + $el[0].announceSearchMatchCount(2); + }); + + cy.get("body") + .find(".ui5-invisiblemessage-polite") + .should("have.text", "2 matches found"); + + cy.get("#sideNav").then($el => { + $el[0].announceSearchMatchCount(1); + }); + + cy.get("body") + .find(".ui5-invisiblemessage-polite") + .should("have.text", "1 match found"); + + cy.get("#sideNav").then($el => { + $el[0].announceSearchMatchCount(0); + }); + + cy.get("body") + .find(".ui5-invisiblemessage-polite") + .should("have.text", "0 matches found"); + }); +}); diff --git a/packages/fiori/cypress/specs/SideNavigationSearchField.cy.tsx b/packages/fiori/cypress/specs/SideNavigationSearchField.cy.tsx new file mode 100644 index 0000000000000..68dab09baafa9 --- /dev/null +++ b/packages/fiori/cypress/specs/SideNavigationSearchField.cy.tsx @@ -0,0 +1,314 @@ +import SideNavigationSearchField from "../../src/SideNavigationSearchField.js"; +import { + SEARCH_FIELD_SEARCH_ICON, + SIDE_NAVIGATION_SEARCH_FIELD_LABEL, + SIDE_NAVIGATION_SEARCH_FIELD_PLACEHOLDER, + SIDE_NAVIGATION_SEARCH_DESCRIPTION, +} from "../../src/generated/i18n/i18n-defaults.js"; + +describe("SideNavigationSearchField general interaction", () => { + describe("Attribute propagation", () => { + it("propagates the value to the inner input", () => { + cy.mount(); + + cy.get("[ui5-side-navigation-search-field]") + .shadow() + .find("[ui5-input]") + .shadow() + .find("input") + .should("have.value", "test"); + }); + + it("propagates a custom placeholder to the inner input", () => { + cy.mount(); + + cy.get("[ui5-side-navigation-search-field]") + .shadow() + .find("[ui5-input]") + .shadow() + .find("input") + .should("have.attr", "placeholder", "Custom"); + }); + + it("uses the default placeholder when none is set", () => { + cy.mount(); + + cy.get("[ui5-side-navigation-search-field]") + .shadow() + .find("[ui5-input]") + .shadow() + .find("input") + .should("have.attr", "placeholder", SIDE_NAVIGATION_SEARCH_FIELD_PLACEHOLDER.defaultText); + }); + + it("propagates accessibleName to the inner input aria-label", () => { + cy.mount(); + + cy.get("[ui5-side-navigation-search-field]") + .shadow() + .find("[ui5-input]") + .shadow() + .find("input") + .should("have.attr", "aria-label", "My filter"); + }); + + it("uses the default aria-label when accessibleName is not set", () => { + cy.mount(); + + cy.get("[ui5-side-navigation-search-field]") + .shadow() + .find("[ui5-input]") + .shadow() + .find("input") + .should("have.attr", "aria-label", SIDE_NAVIGATION_SEARCH_FIELD_LABEL.defaultText); + }); + + it("uses the default aria-description when accessibleDescriptionRef is not set", () => { + cy.mount(); + + cy.get("[ui5-side-navigation-search-field]") + .shadow() + .find("[ui5-input]") + .shadow() + .find("#descr") + .should("have.text", SIDE_NAVIGATION_SEARCH_DESCRIPTION.defaultText); + }); + + it("propagates accessibleDescriptionRef over the default aria-description", () => { + cy.mount( + <> + My description + + + ); + + cy.get("[ui5-side-navigation-search-field]") + .shadow() + .find("[ui5-input]") + .shadow() + .find("#descr") + .should("have.text", "My description"); + }); + + it("exposes the default search icon accessible name", () => { + cy.mount(); + + cy.get("[ui5-side-navigation-search-field]") + .shadow() + .find("[ui5-input-icon][name='search']") + .should("have.attr", "accessible-name", SEARCH_FIELD_SEARCH_ICON.defaultText); + }); + }); + + describe("Clear icon", () => { + it("shows the clear icon when the value is not empty", () => { + cy.mount(); + + cy.get("[ui5-side-navigation-search-field]") + .shadow() + .find("[ui5-input]") + .shadow() + .find("input") + .realClick(); + + cy.get("[ui5-side-navigation-search-field]") + .shadow() + .find("[ui5-input]") + .shadow() + .find("[ui5-icon][name='decline']") + .should("exist"); + }); + + it("does not show the clear icon when the value is empty", () => { + cy.mount(); + + cy.get("[ui5-side-navigation-search-field]") + .shadow() + .find("[ui5-input]") + .shadow() + .find("input") + .realClick(); + + cy.get("[ui5-side-navigation-search-field]") + .shadow() + .find("[ui5-input]") + .shadow() + .find("[ui5-icon][name='decline']") + .should("not.exist"); + }); + + it("does not show the clear icon when hideClearIcon is set", () => { + cy.mount(); + + cy.get("[ui5-side-navigation-search-field]") + .shadow() + .find("[ui5-input]") + .shadow() + .find("input") + .realClick(); + + cy.get("[ui5-side-navigation-search-field]") + .shadow() + .find("[ui5-input]") + .shadow() + .find("[ui5-icon][name='decline']") + .should("not.exist"); + }); + }); + + describe("Events", () => { + it("fires input event on typing and updates the value", () => { + cy.mount(); + + cy.get("[ui5-side-navigation-search-field]") + .as("searchfield"); + + cy.get("@searchfield") + .then(searchfield => { + searchfield.get(0).addEventListener("ui5-input", cy.stub().as("input")); + }); + + cy.get("@searchfield") + .shadow() + .find("[ui5-input]") + .shadow() + .find("input") + .realClick(); + + cy.realType("abc"); + + cy.get("@input") + .should("have.been.calledThrice"); + + cy.get("@searchfield") + .should("have.prop", "value", "abc"); + }); + + it("fires search event on Enter when there is a value", () => { + cy.mount(); + + cy.get("[ui5-side-navigation-search-field]") + .as("searchfield"); + + cy.get("@searchfield") + .then(searchfield => { + searchfield.get(0).addEventListener("ui5-search", cy.stub().as("searched")); + }); + + cy.get("@searchfield") + .shadow() + .find("[ui5-input]") + .shadow() + .find("input") + .realClick(); + + cy.realPress("Enter"); + + cy.get("@searched") + .should("have.been.calledOnce"); + }); + + it("does not fire search event on Enter when the value is empty", () => { + cy.mount(); + + cy.get("[ui5-side-navigation-search-field]") + .as("searchfield"); + + cy.get("@searchfield") + .then(searchfield => { + searchfield.get(0).addEventListener("ui5-search", cy.stub().as("searched")); + }); + + cy.get("@searchfield") + .shadow() + .find("[ui5-input]") + .shadow() + .find("input") + .realClick(); + + cy.realPress("Enter"); + + cy.get("@searched") + .should("not.be.called"); + }); + + it("fires search event on search icon press", () => { + cy.mount(); + + cy.get("[ui5-side-navigation-search-field]") + .as("searchfield"); + + cy.get("@searchfield") + .then(searchfield => { + searchfield.get(0).addEventListener("ui5-search", cy.stub().as("searched")); + }); + + cy.get("@searchfield") + .shadow() + .find("[ui5-input-icon][name='search']") + .realClick(); + + cy.get("@searched") + .should("have.been.calledOnce"); + }); + + it("clears the value and fires input event on Escape", () => { + cy.mount(); + + cy.get("[ui5-side-navigation-search-field]") + .as("searchfield"); + + cy.get("@searchfield") + .then(searchfield => { + searchfield.get(0).addEventListener("ui5-input", cy.stub().as("input")); + }); + + cy.get("@searchfield") + .shadow() + .find("[ui5-input]") + .shadow() + .find("input") + .realClick(); + + cy.realPress("Escape"); + + cy.get("@input") + .should("have.been.called"); + + cy.get("@searchfield") + .should("have.prop", "value", ""); + }); + + it("clears the value and fires input event on clear icon press", () => { + cy.mount(); + + cy.get("[ui5-side-navigation-search-field]") + .as("searchfield"); + + cy.get("@searchfield") + .then(searchfield => { + searchfield.get(0).addEventListener("ui5-input", cy.stub().as("input")); + }); + + cy.get("@searchfield") + .shadow() + .find("[ui5-input]") + .shadow() + .find("input") + .realClick(); + + cy.get("@searchfield") + .shadow() + .find("[ui5-input]") + .shadow() + .find("[ui5-icon][name='decline']") + .realClick(); + + cy.get("@input") + .should("have.been.called"); + + cy.get("@searchfield") + .should("have.prop", "value", ""); + }); + }); +}); diff --git a/packages/fiori/src/SideNavigation.ts b/packages/fiori/src/SideNavigation.ts index 34148745d9773..6881252107a87 100644 --- a/packages/fiori/src/SideNavigation.ts +++ b/packages/fiori/src/SideNavigation.ts @@ -15,6 +15,8 @@ import ItemNavigation from "@ui5/webcomponents-base/dist/delegate/ItemNavigation import type { ITabbable } from "@ui5/webcomponents-base/dist/delegate/ItemNavigation.js"; import { isPhone } from "@ui5/webcomponents-base/dist/Device.js"; import createInstanceChecker from "@ui5/webcomponents-base/dist/util/createInstanceChecker.js"; +import announce from "@ui5/webcomponents-base/dist/util/InvisibleMessage.js"; +import InvisibleMessageMode from "@ui5/webcomponents-base/dist/types/InvisibleMessageMode.js"; import NavigationMode from "@ui5/webcomponents-base/dist/types/NavigationMode.js"; import type SideNavigationItemBase from "./SideNavigationItemBase.js"; @@ -35,6 +37,8 @@ import { SIDE_NAVIGATION_OVERFLOW_ACCESSIBLE_NAME, SIDE_NAVIGATION_FLEXIBLE_LIST_LABEL, SIDE_NAVIGATION_FIXED_LIST_LABEL, + SIDE_NAVIGATION_SEARCH_MATCH_COUNT_SINGULAR, + SIDE_NAVIGATION_SEARCH_MATCH_COUNT_PLURAL, } from "./generated/i18n/i18n-defaults.js"; // Styles @@ -222,6 +226,28 @@ class SideNavigation extends UI5Element { @slot() header!: Slot; + /** + * Defines the filter section of the `ui5-side-navigation`. + * + * **Note:** The filter section is displayed when the component is expanded - the property `collapsed` is false; + * + * @public + * @since 2.28.0 + */ + @slot() + filterSection!: Slot; + + /** + * Specifies a term to be highlighted in the navigation items' text. + * When set, matching portions of item and group texts are visually emphasized during rendering. + * + * @public + * @since 2.28.0 + * @default undefined + */ + @property() + highlightedText?: string; + /** * @private */ @@ -283,6 +309,7 @@ class SideNavigation extends UI5Element { item.inPopover = this.inPopover; item.sideNavigation = this; item.sideNavAnimating = this._bAnimating; + item._highlightedText = this.highlightedText; }); this.initGroupsSettings(this.items); @@ -475,6 +502,14 @@ class SideNavigation extends UI5Element { return !!this.fixedItems.length; } + get noMatchText() { + return SideNavigation.i18nBundle.getText(SIDE_NAVIGATION_SEARCH_MATCH_COUNT_PLURAL, 0); + } + + get showNoMatchText() { + return !!this.highlightedText && !this.items.length && !this.fixedItems.length; + } + get _rootRole() { return this.inPopover ? "none" : undefined; } @@ -932,6 +967,24 @@ class SideNavigation extends UI5Element { }); } } + + /** + * Announces the number of search matches found in the side navigation to assistive technologies. + * + * This method uses an invisible live region message so screen readers can inform users + * about the current number of search matches. + * + * @param count The number of matching navigation items. + * @since 2.28.0 + * @public + */ + announceSearchMatchCount(count: number): void { + const message = count === 1 + ? SideNavigation.i18nBundle.getText(SIDE_NAVIGATION_SEARCH_MATCH_COUNT_SINGULAR, count) + : SideNavigation.i18nBundle.getText(SIDE_NAVIGATION_SEARCH_MATCH_COUNT_PLURAL, count); + + announce(message, InvisibleMessageMode.Polite); + } } const instanceOfItemOrGroup = createMultiInstanceChecker(["isSideNavigationItem", "isSideNavigationGroup"]); diff --git a/packages/fiori/src/SideNavigationGroupTemplate.tsx b/packages/fiori/src/SideNavigationGroupTemplate.tsx index 30c2817968219..784f7f1c8cb01 100644 --- a/packages/fiori/src/SideNavigationGroupTemplate.tsx +++ b/packages/fiori/src/SideNavigationGroupTemplate.tsx @@ -35,7 +35,7 @@ function TreeItemTemplate(this: SideNavigationGroup) { title={this._tooltip} aria-owns={this._groupId} > -
{this.text}
+
{!!this.items.length && } -
{this.text}
+
{this.hasTag &&
diff --git a/packages/fiori/src/SideNavigationSearchField.ts b/packages/fiori/src/SideNavigationSearchField.ts new file mode 100644 index 0000000000000..532c2a5a9d96a --- /dev/null +++ b/packages/fiori/src/SideNavigationSearchField.ts @@ -0,0 +1,230 @@ +import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; +import property from "@ui5/webcomponents-base/dist/decorators/property.js"; +import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js"; +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; +import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js"; +import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; +import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; +import { isEnter, isEscape } from "@ui5/webcomponents-base/dist/Keys.js"; +import type { InputAccInfo } from "@ui5/webcomponents/dist/Input.js"; +import { + registerUI5Element, + deregisterUI5Element, + getEffectiveAriaLabelText, + getEffectiveAriaDescriptionText, + getAllAccessibleNameRefTexts, + getAllAccessibleDescriptionRefTexts, +} from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js"; +import SideNavigationSearchFieldTemplate from "./SideNavigationSearchFieldTemplate.js"; +import SideNavigationSearchFieldCss from "./generated/themes/SideNavigationSearchField.css.js"; +import { + SEARCH_FIELD_SEARCH_ICON, + SIDE_NAVIGATION_SEARCH_FIELD_LABEL, + SIDE_NAVIGATION_SEARCH_FIELD_PLACEHOLDER, + SIDE_NAVIGATION_SEARCH_DESCRIPTION, +} from "./generated/i18n/i18n-defaults.js"; + +/** + * @class + * + * ### Overview + * + * A `ui5-side-navigation-search-field` is a search field, + * used to filter the items of a `ui5-side-navigation`. + * + * ### ES6 Module Import + * + * `import "@ui5/webcomponents-fiori/dist/SideNavigationSearchField.js";` + * + * @constructor + * @extends UI5Element + * @since 2.28.0 + * @public + */ +@customElement({ + tag: "ui5-side-navigation-search-field", + languageAware: true, + renderer: jsxRenderer, + template: SideNavigationSearchFieldTemplate, + styles: [ + SideNavigationSearchFieldCss, + ], +}) + +/** + * Fired when typing in input or clear icon is pressed. + * + * @public + */ +@event("input", { + bubbles: true, +}) + +/** + * Fired when the user has triggered search with Enter key or Search Button press. + * @public + */ +@event("search", { + bubbles: true, + cancelable: true, +}) + +class SideNavigationSearchField extends UI5Element { + eventDetails!: { + search: object, + input: void, + } + + /** + * Defines whether the clear icon of the search will be hidden. + * + * **Note:** When not set, the clear icon is displayed while the component has a value. + * @default false + * @public + */ + @property({ type: Boolean }) + hideClearIcon = false; + + /** + * Defines the value of the component. + * + * **Note:** The property is updated upon typing. + * @default "" + * @public + */ + @property() + value = ""; + + /** + * Defines a short hint intended to aid the user with data entry when the + * component has no value. + * @default undefined + * @public + */ + @property() + placeholder?: string; + + /** + * Defines the accessible ARIA name of the component. + * @public + * @default undefined + */ + @property() + accessibleName?: string; + + /** + * Receives id (or many ids) of the elements that label the component. + * @public + * @default undefined + */ + @property() + accessibleNameRef?: string; + + /** + * Receives id (or many ids) of the elements that describe the component. + * @public + * @default undefined + */ + @property() + accessibleDescriptionRef?: string; + + /** + * Constantly updated value of the texts collected from the accessibleNameRef elements. + * @private + */ + @property({ noAttribute: true }) + _accessibleLabelsRefTexts?: string; + + /** + * Constantly updated value of the texts collected from the accessibleDescriptionRef elements. + * @private + */ + @property({ noAttribute: true }) + _associatedDescriptionRefTexts?: string; + + @i18n("@ui5/webcomponents-fiori") + static i18nBundle: I18nBundle; + + onEnterDOM() { + registerUI5Element(this, this._updateAssociatedTexts.bind(this)); + } + + onExitDOM() { + deregisterUI5Element(this); + } + + _updateAssociatedTexts() { + this._accessibleLabelsRefTexts = getAllAccessibleNameRefTexts(this); + this._associatedDescriptionRefTexts = getAllAccessibleDescriptionRefTexts(this); + } + + _onkeydown(e: KeyboardEvent) { + if (isEnter(e)) { + this._handleEnter(); + } else if (isEscape(e)) { + this._handleClear(); + } + } + + _handleEnter() { + if (this.value.length) { + this._handleSearchEvent(); + } + } + + _handleSearchIconPress() { + this._handleSearchEvent(); + + setTimeout(() => { + this.focus(); + }, 0); + } + + _handleSearchEvent() { + this.fireDecoratorEvent("search"); + } + + _handleInput(e: CustomEvent) { + this.value = (e.target as HTMLInputElement).value; + + this.fireDecoratorEvent("input"); + } + + _handleClear() { + this.value = ""; + this.fireDecoratorEvent("input"); + + this.focus(); + } + + get _translations() { + return { + searchIcon: SideNavigationSearchField.i18nBundle.getText(SEARCH_FIELD_SEARCH_ICON), + searchFieldAriaLabel: SideNavigationSearchField.i18nBundle.getText(SIDE_NAVIGATION_SEARCH_FIELD_LABEL), + searchFieldAriaDescription: SideNavigationSearchField.i18nBundle.getText(SIDE_NAVIGATION_SEARCH_DESCRIPTION), + }; + } + + get _inputAccInfo(): InputAccInfo { + return { + ariaLabel: this._ariaLabelText, + ariaDescription: this._ariaDescriptionText, + }; + } + + get _effectivePlaceholder(): string | undefined { + return this.placeholder || SideNavigationSearchField.i18nBundle.getText(SIDE_NAVIGATION_SEARCH_FIELD_PLACEHOLDER); + } + + get _ariaLabelText(): string | undefined { + return this._accessibleLabelsRefTexts || getEffectiveAriaLabelText(this) || this._translations.searchFieldAriaLabel; + } + + get _ariaDescriptionText(): string | undefined { + return this._associatedDescriptionRefTexts || getEffectiveAriaDescriptionText(this) || this._translations.searchFieldAriaDescription; + } +} + +SideNavigationSearchField.define(); + +export default SideNavigationSearchField; diff --git a/packages/fiori/src/SideNavigationSearchFieldTemplate.tsx b/packages/fiori/src/SideNavigationSearchFieldTemplate.tsx new file mode 100644 index 0000000000000..a10c8aa4a7bba --- /dev/null +++ b/packages/fiori/src/SideNavigationSearchFieldTemplate.tsx @@ -0,0 +1,32 @@ +import Input from "@ui5/webcomponents/dist/Input.js"; +import InputIcon from "@ui5/webcomponents/dist/InputIcon.js"; +import search from "@ui5/webcomponents-icons/dist/search.js"; +import type SideNavigationSearchField from "./SideNavigationSearchField.js"; + +export default function SideNavigationSearchFieldTemplate(this: SideNavigationSearchField) { + return ( +
+ + + +
+ ); +} diff --git a/packages/fiori/src/SideNavigationSubItemTemplate.tsx b/packages/fiori/src/SideNavigationSubItemTemplate.tsx index e1a7c0493d750..d8b60865ad68e 100644 --- a/packages/fiori/src/SideNavigationSubItemTemplate.tsx +++ b/packages/fiori/src/SideNavigationSubItemTemplate.tsx @@ -29,7 +29,7 @@ export default function SideNavigationSubItemTemplate(this: SideNavigationSubIte {this.icon && } -
{this.text}
+
{this.hasTag &&
diff --git a/packages/fiori/src/SideNavigationTemplate.tsx b/packages/fiori/src/SideNavigationTemplate.tsx index 5f7a41a43a0ee..f9786aa793a7f 100644 --- a/packages/fiori/src/SideNavigationTemplate.tsx +++ b/packages/fiori/src/SideNavigationTemplate.tsx @@ -14,6 +14,8 @@ export default function SideNavigationTemplate(this: SideNavigation) { > {header.call(this)} + {filterSection.call(this)} + {this.collapsed ?