Skip to content

Commit 39c019b

Browse files
feat(ui5-input/ui5-multi-input): provide built-in filtering (#12836)
1 parent 0bd7751 commit 39c019b

9 files changed

Lines changed: 361 additions & 16 deletions

File tree

packages/main/cypress/specs/Input.cy.tsx

Lines changed: 169 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -556,21 +556,21 @@ describe("Input general interaction", () => {
556556

557557
cy.document().then(doc => {
558558
const input = doc.querySelector<Input>("#threshold-input")!;
559-
559+
560560
input.addEventListener("input", () => {
561561
const value = input.value;
562-
562+
563563
while (input.lastChild) {
564564
input.removeChild(input.lastChild);
565565
}
566-
566+
567567
if (value.length >= THRESHOLD) {
568568
input.showSuggestions = true;
569-
570-
const filtered = countries.filter(country =>
569+
570+
const filtered = countries.filter(country =>
571571
country.toUpperCase().indexOf(value.toUpperCase()) === 0
572572
);
573-
573+
574574
filtered.forEach(country => {
575575
const item = document.createElement("ui5-suggestion-item");
576576
item.setAttribute("text", country);
@@ -3094,3 +3094,166 @@ describe("Validation inside a form", () => {
30943094
.should("have.been.calledOnce");
30953095
});
30963096
});
3097+
3098+
describe("Input built-in filtering", () => {
3099+
it("StartsWith filtering", () => {
3100+
cy.mount(
3101+
<Input showSuggestions filter="StartsWith" noTypeahead>
3102+
<SuggestionItem text="Iron"></SuggestionItem>
3103+
<SuggestionItem text="Gold"></SuggestionItem>
3104+
</Input>
3105+
);
3106+
cy.get("[ui5-input]")
3107+
.as("input")
3108+
.shadow()
3109+
.find("input")
3110+
.realClick()
3111+
.realType("I");
3112+
3113+
cy.get("@input")
3114+
.shadow()
3115+
.find<ResponsivePopover>("[ui5-responsive-popover]")
3116+
.as("popover")
3117+
.ui5ResponsivePopoverOpened();
3118+
3119+
cy.get("@input")
3120+
.find("[ui5-suggestion-item]")
3121+
.eq(0)
3122+
.should("be.visible");
3123+
3124+
cy.get("@input")
3125+
.find("[ui5-suggestion-item]")
3126+
.eq(1)
3127+
.should("have.attr", "hidden");
3128+
3129+
cy.get("@input")
3130+
.shadow()
3131+
.find("input")
3132+
.realClick()
3133+
.realPress("Backspace");
3134+
3135+
cy.get<ResponsivePopover>("@popover")
3136+
.ui5ResponsivePopoverClosed();
3137+
3138+
cy.get("@input")
3139+
.shadow()
3140+
.find("input")
3141+
.realType("G");
3142+
3143+
cy.get<ResponsivePopover>("@popover")
3144+
.ui5ResponsivePopoverOpened();
3145+
3146+
cy.get("@input")
3147+
.find("[ui5-suggestion-item]")
3148+
.eq(0)
3149+
.should("have.attr", "hidden");
3150+
3151+
cy.get("@input")
3152+
.find("[ui5-suggestion-item]")
3153+
.eq(1)
3154+
.should("be.visible");
3155+
});
3156+
it("Contains filtering", () => {
3157+
cy.mount(
3158+
<Input showSuggestions filter="Contains" noTypeahead>
3159+
<SuggestionItem text="Iron"></SuggestionItem>
3160+
<SuggestionItem text="Gold"></SuggestionItem>
3161+
</Input>
3162+
);
3163+
cy.get("[ui5-input]")
3164+
.as("input")
3165+
.shadow()
3166+
.find("input")
3167+
.realClick()
3168+
.realType("o");
3169+
3170+
cy.get("@input")
3171+
.shadow()
3172+
.find<ResponsivePopover>("[ui5-responsive-popover]")
3173+
.as("popover")
3174+
.ui5ResponsivePopoverOpened();
3175+
3176+
cy.get("@input")
3177+
.find("[ui5-suggestion-item]")
3178+
.eq(0)
3179+
.should("be.visible");
3180+
3181+
cy.get("@input")
3182+
.find("[ui5-suggestion-item]")
3183+
.eq(1)
3184+
.should("be.visible");
3185+
3186+
cy.get("@input")
3187+
.shadow()
3188+
.find("input")
3189+
.realClick()
3190+
.realPress("Backspace");
3191+
3192+
cy.get<ResponsivePopover>("@popover")
3193+
.ui5ResponsivePopoverClosed();
3194+
3195+
cy.get("@input")
3196+
.shadow()
3197+
.find("input")
3198+
.realType("l");
3199+
3200+
cy.get<ResponsivePopover>("@popover")
3201+
.ui5ResponsivePopoverOpened();
3202+
3203+
cy.get("@input")
3204+
.find("[ui5-suggestion-item]")
3205+
.eq(0)
3206+
.should("have.attr", "hidden");
3207+
3208+
cy.get("@input")
3209+
.find("[ui5-suggestion-item]")
3210+
.eq(1)
3211+
.should("be.visible");
3212+
});
3213+
it("hides suggestion group when it has no matching items", () => {
3214+
cy.mount(
3215+
<Input showSuggestions filter="Contains" noTypeahead>
3216+
<SuggestionItemGroup headerText="Metals">
3217+
<SuggestionItem text="Iron"></SuggestionItem>
3218+
<SuggestionItem text="Gold"></SuggestionItem>
3219+
</SuggestionItemGroup>
3220+
<SuggestionItemGroup headerText="Fruits">
3221+
<SuggestionItem text="Apple"></SuggestionItem>
3222+
<SuggestionItem text="Orange"></SuggestionItem>
3223+
</SuggestionItemGroup>
3224+
</Input>
3225+
);
3226+
cy.get("[ui5-input]")
3227+
.as("input")
3228+
.shadow()
3229+
.find("input")
3230+
.realClick()
3231+
.realType("o");
3232+
3233+
cy.get("@input")
3234+
.shadow()
3235+
.find<ResponsivePopover>("[ui5-responsive-popover]")
3236+
.as("popover")
3237+
.ui5ResponsivePopoverOpened();
3238+
3239+
cy.get("@input")
3240+
.find("[ui5-suggestion-item-group]")
3241+
.eq(0)
3242+
.should("be.visible");
3243+
3244+
cy.get("@input")
3245+
.find("[ui5-suggestion-item-group]")
3246+
.eq(1)
3247+
.should("be.visible");
3248+
3249+
cy.get("@input")
3250+
.shadow()
3251+
.find("input")
3252+
.realType("l");
3253+
3254+
cy.get("@input")
3255+
.find("[ui5-suggestion-item-group]")
3256+
.eq(1)
3257+
.should("have.attr", "hidden");
3258+
});
3259+
});

packages/main/src/Input.ts

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ import type { IIcon } from "./Icon.js";
6565

6666
// Templates
6767
import InputTemplate from "./InputTemplate.js";
68-
import { StartsWith } from "./Filters.js";
68+
import * as Filters from "./Filters.js";
6969

7070
import {
7171
VALUE_STATE_SUCCESS,
@@ -100,6 +100,7 @@ import type { ListItemClickEventDetail, ListSelectionChangeEventDetail } from ".
100100
import type ResponsivePopover from "./ResponsivePopover.js";
101101
import type InputKeyHint from "./types/InputKeyHint.js";
102102
import type InputComposition from "./features/InputComposition.js";
103+
import InputSuggestionsFilter from "./types/InputSuggestionsFilter.js";
103104

104105
/**
105106
* Interface for components that represent a suggestion item, usable in `ui5-input`
@@ -492,6 +493,15 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
492493
@property({ type: Boolean })
493494
open = false;
494495

496+
/**
497+
* Defines the filter type of the component.
498+
* @default "None"
499+
* @public
500+
* @since 2.19.0
501+
*/
502+
@property()
503+
filter: `${InputSuggestionsFilter}` = InputSuggestionsFilter.None;
504+
495505
/**
496506
* Defines whether the clear icon is visible.
497507
* @default false
@@ -787,6 +797,10 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
787797
return;
788798
}
789799

800+
if (this.filter !== InputSuggestionsFilter.None) {
801+
this._filterItems(this.typedInValue);
802+
}
803+
790804
const autoCompletedChars = innerInput.selectionEnd! - innerInput.selectionStart!;
791805

792806
// Typehead causes issues on Android devices, so we disable it for now
@@ -820,7 +834,13 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
820834
}
821835

822836
if (this.typedInValue.length && this.value.length) {
823-
innerInput.setSelectionRange(this.typedInValue.length, this.value.length);
837+
// "Contains" filtering requires custom selection range handling.
838+
// Example: "e" → "Belgium" (item does not start with typed value, so select all).
839+
if (this.filter === InputSuggestionsFilter.Contains) {
840+
this._adjustContainsSelectionRange();
841+
} else {
842+
innerInput.setSelectionRange(this.typedInValue.length, this.value.length);
843+
}
824844
}
825845

826846
this.fireDecoratorEvent("type-ahead");
@@ -835,6 +855,22 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
835855
}
836856
}
837857

858+
_adjustContainsSelectionRange() {
859+
const innerInput = this.getInputDOMRefSync()!;
860+
const visibleItems = this.Suggestions?._getItems().filter(item => !item.hidden) as IInputSuggestionItemSelectable[];
861+
const currentItem = visibleItems?.find(item => { return item.selected || item.focused; });
862+
const groupItems = this._flattenItems.filter(item => this._isGroupItem(item));
863+
864+
if (currentItem && !groupItems.includes(currentItem)) {
865+
const doesItemStartWithTypedValue = currentItem?.text?.toLowerCase().startsWith(this.typedInValue.toLowerCase());
866+
if (doesItemStartWithTypedValue) {
867+
innerInput.setSelectionRange(this.typedInValue.length, this.value.length);
868+
} else {
869+
innerInput.setSelectionRange(0, this.value.length);
870+
}
871+
}
872+
}
873+
838874
_onkeydown(e: KeyboardEvent) {
839875
this._isKeyNavigation = true;
840876
this._shouldAutocomplete = !this.noTypeahead && !(isBackSpace(e) || isDelete(e) || isEscape(e));
@@ -911,8 +947,9 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
911947

912948
get currentItemIndex() {
913949
const allItems = this.Suggestions?._getItems() as IInputSuggestionItemSelectable[];
914-
const currentItem = allItems.find(item => { return item.selected || item.focused; });
915-
const indexOfCurrentItem = currentItem ? allItems.indexOf(currentItem) : -1;
950+
const visibleItems = allItems.filter(item => !item.hidden);
951+
const currentItem = visibleItems.find(item => { return item.selected || item.focused; });
952+
const indexOfCurrentItem = currentItem ? visibleItems.indexOf(currentItem) : -1;
916953
return indexOfCurrentItem;
917954
}
918955

@@ -1310,11 +1347,15 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
13101347
this.Suggestions.updateSelectedItemPosition(-1);
13111348
}
13121349

1350+
if (this.filter && (e.target as HTMLInputElement).value === "") {
1351+
this.open = false;
1352+
}
1353+
13131354
this.isTyping = true;
13141355
}
13151356

13161357
_startsWithMatchingItems(str: string): Array<IInputSuggestionItemSelectable> {
1317-
return StartsWith(str, this._selectableItems, "text");
1358+
return Filters.StartsWith(str, this._selectableItems, "text");
13181359
}
13191360

13201361
_getFirstMatchingItem(current: string): IInputSuggestionItemSelectable | undefined {
@@ -1337,6 +1378,52 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
13371378
item.selected = true;
13381379
}
13391380

1381+
_filterItems(value: string) {
1382+
let matchingItems: Array<IInputSuggestionItem> = [];
1383+
const groupItems = this._flattenItems.filter(item => this._isGroupItem(item));
1384+
1385+
this._resetItemVisibility();
1386+
1387+
if (groupItems.length) {
1388+
matchingItems = this._filterGroups(this.filter, groupItems);
1389+
} else {
1390+
matchingItems = (Filters[this.filter])(value, this._selectableItems, "text");
1391+
}
1392+
this._selectableItems.forEach(item => {
1393+
item.hidden = !matchingItems.includes(item);
1394+
});
1395+
1396+
if (matchingItems.length === 0) {
1397+
this.open = false;
1398+
}
1399+
}
1400+
1401+
_filterGroups(filterType: `${InputSuggestionsFilter}`, groupItems: IInputSuggestionItem[]) {
1402+
const filteredGroupItems: IInputSuggestionItem[] = [];
1403+
groupItems.forEach(groupItem => {
1404+
const currentGroupItems = (Filters[filterType])(this.typedInValue, groupItem.items ?? [], "text");
1405+
filteredGroupItems.push(...currentGroupItems);
1406+
if (currentGroupItems.length === 0) {
1407+
groupItem.hidden = true;
1408+
} else {
1409+
groupItem.hidden = false;
1410+
}
1411+
});
1412+
return filteredGroupItems;
1413+
}
1414+
1415+
_resetItemVisibility() {
1416+
this._flattenItems.forEach(item => {
1417+
if (this._isGroupItem(item)) {
1418+
item.items?.forEach(i => {
1419+
i.hidden = false;
1420+
});
1421+
return;
1422+
}
1423+
item.hidden = false;
1424+
});
1425+
}
1426+
13401427
_handleTypeAhead(item: IInputSuggestionItemSelectable) {
13411428
const value = item.text ? item.text : "";
13421429

0 commit comments

Comments
 (0)