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