From 01d01de57ff8fb9a683f9f7ba5668ad8f83d2a67 Mon Sep 17 00:00:00 2001 From: JC Franco Date: Fri, 21 Jun 2024 13:33:45 -0700 Subject: [PATCH] feat(combobox): add `filterText` prop (#9654) *Related Issue:** #7212 ## Summary Adds `filterText` to allow dynamic access to filter text. --- .../calcite-components/src/components.d.ts | 8 + .../src/components/combobox/combobox.e2e.ts | 414 +++++++++++------- .../src/components/combobox/combobox.tsx | 66 +-- 3 files changed, 305 insertions(+), 183 deletions(-) diff --git a/packages/calcite-components/src/components.d.ts b/packages/calcite-components/src/components.d.ts index bed0a00246..8067de60cb 100644 --- a/packages/calcite-components/src/components.d.ts +++ b/packages/calcite-components/src/components.d.ts @@ -1236,6 +1236,10 @@ export namespace Components { * When `true`, interaction is prevented and the component is displayed with lower opacity. */ "disabled": boolean; + /** + * Text for the component's filter input field. + */ + "filterText": string; /** * Specifies the component's filtered items. * @readonly @@ -9022,6 +9026,10 @@ declare namespace LocalJSX { * When `true`, interaction is prevented and the component is displayed with lower opacity. */ "disabled"?: boolean; + /** + * Text for the component's filter input field. + */ + "filterText"?: string; /** * Specifies the component's filtered items. * @readonly diff --git a/packages/calcite-components/src/components/combobox/combobox.e2e.ts b/packages/calcite-components/src/components/combobox/combobox.e2e.ts index 99d905f714..6b195d744a 100644 --- a/packages/calcite-components/src/components/combobox/combobox.e2e.ts +++ b/packages/calcite-components/src/components/combobox/combobox.e2e.ts @@ -179,177 +179,304 @@ describe("calcite-combobox", () => { openClose(simpleComboboxHTML); }); - it("should toggle the combobox when typing within the input", async () => { - const page = await newE2EPage(); + describe("filtering", () => { + it("should toggle the combobox when typing within the input", async () => { + const page = await newE2EPage(); - await page.setContent(html` - - - - - - - `); + await page.setContent(html` + + + + + + + `); - const combobox = await page.find("calcite-combobox"); - await combobox.callMethod("setFocus"); - await page.waitForChanges(); - expect(await combobox.getProperty("open")).toBe(false); + const combobox = await page.find("calcite-combobox"); + await combobox.callMethod("setFocus"); + await page.waitForChanges(); + expect(await combobox.getProperty("open")).toBe(false); - const text = "Arizona"; + const text = "Arizona"; - await combobox.type(text); - await page.waitForChanges(); + await combobox.type(text); + await page.waitForChanges(); - expect(await combobox.getProperty("open")).toBe(true); + expect(await combobox.getProperty("open")).toBe(true); - for (let i = 0; i < text.length; i++) { - await combobox.press("Backspace"); - } + for (let i = 0; i < text.length; i++) { + await combobox.press("Backspace"); + } - await page.waitForChanges(); - expect(await combobox.getProperty("open")).toBe(false); - }); + await page.waitForChanges(); + expect(await combobox.getProperty("open")).toBe(false); + }); - it("should not toggle the combobox when typing within the input does not match any results", async () => { - const page = await newE2EPage(); + it("should not toggle the combobox when typing within the input does not match any results", async () => { + const page = await newE2EPage(); - await page.setContent(html` - - - - - - - `); + await page.setContent(html` + + + + + + + `); - const combobox = await page.find("calcite-combobox"); - await combobox.callMethod("setFocus"); - await page.waitForChanges(); - expect(await combobox.getProperty("open")).toBe(false); + const combobox = await page.find("calcite-combobox"); + await combobox.callMethod("setFocus"); + await page.waitForChanges(); + expect(await combobox.getProperty("open")).toBe(false); - const text = "nomatchingtexthere"; + const text = "nomatchingtexthere"; - await combobox.type(text); - await page.waitForChanges(); + await combobox.type(text); + await page.waitForChanges(); - expect(await combobox.getProperty("open")).toBe(false); - }); + expect(await combobox.getProperty("open")).toBe(false); + }); - it("filtering does not match property with value of undefined", async () => { - const page = await newE2EPage(); + it("filtering does not match property with value of undefined", async () => { + const page = await newE2EPage(); - await page.setContent(html` - - - - - - - `); + await page.setContent(html` + + + + + + + `); - const combobox = await page.find("calcite-combobox"); - const input = await page.find("calcite-combobox >>> input"); - const items = await page.findAll("calcite-combobox-item"); - await combobox.click(); - await page.waitForChanges(); + const combobox = await page.find("calcite-combobox"); + const input = await page.find("calcite-combobox >>> input"); + const items = await page.findAll("calcite-combobox-item"); + await combobox.click(); + await page.waitForChanges(); - await input.type("undefined"); - await page.waitForChanges(); + await input.type("undefined"); + await page.waitForChanges(); - expect(await items[0].isVisible()).toBe(false); - expect(await items[1].isVisible()).toBe(false); - expect(await items[2].isVisible()).toBe(false); - expect(await items[3].isVisible()).toBe(false); - }); + expect(await items[0].isVisible()).toBe(false); + expect(await items[1].isVisible()).toBe(false); + expect(await items[2].isVisible()).toBe(false); + expect(await items[3].isVisible()).toBe(false); + }); - it("should filter the items in listbox when typing into the input", async () => { - const page = await newE2EPage(); + it("should filter the items in listbox when typing into the input", async () => { + const page = await newE2EPage(); + await page.setContent(html` + + + + + + + `); - await page.setContent(html` - - - - - - - `); + const combobox = await page.find("calcite-combobox"); + const items = await page.findAll("calcite-combobox-item"); - const combobox = await page.find("calcite-combobox"); - const input = await page.find("calcite-combobox >>> input"); - const items = await page.findAll("calcite-combobox-item"); + const openEvent = await combobox.spyOnEvent("calciteComboboxOpen"); + const filterEventSpy = await combobox.spyOnEvent("calciteComboboxFilterChange"); - const openEvent = await combobox.spyOnEvent("calciteComboboxOpen"); - const filterEventSpy = await combobox.spyOnEvent("calciteComboboxFilterChange"); + await combobox.click(); + await page.waitForChanges(); + expect(openEvent).toHaveReceivedEventTimes(1); - await combobox.click(); - await page.waitForChanges(); - expect(openEvent).toHaveReceivedEventTimes(1); + await combobox.press("s"); + await page.waitForChanges(); + expect(filterEventSpy).toHaveReceivedEventTimes(1); - await input.press("s"); - await page.waitForChanges(); - expect(filterEventSpy).toHaveReceivedEventTimes(1); + expect(await items[0].isVisible()).toBe(true); + expect(await items[1].isVisible()).toBe(true); + expect(await items[2].isVisible()).toBe(true); + expect(await items[3].isVisible()).toBe(true); - expect(await items[0].isVisible()).toBe(true); - expect(await items[1].isVisible()).toBe(true); - expect(await items[2].isVisible()).toBe(true); - expect(await items[3].isVisible()).toBe(true); + expect(await combobox.getProperty("filterText")).toBe("s"); + expect((await combobox.getProperty("filteredItems")).length).toBe(4); - expect((await combobox.getProperty("filteredItems")).length).toBe(4); + await combobox.press("i"); + await page.waitForChanges(); + expect(filterEventSpy).toHaveReceivedEventTimes(2); - await input.press("i"); - await page.waitForChanges(); - expect(filterEventSpy).toHaveReceivedEventTimes(2); + expect(await items[0].isVisible()).toBe(true); + expect(await items[1].isVisible()).toBe(true); + expect(await items[2].isVisible()).toBe(false); + expect(await items[3].isVisible()).toBe(true); - expect(await items[0].isVisible()).toBe(true); - expect(await items[1].isVisible()).toBe(true); - expect(await items[2].isVisible()).toBe(false); - expect(await items[3].isVisible()).toBe(true); + expect(await combobox.getProperty("filterText")).toBe("si"); + expect((await combobox.getProperty("filteredItems")).length).toBe(3); - expect((await combobox.getProperty("filteredItems")).length).toBe(3); + await combobox.press("n"); + await page.waitForChanges(); + expect(filterEventSpy).toHaveReceivedEventTimes(3); - await input.press("n"); - await page.waitForChanges(); - expect(filterEventSpy).toHaveReceivedEventTimes(3); + expect(await items[0].isVisible()).toBe(true); + expect(await items[1].isVisible()).toBe(true); + expect(await items[2].isVisible()).toBe(false); + expect(await items[3].isVisible()).toBe(false); - expect(await items[0].isVisible()).toBe(true); - expect(await items[1].isVisible()).toBe(true); - expect(await items[2].isVisible()).toBe(false); - expect(await items[3].isVisible()).toBe(false); + expect(await combobox.getProperty("filterText")).toBe("sin"); + expect((await combobox.getProperty("filteredItems")).length).toBe(2); + }); - expect((await combobox.getProperty("filteredItems")).length).toBe(2); - }); + it("does not clear filter if pointer down/up on an item has a delay in between events", async () => { + const page = await newE2EPage(); + await page.setContent(html` + + + + + + + `); - it("does not clear filter if pointer down/up on an item has a delay in between events", async () => { - const page = await newE2EPage(); - await page.setContent(html` - - - - - + const combobox = await page.find("calcite-combobox"); + await combobox.click(); + await page.waitForChanges(); + await combobox.type("Algeria"); + await page.waitForChanges(); + + const [lastItemX, lastItemY] = await getElementXY(page, "#item-4"); + + await page.mouse.move(lastItemX, lastItemY); + await page.mouse.down(); + await page.waitForChanges(); + await page.mouse.up(); + await page.waitForChanges(); + + expect(await combobox.getProperty("value")).toBe("Libya/Algeria"); + }); + + it("respects the filterDisabled item property", async () => { + const page = await newE2EPage(); + await page.setContent(` + + + + `); - const combobox = await page.find("calcite-combobox"); - await combobox.click(); - await page.waitForChanges(); - await combobox.type("Algeria"); - await page.waitForChanges(); + const combobox = await page.find("calcite-combobox"); + await combobox.click(); + await page.waitForChanges(); + await combobox.type("two"); + await page.waitForChanges(); + const one = await (await page.find("#one")).isVisible(); + const two = await (await page.find("#two")).isVisible(); + const three = await (await page.find("#three")).isVisible(); - const [lastItemX, lastItemY] = await getElementXY(page, "#item-4"); + expect(one).toBeFalsy(); + expect(two).toBeTruthy(); + expect(three).toBeTruthy(); + }); - await page.mouse.move(lastItemX, lastItemY); - await page.mouse.down(); - await page.waitForChanges(); - await page.mouse.up(); - await page.waitForChanges(); + const nestedComboboxChildren = html` + + + + + + + + + + + + + + + + + + + + + + + `; + + it("should filter on initial load", async () => { + const page = await newE2EPage(); + await page.setContent(html` ${nestedComboboxChildren} `); + await page.waitForChanges(); + + const visibleItemsAndGroups = await page.findAll( + "calcite-combobox-item:not([hidden]), calcite-combobox-item-group:not([hidden])", + ); + const visibleItemAndGroupIds = await Promise.all(visibleItemsAndGroups.map((item) => item.getProperty("id"))); + + expect(visibleItemAndGroupIds).toEqual([ + "group-1", + "item-1-2", + "subgroup-1-1", + "subgroup-1-1-2", + "item-1-1-2-1", + "item-1-1-2-2", + "group-2", + "item-2-1", + "item-2-1-2", + ]); + }); + + it("should display all groups/items when filter is cleared", async () => { + const page = await newE2EPage(); + await page.setContent(html` ${nestedComboboxChildren} `); + await page.waitForChanges(); - expect(await combobox.getProperty("value")).toBe("Libya/Algeria"); + const combobox = await page.find("calcite-combobox"); + combobox.setProperty("filterText", "1.2"); + await page.waitForChanges(); + + const filteredItemsAndGroups = await page.findAll( + "calcite-combobox-item:not([hidden]), calcite-combobox-item-group:not([hidden])", + ); + const filteredItemAndGroupIds = await Promise.all(filteredItemsAndGroups.map((item) => item.getProperty("id"))); + + expect(filteredItemAndGroupIds).toEqual([ + "group-1", + "item-1-2", + "subgroup-1-1", + "subgroup-1-1-2", + "item-1-1-2-1", + "item-1-1-2-2", + "group-2", + "item-2-1", + "item-2-1-2", + ]); + + combobox.setProperty("filterText", ""); + await page.waitForChanges(); + + const allVisibleItemAndGroups = await page.findAll( + "calcite-combobox-item:not([hidden]), calcite-combobox-item-group:not([hidden])", + ); + const allVisibleItemAndGroupIds = await Promise.all( + allVisibleItemAndGroups.map((item) => item.getProperty("id")), + ); + expect(allVisibleItemAndGroupIds).toEqual([ + "group-1", + "item-1-1", + "item-1-2", + "subgroup-1-1", + "item-1-1-1", + "subgroup-1-1-1", + "subgroup-1-1-2", + "item-1-1-2-1", + "item-1-1-2-2", + "group-2", + "item-2-1", + "item-2-1-1", + "item-2-1-2", + ]); + }); }); it("should control max items displayed", async () => { @@ -1731,29 +1858,6 @@ describe("calcite-combobox", () => { }); }); - it("respects the filterDisabled item property", async () => { - const page = await newE2EPage(); - await page.setContent(` - - - - - - `); - - await page.waitForChanges(); - const input = await page.find("calcite-combobox >>> .wrapper"); - await input.click(); - await page.keyboard.type("two"); - await page.waitForChanges(); - const one = await (await page.find("#one")).isVisible(); - const two = await (await page.find("#two")).isVisible(); - const three = await (await page.find("#three")).isVisible(); - expect(one).toBeFalsy(); - expect(two).toBeTruthy(); - expect(three).toBeTruthy(); - }); - it("works correctly inside a shadowRoot", async () => { const page = await newE2EPage(); await page.setContent(` diff --git a/packages/calcite-components/src/components/combobox/combobox.tsx b/packages/calcite-components/src/components/combobox/combobox.tsx index 540fdc9a1c..54804ba431 100644 --- a/packages/calcite-components/src/components/combobox/combobox.tsx +++ b/packages/calcite-components/src/components/combobox/combobox.tsx @@ -116,6 +116,17 @@ export class Combobox */ @Prop({ reflect: true }) clearDisabled = false; + /** + * Text for the component's filter input field. + */ + @Prop({ reflect: true, mutable: true }) filterText = ""; + + @Watch("filterText") + filterTextChange(value: string): void { + this.updateActiveItemIndex(-1); + this.filterItems(value, true); + } + /** * When `selectionMode` is `"ancestors"` or `"multiple"`, specifies the display of multiple `calcite-combobox-item` selections, where: * @@ -350,14 +361,14 @@ export class Combobox await componentOnReady(this.el); - if (!this.allowCustomValues && this.text) { + if (!this.allowCustomValues && this.filterText) { this.clearInputValue(); this.filterItems(""); this.updateActiveItemIndex(-1); } - if (this.allowCustomValues && this.text.trim().length) { - this.addCustomChip(this.text); + if (this.allowCustomValues && this.filterText.trim().length) { + this.addCustomChip(this.filterText); } this.open = false; @@ -481,6 +492,7 @@ export class Combobox setUpLoadableComponent(this); this.updateItems(); await setUpMessages(this); + this.filterItems(this.filterText, false, false); } componentDidLoad(): void { @@ -551,14 +563,6 @@ export class Combobox @State() selectedVisibleChipsCount = 0; - @State() text = ""; - - /** when search text is cleared, reset active to */ - @Watch("text") - textHandler(): void { - this.updateActiveItemIndex(-1); - } - @State() effectiveLocale: string; @Watch("effectiveLocale") @@ -622,7 +626,7 @@ export class Combobox private clearInputValue(): void { this.textInput.value = ""; - this.text = ""; + this.filterText = ""; } setFilteredPlacements = (): void => { @@ -663,13 +667,13 @@ export class Combobox case "Tab": this.activeChipIndex = -1; this.activeItemIndex = -1; - if (this.allowCustomValues && this.text) { - this.addCustomChip(this.text, true); + if (this.allowCustomValues && this.filterText) { + this.addCustomChip(this.filterText, true); event.preventDefault(); } else if (this.open) { this.open = false; event.preventDefault(); - } else if (!this.allowCustomValues && this.text) { + } else if (!this.allowCustomValues && this.filterText) { this.clearInputValue(); this.filterItems(""); this.updateActiveItemIndex(-1); @@ -760,8 +764,8 @@ export class Combobox } else if (this.activeChipIndex > -1) { this.removeActiveChip(); event.preventDefault(); - } else if (this.allowCustomValues && this.text) { - this.addCustomChip(this.text, true); + } else if (this.allowCustomValues && this.filterText) { + this.addCustomChip(this.filterText, true); event.preventDefault(); } else if (!event.defaultPrevented) { if (submitForm(this)) { @@ -780,7 +784,7 @@ export class Combobox if (this.activeChipIndex > -1) { event.preventDefault(); this.removeActiveChip(); - } else if (!this.text && this.isMulti()) { + } else if (!this.filterText && this.isMulti()) { event.preventDefault(); this.removeLastChip(); } @@ -1060,11 +1064,7 @@ export class Combobox inputHandler = (event: Event): void => { const value = (event.target as HTMLInputElement).value; - this.text = value; - this.filterItems(value, true); - if (value) { - this.activeChipIndex = -1; - } + this.filterText = value; }; getItemsAndGroups(): ComboboxChildElement[] { @@ -1078,11 +1078,18 @@ export class Combobox isGroup(item) ? label === item.label : value === item.value && label === item.textLabel, ); - return debounce((text: string, setOpenToEmptyState = false): void => { + return debounce((text: string, setOpenToEmptyState = false, emit = true): void => { const filteredData = filter(this.data, text); const itemsAndGroups = this.getItemsAndGroups(); + const matchAll = text === ""; + itemsAndGroups.forEach((item) => { + if (matchAll) { + item.hidden = false; + return; + } + const hidden = !find(item, filteredData); item.hidden = hidden; const [parent, grandparent] = item.ancestors; @@ -1099,10 +1106,12 @@ export class Combobox this.filteredItems = this.getFilteredItems(); if (setOpenToEmptyState) { - this.open = this.text.trim().length > 0 && this.filteredItems.length > 0; + this.open = this.filterText.trim().length > 0 && this.filteredItems.length > 0; } - this.calciteComboboxFilterChange.emit(); + if (emit) { + this.calciteComboboxFilterChange.emit(); + } }, 100); })(); @@ -1165,7 +1174,7 @@ export class Combobox } getFilteredItems(): HTMLCalciteComboboxItemElement[] { - return this.items.filter((item) => !item.hidden); + return this.filterText === "" ? this.items : this.items.filter((item) => !item.hidden); } private getSelectedItems = (): HTMLCalciteComboboxItemElement[] => { @@ -1238,7 +1247,7 @@ export class Combobox if (this.textInput) { this.textInput.value = ""; } - this.text = ""; + this.filterText = ""; } getItems(): HTMLCalciteComboboxItemElement[] { @@ -1618,6 +1627,7 @@ export class Combobox role="combobox" tabindex={this.activeChipIndex === -1 ? 0 : -1} type="text" + value={this.filterText} /> );