diff --git a/packages/main/cypress/specs/Input.cy.tsx b/packages/main/cypress/specs/Input.cy.tsx
index 6f5a161aef82..a067f4322b72 100644
--- a/packages/main/cypress/specs/Input.cy.tsx
+++ b/packages/main/cypress/specs/Input.cy.tsx
@@ -556,21 +556,21 @@ describe("Input general interaction", () => {
cy.document().then(doc => {
const input = doc.querySelector("#threshold-input")!;
-
+
input.addEventListener("input", () => {
const value = input.value;
-
+
while (input.lastChild) {
input.removeChild(input.lastChild);
}
-
+
if (value.length >= THRESHOLD) {
input.showSuggestions = true;
-
- const filtered = countries.filter(country =>
+
+ const filtered = countries.filter(country =>
country.toUpperCase().indexOf(value.toUpperCase()) === 0
);
-
+
filtered.forEach(country => {
const item = document.createElement("ui5-suggestion-item");
item.setAttribute("text", country);
@@ -3094,3 +3094,166 @@ describe("Validation inside a form", () => {
.should("have.been.calledOnce");
});
});
+
+describe("Input built-in filtering", () => {
+ it("StartsWith filtering", () => {
+ cy.mount(
+
+
+
+
+ );
+ cy.get("[ui5-input]")
+ .as("input")
+ .shadow()
+ .find("input")
+ .realClick()
+ .realType("I");
+
+ cy.get("@input")
+ .shadow()
+ .find("[ui5-responsive-popover]")
+ .as("popover")
+ .ui5ResponsivePopoverOpened();
+
+ cy.get("@input")
+ .find("[ui5-suggestion-item]")
+ .eq(0)
+ .should("be.visible");
+
+ cy.get("@input")
+ .find("[ui5-suggestion-item]")
+ .eq(1)
+ .should("have.attr", "hidden");
+
+ cy.get("@input")
+ .shadow()
+ .find("input")
+ .realClick()
+ .realPress("Backspace");
+
+ cy.get("@popover")
+ .ui5ResponsivePopoverClosed();
+
+ cy.get("@input")
+ .shadow()
+ .find("input")
+ .realType("G");
+
+ cy.get("@popover")
+ .ui5ResponsivePopoverOpened();
+
+ cy.get("@input")
+ .find("[ui5-suggestion-item]")
+ .eq(0)
+ .should("have.attr", "hidden");
+
+ cy.get("@input")
+ .find("[ui5-suggestion-item]")
+ .eq(1)
+ .should("be.visible");
+ });
+ it("Contains filtering", () => {
+ cy.mount(
+
+
+
+
+ );
+ cy.get("[ui5-input]")
+ .as("input")
+ .shadow()
+ .find("input")
+ .realClick()
+ .realType("o");
+
+ cy.get("@input")
+ .shadow()
+ .find("[ui5-responsive-popover]")
+ .as("popover")
+ .ui5ResponsivePopoverOpened();
+
+ cy.get("@input")
+ .find("[ui5-suggestion-item]")
+ .eq(0)
+ .should("be.visible");
+
+ cy.get("@input")
+ .find("[ui5-suggestion-item]")
+ .eq(1)
+ .should("be.visible");
+
+ cy.get("@input")
+ .shadow()
+ .find("input")
+ .realClick()
+ .realPress("Backspace");
+
+ cy.get("@popover")
+ .ui5ResponsivePopoverClosed();
+
+ cy.get("@input")
+ .shadow()
+ .find("input")
+ .realType("l");
+
+ cy.get("@popover")
+ .ui5ResponsivePopoverOpened();
+
+ cy.get("@input")
+ .find("[ui5-suggestion-item]")
+ .eq(0)
+ .should("have.attr", "hidden");
+
+ cy.get("@input")
+ .find("[ui5-suggestion-item]")
+ .eq(1)
+ .should("be.visible");
+ });
+ it("hides suggestion group when it has no matching items", () => {
+ cy.mount(
+
+
+
+
+
+
+
+
+
+
+ );
+ cy.get("[ui5-input]")
+ .as("input")
+ .shadow()
+ .find("input")
+ .realClick()
+ .realType("o");
+
+ cy.get("@input")
+ .shadow()
+ .find("[ui5-responsive-popover]")
+ .as("popover")
+ .ui5ResponsivePopoverOpened();
+
+ cy.get("@input")
+ .find("[ui5-suggestion-item-group]")
+ .eq(0)
+ .should("be.visible");
+
+ cy.get("@input")
+ .find("[ui5-suggestion-item-group]")
+ .eq(1)
+ .should("be.visible");
+
+ cy.get("@input")
+ .shadow()
+ .find("input")
+ .realType("l");
+
+ cy.get("@input")
+ .find("[ui5-suggestion-item-group]")
+ .eq(1)
+ .should("have.attr", "hidden");
+ });
+});
diff --git a/packages/main/src/Input.ts b/packages/main/src/Input.ts
index bfc26327cf29..701e487d30ca 100644
--- a/packages/main/src/Input.ts
+++ b/packages/main/src/Input.ts
@@ -65,7 +65,7 @@ import type { IIcon } from "./Icon.js";
// Templates
import InputTemplate from "./InputTemplate.js";
-import { StartsWith } from "./Filters.js";
+import * as Filters from "./Filters.js";
import {
VALUE_STATE_SUCCESS,
@@ -100,6 +100,7 @@ import type { ListItemClickEventDetail, ListSelectionChangeEventDetail } from ".
import type ResponsivePopover from "./ResponsivePopover.js";
import type InputKeyHint from "./types/InputKeyHint.js";
import type InputComposition from "./features/InputComposition.js";
+import InputSuggestionsFilter from "./types/InputSuggestionsFilter.js";
/**
* Interface for components that represent a suggestion item, usable in `ui5-input`
@@ -492,6 +493,14 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
@property({ type: Boolean })
open = false;
+ /**
+ * Defines the filter type of the component.
+ * @default "None"
+ * @public
+ */
+ @property()
+ filter: `${InputSuggestionsFilter}` = InputSuggestionsFilter.None;
+
/**
* Defines whether the clear icon is visible.
* @default false
@@ -787,6 +796,10 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
return;
}
+ if (this.filter !== InputSuggestionsFilter.None) {
+ this._filterItems(this.typedInValue);
+ }
+
const autoCompletedChars = innerInput.selectionEnd! - innerInput.selectionStart!;
// Typehead causes issues on Android devices, so we disable it for now
@@ -820,7 +833,13 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
}
if (this.typedInValue.length && this.value.length) {
- innerInput.setSelectionRange(this.typedInValue.length, this.value.length);
+ // "Contains" filtering requires custom selection range handling.
+ // Example: "e" → "Belgium" (item does not start with typed value, so select all).
+ if (this.filter === InputSuggestionsFilter.Contains) {
+ this._adjustContainsSelectionRange();
+ } else {
+ innerInput.setSelectionRange(this.typedInValue.length, this.value.length);
+ }
}
this.fireDecoratorEvent("type-ahead");
@@ -835,6 +854,22 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
}
}
+ _adjustContainsSelectionRange() {
+ const innerInput = this.getInputDOMRefSync()!;
+ const visibleItems = this.Suggestions?._getItems().filter(item => !item.hidden) as IInputSuggestionItemSelectable[];
+ const currentItem = visibleItems?.find(item => { return item.selected || item.focused; });
+ const groupItems = this._flattenItems.filter(item => this._isGroupItem(item));
+
+ if (currentItem && !groupItems.includes(currentItem)) {
+ const doesItemStartWithTypedValue = currentItem?.text?.toLowerCase().startsWith(this.typedInValue.toLowerCase());
+ if (doesItemStartWithTypedValue) {
+ innerInput.setSelectionRange(this.typedInValue.length, this.value.length);
+ } else {
+ innerInput.setSelectionRange(0, this.value.length);
+ }
+ }
+ }
+
_onkeydown(e: KeyboardEvent) {
this._isKeyNavigation = true;
this._shouldAutocomplete = !this.noTypeahead && !(isBackSpace(e) || isDelete(e) || isEscape(e));
@@ -911,8 +946,9 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
get currentItemIndex() {
const allItems = this.Suggestions?._getItems() as IInputSuggestionItemSelectable[];
- const currentItem = allItems.find(item => { return item.selected || item.focused; });
- const indexOfCurrentItem = currentItem ? allItems.indexOf(currentItem) : -1;
+ const visibleItems = allItems.filter(item => !item.hidden);
+ const currentItem = visibleItems.find(item => { return item.selected || item.focused; });
+ const indexOfCurrentItem = currentItem ? visibleItems.indexOf(currentItem) : -1;
return indexOfCurrentItem;
}
@@ -1310,11 +1346,15 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
this.Suggestions.updateSelectedItemPosition(-1);
}
+ if (this.filter && (e.target as HTMLInputElement).value === "") {
+ this.open = false;
+ }
+
this.isTyping = true;
}
_startsWithMatchingItems(str: string): Array {
- return StartsWith(str, this._selectableItems, "text");
+ return Filters.StartsWith(str, this._selectableItems, "text");
}
_getFirstMatchingItem(current: string): IInputSuggestionItemSelectable | undefined {
@@ -1337,6 +1377,52 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
item.selected = true;
}
+ _filterItems(value: string) {
+ let matchingItems: Array = [];
+ const groupItems = this._flattenItems.filter(item => this._isGroupItem(item));
+
+ this._resetItemVisibility();
+
+ if (groupItems.length) {
+ matchingItems = this._filterGroups(this.filter, groupItems);
+ } else {
+ matchingItems = (Filters[this.filter])(value, this._selectableItems, "text");
+ }
+ this._selectableItems.forEach(item => {
+ item.hidden = !matchingItems.includes(item);
+ });
+
+ if (matchingItems.length === 0) {
+ this.open = false;
+ }
+ }
+
+ _filterGroups(filterType: `${InputSuggestionsFilter}`, groupItems: IInputSuggestionItem[]) {
+ const filteredGroupItems: IInputSuggestionItem[] = [];
+ groupItems.forEach(groupItem => {
+ const currentGroupItems = (Filters[filterType])(this.typedInValue, groupItem.items ?? [], "text");
+ filteredGroupItems.push(...currentGroupItems);
+ if (currentGroupItems.length === 0) {
+ groupItem.hidden = true;
+ } else {
+ groupItem.hidden = false;
+ }
+ });
+ return filteredGroupItems;
+ }
+
+ _resetItemVisibility() {
+ this._flattenItems.forEach(item => {
+ if (this._isGroupItem(item)) {
+ item.items?.forEach(i => {
+ i.hidden = false;
+ });
+ return;
+ }
+ item.hidden = false;
+ });
+ }
+
_handleTypeAhead(item: IInputSuggestionItemSelectable) {
const value = item.text ? item.text : "";
diff --git a/packages/main/src/features/InputSuggestions.ts b/packages/main/src/features/InputSuggestions.ts
index e6511090ee50..1969423f6ae8 100644
--- a/packages/main/src/features/InputSuggestions.ts
+++ b/packages/main/src/features/InputSuggestions.ts
@@ -130,7 +130,7 @@ class Suggestions {
onPageDown(e: KeyboardEvent) {
e.preventDefault();
- const items = this._getItems();
+ const items = this.visibleItems;
if (!items) {
return true;
@@ -302,7 +302,7 @@ class Suggestions {
}
_selectPreviousItem() {
- const items = this._getItems();
+ const items = this.visibleItems;
const previousSelectedIdx = this.selectedItemIndex;
if (previousSelectedIdx === -1 || previousSelectedIdx === null) {
@@ -325,12 +325,16 @@ class Suggestions {
this._moveItemSelection(previousSelectedIdx, --this.selectedItemIndex);
}
+ get visibleItems() {
+ return this._getItems().filter(item => !item.hidden);
+ }
+
_moveItemSelection(previousIdx: number, nextIdx: number) {
- const items = this._getItems();
+ const items = this.visibleItems;
const currentItem = items[nextIdx];
const previousItem = items[previousIdx];
const nonGroupItems = this._getNonGroupItems();
- const isGroupItem = currentItem.hasAttribute("ui5-suggestion-item-group");
+ const isGroupItem = currentItem?.hasAttribute("ui5-suggestion-item-group");
if (!currentItem) {
return;
@@ -338,7 +342,7 @@ class Suggestions {
this.component.focused = false;
- const selectedItem = this._getItems()[this.selectedItemIndex];
+ const selectedItem = this.visibleItems[this.selectedItemIndex];
this.accInfo = {
isGroup: isGroupItem,
diff --git a/packages/main/src/types/InputSuggestionsFilter.ts b/packages/main/src/types/InputSuggestionsFilter.ts
new file mode 100644
index 000000000000..8c104da855df
--- /dev/null
+++ b/packages/main/src/types/InputSuggestionsFilter.ts
@@ -0,0 +1,31 @@
+/**
+ * Different filtering types of the Input.
+ * @public
+ */
+ enum InputSuggestionsFilter {
+ /**
+ * Defines filtering by first symbol of each word of item's text.
+ * @public
+ */
+ StartsWithPerTerm = "StartsWithPerTerm",
+
+ /**
+ * Defines filtering by starting symbol of item's text.
+ * @public
+ */
+ StartsWith = "StartsWith",
+
+ /**
+ * Defines contains filtering.
+ * @public
+ */
+ Contains = "Contains",
+
+ /**
+ * Removes any filtering applied while typing
+ * @public
+ */
+ None = "None",
+}
+
+export default InputSuggestionsFilter;
diff --git a/packages/main/test/pages/Input.html b/packages/main/test/pages/Input.html
index 14f166bca1c1..12b97241c8c0 100644
--- a/packages/main/test/pages/Input.html
+++ b/packages/main/test/pages/Input.html
@@ -551,6 +551,22 @@ Input - open suggestions picker
Input with just accessible description
+
+
+ Input with built-in filtering (Contains)
+
+
+
+
+
+
+
+
+
+
+
+
+
Input Composition