Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
175 changes: 169 additions & 6 deletions packages/main/cypress/specs/Input.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -556,21 +556,21 @@ describe("Input general interaction", () => {

cy.document().then(doc => {
const input = doc.querySelector<Input>("#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);
Expand Down Expand Up @@ -3094,3 +3094,166 @@ describe("Validation inside a form", () => {
.should("have.been.calledOnce");
});
});

describe("Input built-in filtering", () => {
it("StartsWith filtering", () => {
cy.mount(
<Input showSuggestions filter="StartsWith" noTypeahead>
<SuggestionItem text="Iron"></SuggestionItem>
<SuggestionItem text="Gold"></SuggestionItem>
</Input>
);
cy.get("[ui5-input]")
.as("input")
.shadow()
.find("input")
.realClick()
.realType("I");

cy.get("@input")
.shadow()
.find<ResponsivePopover>("[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<ResponsivePopover>("@popover")
.ui5ResponsivePopoverClosed();

cy.get("@input")
.shadow()
.find("input")
.realType("G");

cy.get<ResponsivePopover>("@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(
<Input showSuggestions filter="Contains" noTypeahead>
<SuggestionItem text="Iron"></SuggestionItem>
<SuggestionItem text="Gold"></SuggestionItem>
</Input>
);
cy.get("[ui5-input]")
.as("input")
.shadow()
.find("input")
.realClick()
.realType("o");

cy.get("@input")
.shadow()
.find<ResponsivePopover>("[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<ResponsivePopover>("@popover")
.ui5ResponsivePopoverClosed();

cy.get("@input")
.shadow()
.find("input")
.realType("l");

cy.get<ResponsivePopover>("@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(
<Input showSuggestions filter="Contains" noTypeahead>
<SuggestionItemGroup headerText="Metals">
<SuggestionItem text="Iron"></SuggestionItem>
<SuggestionItem text="Gold"></SuggestionItem>
</SuggestionItemGroup>
<SuggestionItemGroup headerText="Fruits">
<SuggestionItem text="Apple"></SuggestionItem>
<SuggestionItem text="Orange"></SuggestionItem>
</SuggestionItemGroup>
</Input>
);
cy.get("[ui5-input]")
.as("input")
.shadow()
.find("input")
.realClick()
.realType("o");

cy.get("@input")
.shadow()
.find<ResponsivePopover>("[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");
});
});
96 changes: 91 additions & 5 deletions packages/main/src/Input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand All @@ -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));
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<IInputSuggestionItemSelectable> {
return StartsWith(str, this._selectableItems, "text");
return Filters.StartsWith(str, this._selectableItems, "text");
}

_getFirstMatchingItem(current: string): IInputSuggestionItemSelectable | undefined {
Expand All @@ -1337,6 +1377,52 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
item.selected = true;
}

_filterItems(value: string) {
let matchingItems: Array<IInputSuggestionItem> = [];
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 : "";

Expand Down
Loading
Loading