diff --git a/packages/main/cypress/specs/ComboBox.cy.tsx b/packages/main/cypress/specs/ComboBox.cy.tsx index a956c757cbf60..33c49052764c5 100644 --- a/packages/main/cypress/specs/ComboBox.cy.tsx +++ b/packages/main/cypress/specs/ComboBox.cy.tsx @@ -3207,15 +3207,15 @@ describe("Loading State", () => { cy.get("[ui5-combobox]") .shadow() - .find("ui5-responsive-popover") + .find("[ui5-responsive-popover]") .as("popover"); cy.get("@popover") - .find("ui5-busy-indicator") + .find("[ui5-busy-indicator]") .should("exist"); cy.get("@popover") - .find("ui5-list") + .find("[ui5-list]") .should("not.exist"); }); @@ -3230,22 +3230,22 @@ describe("Loading State", () => { cy.get("[ui5-combobox]") .as("combo") .shadow() - .find("ui5-responsive-popover") + .find("[ui5-responsive-popover]") .as("popover"); cy.get("@popover") - .find("ui5-busy-indicator") + .find("[ui5-busy-indicator]") .should("exist"); cy.get("@combo") .invoke("prop", "loading", false); cy.get("@popover") - .find("ui5-busy-indicator") + .find("[ui5-busy-indicator]") .should("not.exist"); cy.get("@popover") - .find("ui5-list") + .find("[ui5-list]") .should("exist"); }); }); @@ -4351,3 +4351,330 @@ describe("Newline normalization in item text", () => { cy.get("@changeSpy").should("have.been.calledTwice"); }); }); + +describe("load-items event", () => { + it("fires on arrow click when ComboBox has no items", () => { + cy.mount( + + ); + + cy.get("[ui5-combobox]") + .shadow() + .find("[ui5-icon][name='slim-arrow-down']") + .realClick(); + + cy.get("@loadItems") + .should("have.been.calledOnce") + .and("have.been.calledWithMatch", Cypress.sinon.match(event => { + return event.detail.reason === "open"; + })); + }); + + it("does not fire on arrow click when ComboBox has items", () => { + cy.mount( + + + + + ); + + cy.get("[ui5-combobox]") + .shadow() + .find("[ui5-icon][name='slim-arrow-down']") + .realClick(); + + cy.get("@loadItems") + .should("not.have.been.called"); + }); + + it("fires on F4 when ComboBox has no items", () => { + cy.mount( + + ); + + cy.get("[ui5-combobox]") + .as("comboBox") + .realClick(); + + cy.get("@comboBox").realPress("F4"); + + cy.get("@loadItems") + .should("have.been.calledOnce") + .and("have.been.calledWithMatch", Cypress.sinon.match(event => { + return event.detail.reason === "open"; + })); + }); + + it("does not fire on F4 when ComboBox has items", () => { + cy.mount( + + + + + ); + + cy.get("[ui5-combobox]") + .as("comboBox") + .realClick(); + + cy.get("@comboBox").realPress("F4"); + + cy.get("@loadItems") + .should("not.have.been.called"); + }); + + it("fires on each new character typed in the input", () => { + cy.mount( + + ); + + cy.get("[ui5-combobox]") + .realClick(); + + cy.realType("Alg"); + + cy.get("@loadItems") + .should("have.been.calledThrice") + .and("have.been.calledWithMatch", Cypress.sinon.match(event => { + return event.detail.reason === "input"; + })); + }); + + it("fires with the current input value in the event detail on each character typed", () => { + cy.mount( + + ); + + cy.get("[ui5-combobox]") + .realClick(); + + cy.realType("Alg"); + + cy.get("@loadItems").should("have.been.calledThrice"); + + cy.get("@loadItems").its("firstCall.args.0.detail").should("deep.include", { reason: "input", value: "A" }); + cy.get("@loadItems").its("secondCall.args.0.detail").should("deep.include", { reason: "input", value: "Al" }); + cy.get("@loadItems").its("thirdCall.args.0.detail").should("deep.include", { reason: "input", value: "Alg" }); + }); +}); + +describe("Loading announcements", () => { + it("announces loading start when loading becomes true", () => { + cy.mount( + + + + ); + + cy.get("[ui5-combobox]") + .invoke("prop", "loading", true); + + cy.get(".ui5-invisiblemessage-polite") + .should("contain.text", "Loading data"); + }); + + it("announces loading end with item count when loading becomes false", () => { + cy.mount( + + + + + ); + + cy.get("[ui5-combobox]") + .invoke("prop", "loading", false); + + cy.get(".ui5-invisiblemessage-polite") + .should("contain.text", "Data loaded") + .and("contain.text", "2 results are available"); + }); + + it("announces the total item count after lazy loading grouped items via arrow click", () => { + // Loads 2 group items: the first with 2 child items, the second with 1 child item (3 items total). + const loadItems = (e: CustomEvent) => { + const cb = e.target as ComboBox; + cb.loading = true; + setTimeout(() => { + const group1 = document.createElement("ui5-cb-item-group") as ComboBoxItemGroup; + group1.headerText = "Group 1"; + ["Item 1", "Item 2"].forEach(text => { + const item = document.createElement("ui5-cb-item") as ComboBoxItem; + item.text = text; + group1.appendChild(item); + }); + + const group2 = document.createElement("ui5-cb-item-group") as ComboBoxItemGroup; + group2.headerText = "Group 2"; + const item3 = document.createElement("ui5-cb-item") as ComboBoxItem; + item3.text = "Item 3"; + group2.appendChild(item3); + + cb.appendChild(group1); + cb.appendChild(group2); + cb.loading = false; + }, 100); + }; + + cy.mount( + + ); + + cy.get("[ui5-combobox]") + .shadow() + .find("[ui5-icon][name='slim-arrow-down']") + .realClick(); + + // 2 groups get created, holding 3 child items in total. + cy.get("[ui5-combobox]").find("[ui5-cb-item-group]").should("have.length", 2); + cy.get("[ui5-combobox]").find("[ui5-cb-item]").should("have.length", 3); + + // The announcement reflects the 3 loaded (group headers excluded) items. + cy.get(".ui5-invisiblemessage-polite") + .should("contain.text", "Data loaded") + .and("contain.text", "3 results are available"); + }); + + it("applies the filter and announces the filtered item count after lazy loading triggered by typing", () => { + // Typing "a" loads 3 items - only "Albania" matches the default StartsWithPerTerm filter. + const loadItems = (e: CustomEvent) => { + const cb = e.target as ComboBox; + cb.loading = true; + cb.open = true; + setTimeout(() => { + ["Albania", "Bulgaria", "Canada"].forEach(text => { + const item = document.createElement("ui5-cb-item") as ComboBoxItem; + item.text = text; + cb.appendChild(item); + }); + cb.loading = false; + }, 100); + }; + + cy.mount( + + ); + + cy.get("[ui5-combobox]").realClick(); + cy.realType("a"); + + // All 3 items are created, but the filter leaves only "Albania" visible. + cy.get("[ui5-combobox]").find("[ui5-cb-item]").should("have.length", 3); + cy.get("[ui5-combobox]") + .find("[ui5-cb-item]") + .filter((_, el: Element & { _isVisible?: boolean }) => !!el._isVisible) + .should("have.length", 1) + .and("have.attr", "text", "Albania"); + + // The announcement reflects the single item left after filtering. + cy.get(".ui5-invisiblemessage-polite") + .should("contain.text", "Data loaded") + .and("contain.text", "1 result is available"); + }); +}); + +describe("Lazy loading item selection", () => { + const appendItems = (cb: ComboBox, texts: Array) => { + texts.forEach(text => { + const item = document.createElement("ui5-cb-item") as ComboBoxItem; + item.text = text; + cb.appendChild(item); + }); + }; + + it("selects the first matching item when loading is triggered by typing", () => { + const loadItems = (e: CustomEvent) => { + const cb = e.target as ComboBox; + cb.loading = true; + cb.open = true; + setTimeout(() => { + appendItems(cb, ["Albania", "Algeria", "Bulgaria"]); + cb.loading = false; + }, 100); + }; + + cy.mount(); + + cy.get("[ui5-combobox]").realClick(); + cy.realType("a"); + + // "Albania" is the first non-group item matching the typed text - it gets autocompleted and selected. + cy.get("[ui5-combobox]").should("have.prop", "value", "Albania"); + cy.get("[ui5-combobox]") + .find("[ui5-cb-item][text='Albania']") + .should("have.prop", "selected", true); + }); + + it("does not select any item when loading is triggered by the arrow icon", () => { + const loadItems = (e: CustomEvent) => { + const cb = e.target as ComboBox; + cb.loading = true; + setTimeout(() => { + appendItems(cb, ["Albania", "Algeria", "Bulgaria"]); + cb.loading = false; + }, 100); + }; + + cy.mount(); + + cy.get("[ui5-combobox]") + .shadow() + .find("[ui5-icon][name='slim-arrow-down']") + .realClick(); + + cy.get("[ui5-combobox]").find("[ui5-cb-item]").should("have.length", 3); + + // No item is selected or focused and the value stays empty. + cy.get("[ui5-combobox]").should("have.prop", "value", ""); + cy.get("[ui5-combobox]") + .find("[ui5-cb-item]") + .each($item => { + cy.wrap($item) + .should("have.prop", "selected", false) + .and("have.prop", "focused", false); + }); + }); + + it("selects the first item when loading is triggered by Arrow Down", () => { + const loadItems = (e: CustomEvent) => { + const cb = e.target as ComboBox; + cb.loading = true; + cb.open = true; + setTimeout(() => { + appendItems(cb, ["Albania", "Algeria", "Bulgaria"]); + cb.loading = false; + }, 100); + }; + + cy.mount(); + + cy.get("[ui5-combobox]").shadow().find("input").realClick(); + cy.get("[ui5-combobox]").shadow().find("input").realPress("ArrowDown"); + + cy.get("[ui5-combobox]") + .find("[ui5-cb-item]") + .first() + .should("have.prop", "focused", true); + }); + + it("selects the last item when loading is triggered by Arrow Up", () => { + const loadItems = (e: CustomEvent) => { + const cb = e.target as ComboBox; + cb.loading = true; + cb.open = true; + setTimeout(() => { + appendItems(cb, ["Albania", "Algeria", "Bulgaria"]); + cb.loading = false; + }, 100); + }; + + cy.mount(); + + cy.get("[ui5-combobox]").shadow().find("input").realClick(); + cy.get("[ui5-combobox]").shadow().find("input").realPress("ArrowUp"); + + cy.get("[ui5-combobox]") + .find("[ui5-cb-item]") + .last() + .should("have.prop", "focused", true); + }); +}); diff --git a/packages/main/cypress/specs/ComboBox.mobile.cy.tsx b/packages/main/cypress/specs/ComboBox.mobile.cy.tsx index c3eb8a58d783d..a1b6234d7cc71 100644 --- a/packages/main/cypress/specs/ComboBox.mobile.cy.tsx +++ b/packages/main/cypress/specs/ComboBox.mobile.cy.tsx @@ -670,3 +670,77 @@ describe("Dialog header title", () => { .should("have.text", INPUT_SUGGESTIONS_TITLE.defaultText); }); }); + +describe("Lazy loading", () => { + beforeEach(() => { + cy.ui5SimulateDevice("phone"); + }); + + it("Should open the dialog and fire load-items when start typing with no items", () => { + cy.mount( + + ); + + cy.get("[ui5-combobox]").realClick(); + + cy.get("[ui5-combobox]") + .shadow() + .find("[ui5-responsive-popover]") + .ui5ResponsivePopoverOpened(); + + cy.get("[ui5-combobox]") + .shadow() + .find("[ui5-responsive-popover] [ui5-input]") + .shadow() + .find("input") + .realType("A"); + + cy.get("@loadItems").should("have.been.called"); + }); + + it("Should open the dialog and fire load-items when pressing the arrow with no items", () => { + cy.mount( + + ); + + cy.get("[ui5-combobox]") + .shadow() + .find("[ui5-icon][name='slim-arrow-down']") + .realClick(); + + cy.get("[ui5-combobox]") + .shadow() + .find("[ui5-responsive-popover]") + .ui5ResponsivePopoverOpened(); + + cy.get("@loadItems") + .should("have.been.calledOnce") + .and("have.been.calledWithMatch", Cypress.sinon.match(event => { + return event.detail.reason === "open"; + })); + }); + + it("Should not fire load-items when typing and items are already present", () => { + cy.mount( + + + + + ); + + cy.get("[ui5-combobox]").realClick(); + + cy.get("[ui5-combobox]") + .shadow() + .find("[ui5-responsive-popover]") + .ui5ResponsivePopoverOpened(); + + cy.get("[ui5-combobox]") + .shadow() + .find("[ui5-responsive-popover] [ui5-input]") + .realClick() + .realType("A"); + + cy.get("@loadItems").should("not.have.been.called"); + }); +}); diff --git a/packages/main/src/ComboBox.ts b/packages/main/src/ComboBox.ts index 9674c08eab19f..d2e3ac22bcfdf 100644 --- a/packages/main/src/ComboBox.ts +++ b/packages/main/src/ComboBox.ts @@ -10,6 +10,8 @@ import { isPhone, isAndroid, isMac } from "@ui5/webcomponents-base/dist/Device.j import InvisibleMessageMode from "@ui5/webcomponents-base/dist/types/InvisibleMessageMode.js"; import { getEffectiveAriaLabelText, getAssociatedLabelForTexts } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js"; import announce from "@ui5/webcomponents-base/dist/util/InvisibleMessage.js"; +import type { LoadItemsReason } from "./features/ComboBoxLazyLoading.js"; +import ComboBoxLazyLoading from "./features/ComboBoxLazyLoading.js"; import "@ui5/webcomponents-icons/dist/slim-arrow-down.js"; import "@ui5/webcomponents-icons/dist/decline.js"; import "@ui5/webcomponents-icons/dist/error.js"; @@ -60,6 +62,10 @@ import { COMBOBOX_AVAILABLE_OPTIONS, COMBOBOX_DIALOG_OK_BUTTON, COMBOBOX_DIALOG_CANCEL_BUTTON, + COMBOBOX_LOADING, + COMBOBOX_LOADED, + COMBOBOX_LOADED_ITEMS, + COMBOBOX_LOADED_ITEM, SELECT_OPTIONS, LIST_ITEM_POSITION, LIST_ITEM_GROUP_HEADER, @@ -135,6 +141,11 @@ type ComboBoxSelectionChangeEventDetail = { trigger: ComboBoxSelectionChangeTrigger, }; +type ComboBoxLoadItemsEventDetail = { + reason: LoadItemsReason; + value: string; +}; + /** * @class * @@ -258,6 +269,17 @@ type ComboBoxSelectionChangeEventDetail = { bubbles: true, }) +/** + * Fired when the application should provide items for the component to render. + * The event is fired either when text is input or when the user presses arrow down on a combo-box with no items. + * @param {string} reason the reason the event was fired - "input" when text is typed, "open" when the picker is about to open + * @param {string} value value of the input + * @public + */ +@event("load-items", { + bubbles: true, +}) + class ComboBox extends UI5Element implements IFormInputElement { eventDetails!: { "change": void, @@ -265,6 +287,7 @@ class ComboBox extends UI5Element implements IFormInputElement { "open": void, "close": void, "selection-change": ComboBoxSelectionChangeEventDetail, + "load-items": ComboBoxLoadItemsEventDetail, } /** * Defines the value of the component. @@ -512,6 +535,10 @@ class ComboBox extends UI5Element implements IFormInputElement { icon!: Slot; _initialRendering = true; + _loadingDelegate: ComboBoxLazyLoading; + // Remembers what triggered the last "load-items" request, so the matching item can be + // selected once lazy loading finishes (see _selectItemOnLoadingEnd). + _loadItemsTrigger?: "input" | "iconClick" | "arrowDown" | "arrowUp"; _itemFocused = false; // used only for Safari fix (check onAfterRendering) _autocomplete = false; @@ -559,6 +586,26 @@ class ComboBox extends UI5Element implements IFormInputElement { // when an initial value is set it should be considered as a _lastValue this._lastValue = this.getAttribute("value") || ""; + this._loadingDelegate = new ComboBoxLazyLoading({ + getItemCount: () => this._getItems().filter(item => !item.isGroupItem && item._isVisible).length, + isLoading: () => this.loading, + isOpen: () => this.open, + fireLoadItems: reason => { + // Track what triggered the load so the correct item can be selected once loading ends. + if (reason === "input") { + this._loadItemsTrigger = "input"; + } else if (reason === "open") { + this._loadItemsTrigger = "iconClick"; + } + this.fireDecoratorEvent("load-items", { reason, value: this.value }); + }, + loadingMessage: () => ComboBox.i18nBundle.getText(COMBOBOX_LOADING), + loadedMessage: () => ComboBox.i18nBundle.getText(COMBOBOX_LOADED), + loadedItemMessage: () => ComboBox.i18nBundle.getText(COMBOBOX_LOADED_ITEM), + loadedItemsMessage: count => ComboBox.i18nBundle.getText(COMBOBOX_LOADED_ITEMS, count), + onLoadingEnd: () => this._selectItemOnLoadingEnd(), + }); + this._loadingDelegate.init(this.loading); } onBeforeRendering() { @@ -609,6 +656,7 @@ class ComboBox extends UI5Element implements IFormInputElement { }); this._selectMatchingItem(); + this._loadingDelegate.onBeforeRendering(this.loading); this._initialRendering = false; this.style.setProperty("--_ui5-input-icons-count", `${this.iconsCount}`); @@ -629,6 +677,8 @@ class ComboBox extends UI5Element implements IFormInputElement { this.storeResponsivePopoverWidth(); + this._loadingDelegate.announceLoadingState(); + if (!arraysAreEqual(this._valueStateLinks, this.linksInAriaValueStateHiddenText)) { this._removeLinksEventListeners(); this._addLinksEventListeners(); @@ -788,6 +838,10 @@ class ComboBox extends UI5Element implements IFormInputElement { this._lastSelectedValue = this.selectedValue; } + if (!this.open && !this.loading && this._getItems().length === 0) { + this._loadingDelegate.fireOnDropdownOpen(); + } + this._toggleRespPopover(); } @@ -829,12 +883,13 @@ class ComboBox extends UI5Element implements IFormInputElement { } this.fireDecoratorEvent("input"); + this._loadingDelegate.fireOnInput(); if (isPhone()) { return; } - if (!this._filteredItems.length || value === "") { + if (!this.loading && (!this._filteredItems.length || value === "")) { this._closeRespPopover(); } else { this._openRespPopover(); @@ -928,7 +983,7 @@ class ComboBox extends UI5Element implements IFormInputElement { } } - _handleItemNavigation(e: KeyboardEvent, indexOfItem: number, isForward: boolean) { + _handleItemNavigation(e: KeyboardEvent | undefined, indexOfItem: number, isForward: boolean) { const allItems = this._getItems(); const currentItem: IComboBoxItem = allItems[indexOfItem]; @@ -978,6 +1033,10 @@ class ComboBox extends UI5Element implements IFormInputElement { } _handleArrowDown(e: KeyboardEvent, indexOfItem: number) { + if (this.loading) { + return; + } + this._selectionTrigger = "Keyboard"; const isOpen = this.open; @@ -987,22 +1046,50 @@ class ComboBox extends UI5Element implements IFormInputElement { const allItems = this._getItems(); const currentItem = allItems[indexOfItem]; - const isLastItem = indexOfItem === allItems.length - 1; + const isLastItem = allItems.length > 0 && indexOfItem === allItems.length - 1; // We don't want to navigate further if the current item is the last one and either is already focused or the popover is closed if (isLastItem && ((isOpen && currentItem.focused) || !isOpen)) { return; } - const itemIndexToBeFocused = isLastItem ? indexOfItem : indexOfItem + 1; + if (this.items.length === 0 && !isOpen) { + this._loadItemsTrigger = "arrowDown"; + this.fireDecoratorEvent("load-items", { reason: "arrowNav", value: this.value }); + return; + } + + // When the popover is closed and no item is selected yet, Arrow Down selects the first item + if (!isOpen && indexOfItem === -1) { + this._navigateToEdgeItem(false); + return; + } + const itemIndexToBeFocused = isLastItem ? indexOfItem : indexOfItem + 1; this._handleItemNavigation(e, itemIndexToBeFocused, true /* isForward */); } _handleArrowUp(e: KeyboardEvent, indexOfItem: number) { - this._selectionTrigger = "Keyboard"; + if (this.loading) { + return; + } + const isOpen = this.open; + if (this.items.length === 0 && !isOpen) { + this._loadItemsTrigger = "arrowUp"; + this.fireDecoratorEvent("load-items", { reason: "arrowNav", value: this.value }); + return; + } + + this._selectionTrigger = "Keyboard"; + + // When the popover is closed and no item is selected yet, Arrow Up selects the last item + if (!isOpen && indexOfItem === -1) { + this._navigateToEdgeItem(true); + return; + } + if (indexOfItem === 0) { this._clearFocus(); this._itemFocused = false; @@ -1064,7 +1151,7 @@ class ComboBox extends UI5Element implements IFormInputElement { this._autocomplete = !(isBackSpace(e) || isDelete(e)); this._isKeyNavigation = false; - if (isNavKey && !this.readonly && this._filteredItems.length) { + if (isNavKey && !this.readonly) { this.handleNavKeyPress(e); } @@ -1110,6 +1197,7 @@ class ComboBox extends UI5Element implements IFormInputElement { e.preventDefault(); this._resetFilter(); + this._loadingDelegate.fireOnDropdownOpen(); this._toggleRespPopover(); const selectedItem = allItems.find(item => { @@ -1201,6 +1289,9 @@ class ComboBox extends UI5Element implements IFormInputElement { _click() { if (isPhone() && !this.readonly) { + if (!this.loading && this._getItems().length === 0) { + this._loadingDelegate.fireOnDropdownOpen(); + } this._openRespPopover(); } } @@ -1310,6 +1401,10 @@ class ComboBox extends UI5Element implements IFormInputElement { } _selectMatchingItem() { + if (!this.items || !this.items.length) { + return; + } + const currentlyFocusedItem = this.items.find(item => item.focused); const shouldSelectionBeCleared = currentlyFocusedItem && currentlyFocusedItem.isGroupItem; const valueToMatch = currentlyFocusedItem?.value ?? this.selectedValue; @@ -1403,6 +1498,64 @@ class ComboBox extends UI5Element implements IFormInputElement { } } + /** + * Selects the appropriate item once lazy loading finishes, based on what triggered the load: + * - typing (`"input"`): autocompletes and selects the first non-group item matching the typed text + * - arrow icon click / [F4] (`"iconClick"`): shows the loaded items without selecting any + * - [Arrow Down] (`"arrowDown"`): focuses the first item + * - [Arrow Up] (`"arrowUp"`): focuses the last item + * @private + */ + _selectItemOnLoadingEnd() { + const trigger = this._loadItemsTrigger; + this._loadItemsTrigger = undefined; + + // Arrow click / F4 and arrow navigation show all loaded items; typing filters by the value. + // Refresh _filteredItems from the freshly loaded items - onBeforeRendering skips this while + // key navigating (_isKeyNavigation is still true), so _getItems() would otherwise be stale. + const showsAllItems = trigger === "iconClick" || trigger === "arrowDown" || trigger === "arrowUp"; + this._filteredItems = showsAllItems ? this._filterItems("") : this._filterItems(this.value); + + const visibleItems = this._getItems().filter(item => !item.isGroupItem && item._isVisible); + + // Nothing matches the typed value - close the picker. + if (visibleItems.length === 0 && this.value) { + this._closeRespPopover(); + return; + } + + if (trigger === "arrowDown" || trigger === "arrowUp") { + this._navigateToEdgeItem(trigger === "arrowUp" /* selectLast */); + } else if (trigger === "input" && this.inner) { + // Same behavior as typing without lazy loading - autocomplete and select the match. + if (!this.noTypeahead && this.value) { + this._handleTypeAhead(this.value, this.value); + } + this._selectMatchingItem(); + } + // trigger === "iconClick" (arrow icon / F4) or programmatic loading - no selection is performed. + } + + /** + * Focuses (and, when the picker is closed, selects) the first or last non-group item. + * Shared by the immediate Arrow Up/Down navigation (when the ComboBox already has items) + * and by the post-lazy-loading selection, so both behave identically once items are available. + * @private + */ + _navigateToEdgeItem(selectLast: boolean) { + const allItems = this._getItems(); + const navigableItems = allItems.filter(item => !item.isGroupItem); + + if (!navigableItems.length) { + return; + } + + const targetItem = selectLast ? navigableItems[navigableItems.length - 1] : navigableItems[0]; + this._isKeyNavigation = true; + this._selectionTrigger = "Keyboard"; + this._handleItemNavigation(undefined, allItems.indexOf(targetItem), !selectLast /* isForward */); + } + _fireChangeEvent() { const valueChanged = this.value !== this._lastValue; const selectedValueChanged = this._useSelectedValue && this.selectedValue !== this._lastSelectedValue; @@ -1748,7 +1901,7 @@ class ComboBox extends UI5Element implements IFormInputElement { const remSizeInPx = parseInt(getComputedStyle(document.documentElement).fontSize); return { suggestionPopoverHeader: { - "display": this._listWidth === 0 ? "none" : "inline-block", + "display": (!this._isPhone && this._listWidth === 0) ? "none" : "inline-block", "width": `${this._listWidth || ""}px`, "max-width": "inherit", }, @@ -1786,5 +1939,6 @@ export default ComboBox; export type { ComboBoxSelectionChangeEventDetail, ComboBoxSelectionChangeTrigger, + ComboBoxLoadItemsEventDetail, IComboBoxItem, }; diff --git a/packages/main/src/ComboBoxPopoverTemplate.tsx b/packages/main/src/ComboBoxPopoverTemplate.tsx index 8373f4ced8b52..8ba3062e4eea6 100644 --- a/packages/main/src/ComboBoxPopoverTemplate.tsx +++ b/packages/main/src/ComboBoxPopoverTemplate.tsx @@ -10,6 +10,7 @@ import BusyIndicator from "./BusyIndicator.js"; import SuggestionItem from "./SuggestionItem.js"; import generateHighlightedMarkupFirstMatch from "@ui5/webcomponents-base/dist/util/generateHighlightedMarkupFirstMatch.js"; import type ComboBox from "./ComboBox.js"; +import { LOADING_DELAY } from "./features/ComboBoxLazyLoading.js"; export default function ComboBoxPopoverTemplate(this: ComboBox) { return ( @@ -33,74 +34,93 @@ export default function ComboBoxPopoverTemplate(this: ComboBox) { onKeyDown={this._handlePopoverKeydown} onFocusOut={this._handlePopoverFocusout} > - {this.loading && - - } - - {!this.loading && this._isPhone && - <> -
-
- - {this._headerTitleText} - -
+ {this._isPhone && dialogHeader.call(this)} + {valueStateHeader.call(this)} + {content.call(this)} + {this._isPhone && dialogFooter.call(this)} + -
- - { this._filteredItems.flatMap(item => { - if (item.isGroupItem && item.items) { - // For group items, return all nested items - return item.items - .filter(nestedItem => !!nestedItem) - .map(nestedItem => - - ); - } - // For regular items - return ; - })} - -
+ {this.shouldOpenValueStateMessagePopover && + +
+ + {valueStateMessage.call(this)}
+
+ } + + ); +} - {this.hasValueStateText && -
- - { this.open && valueStateMessage.call(this) } -
- } - - } +function valueStateMessage(this: ComboBox) { + return ( + <> + {this.shouldDisplayDefaultValueStateMessage ? this.valueStateDefaultText : } + + ); +} - {!this._isPhone && this.hasValueStateText && -
- - { this.open && valueStateMessage.call(this) } -
- } +function valueStateHeader(this: ComboBox) { + if (!this.hasValueStateText) { + return; + } + + if (this._isPhone) { + return ( +
+ + {this.open && valueStateMessage.call(this)} +
+ ); + } + + return ( +
+ + {this.open && valueStateMessage.call(this)} +
+ ); +} + +function content(this: ComboBox) { + if (this.loading && (this._isPhone || !this.hasValueState)) { + return ; + } - {!this.loading && !!this._filteredItems.length && + const loadingOnDesktopWithValueState = this.loading && !this._isPhone && this.hasValueState; + const hasFilteredItems = !this.loading && !!this._filteredItems?.length; + + if (!loadingOnDesktopWithValueState && !hasFilteredItems) { + return; + } + + return ( + <> + {loadingOnDesktopWithValueState && +
+ +
+ } + {hasFilteredItems && - { this._filteredItems.map(item => )} + {this._filteredItems.map(item => )} - } - - {this._isPhone && - - } - - - {this.shouldOpenValueStateMessagePopover && - -
- - { valueStateMessage.call(this) } -
-
} ); } -function valueStateMessage(this: ComboBox) { +function dialogFooter(this: ComboBox) { return ( - <> - { this.shouldDisplayDefaultValueStateMessage ? this.valueStateDefaultText : } - + ); } + +function dialogHeader(this: ComboBox) { + return <> +
+
+ + {this._headerTitleText} + +
+ +
+ + {!this.loading && this._filteredItems.flatMap(item => { + if (item.isGroupItem && item.items) { + // For group items, return all nested items + return item.items + .filter(nestedItem => !!nestedItem) + .map(nestedItem => + + ); + } + // For regular items + return ; + })} + +
+
+ ; +} diff --git a/packages/main/src/ComboBoxTemplate.tsx b/packages/main/src/ComboBoxTemplate.tsx index 1d75e59481d64..aecc9fe00a8f6 100644 --- a/packages/main/src/ComboBoxTemplate.tsx +++ b/packages/main/src/ComboBoxTemplate.tsx @@ -3,10 +3,12 @@ import Icon from "./Icon.js"; import decline from "@ui5/webcomponents-icons/dist/decline.js"; import slimArrowDown from "@ui5/webcomponents-icons/dist/slim-arrow-down.js"; import ComboBoxPopoverTemplate from "./ComboBoxPopoverTemplate.js"; +import BusyIndicator from "./BusyIndicator.js"; export default function ComboBoxTemplate(this: ComboBox) { + const inlineLoading = this.loading && !this.open && !this._isPhone; return ( - <> +
{this.hasValueState && {this.ariaValueStateHiddenText} @@ -69,6 +71,6 @@ export default function ComboBoxTemplate(this: ComboBox) {
{ ComboBoxPopoverTemplate.call(this) } - +
); } diff --git a/packages/main/src/features/ComboBoxLazyLoading.ts b/packages/main/src/features/ComboBoxLazyLoading.ts new file mode 100644 index 0000000000000..cc74ae9a74622 --- /dev/null +++ b/packages/main/src/features/ComboBoxLazyLoading.ts @@ -0,0 +1,73 @@ +import InvisibleMessageMode from "@ui5/webcomponents-base/dist/types/InvisibleMessageMode.js"; +import announce from "@ui5/webcomponents-base/dist/util/InvisibleMessage.js"; + +export const LOADING_DELAY = 100; + +type AnnounceState = "Loading" | "Loaded" | "None"; +type LoadItemsReason = "input" | "open" | "arrowNav"; + +export interface LoadingDelegateConfig { + getItemCount: () => number; + isLoading: () => boolean; + isOpen: () => boolean; + fireLoadItems: (reason: LoadItemsReason) => void; + loadingMessage: () => string; + loadedMessage: () => string; + loadedItemMessage: () => string; + loadedItemsMessage: (count: number) => string; + onLoadingEnd?: () => void; +} + +export default class ComboBoxLazyLoading { + _config: LoadingDelegateConfig; + _prevLoading: boolean; + _announceLoading: AnnounceState; + + constructor(config: LoadingDelegateConfig) { + this._config = config; + this._prevLoading = false; + this._announceLoading = "None"; + } + + init(loading: boolean) { + this._prevLoading = loading; + } + + onBeforeRendering(loading: boolean) { + if (!this._prevLoading && loading) { + this._announceLoading = "Loading"; + } else if (this._prevLoading && !loading) { + this._announceLoading = "Loaded"; + this._config.onLoadingEnd?.(); + } + this._prevLoading = loading; + } + + announceLoadingState() { + if (this._announceLoading === "Loading") { + announce(this._config.loadingMessage(), InvisibleMessageMode.Polite); + } else if (this._announceLoading === "Loaded") { + const count = this._config.getItemCount(); + const itemsMsg = count === 1 + ? this._config.loadedItemMessage() + : this._config.loadedItemsMessage(count); + announce(`${this._config.loadedMessage()}. ${itemsMsg}`, InvisibleMessageMode.Polite); + } + this._announceLoading = "None"; + } + + // Fires load-items event when the picker is about to open and there are no items yet. + // shouldOpenPicker=false: caller will open the picker itself (e.g. arrow click). + // shouldOpenPicker=true: app must open the picker when loading starts. + fireOnDropdownOpen() { + if (!this._config.isOpen() && !this._config.isLoading() && this._config.getItemCount() === 0) { + this._config.fireLoadItems("open"); + } + } + + fireOnInput() { + this._config.fireLoadItems("input"); + } +} + +export type { LoadItemsReason }; diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties index c43151f060cba..aa7703e426842 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -450,6 +450,18 @@ MULTICOMBOBOX_DIALOG_CANCEL_BUTTON=Cancel #XACT: ARIA announcement for Combo Box and Multi Combo Box available options COMBOBOX_AVAILABLE_OPTIONS=Available Options +#XACT: ARIA announcement when ComboBox starts loading items +COMBOBOX_LOADING=Loading data + +#XACT: ARIA announcement when ComboBox ends loading items +COMBOBOX_LOADED=Data loaded + +#XACT: ARIA announcement when ComboBox finishes loading, {0} is the number of loaded items +COMBOBOX_LOADED_ITEMS={0} results are available + +#XACT: ARIA announcement when ComboBox finishes loading and there is 1 item +COMBOBOX_LOADED_ITEM=1 result is available + #XBUT: Combobox Dialog OK button on mobile devices COMBOBOX_DIALOG_OK_BUTTON=OK diff --git a/packages/main/src/themes/ComboBox.css b/packages/main/src/themes/ComboBox.css index 146004544bf9f..1dec1b60e36db 100644 --- a/packages/main/src/themes/ComboBox.css +++ b/packages/main/src/themes/ComboBox.css @@ -10,6 +10,11 @@ border-radius: var(--_ui5_input_border_radius); } +.ui5-combobox-busy-indicator { + width: 100%; + height: 100%; +} + :host(:not([readonly])) [inner-input] { padding: var(--_ui5_input_inner_padding_with_icon); } \ No newline at end of file diff --git a/packages/main/src/themes/ComboBoxPopover.css b/packages/main/src/themes/ComboBoxPopover.css index 8e0921c827287..00470aa8ab0d6 100644 --- a/packages/main/src/themes/ComboBoxPopover.css +++ b/packages/main/src/themes/ComboBoxPopover.css @@ -15,6 +15,12 @@ display: none; } +.ui5-combobox-busy-container { + position: relative; + min-height: 2.5rem; + width: 100%; +} + [ui5-responsive-popover] [ui5-input] { width: 100%; } \ No newline at end of file diff --git a/packages/main/test/pages/ComboBox.html b/packages/main/test/pages/ComboBox.html index dae7010c1efc3..6a490670fd689 100644 --- a/packages/main/test/pages/ComboBox.html +++ b/packages/main/test/pages/ComboBox.html @@ -191,30 +191,17 @@
- Toggle Loading State + Lazy loading using the 'load-items' event

- - - - - - Toggle Loading + + Clear items
- Lazy Loading - example does not work on IE11 - -
-
- - -
- -
- Lazy Loading with filter='None' + Lazy loading using the 'input' event and the entered text for creating items

@@ -613,40 +600,6 @@

ComboBox Composition

- - + + +

InvisibleMessage announcements while a modal Dialog is open

+

+ Reproduction for issue #13613. + Turn on VoiceOver (Cmd+F5 on macOS), then follow the steps below. With this branch's fix, the UI5 Dialog + renders its own aria-live region inside the dialog subtree, so announce() is heard while the dialog is open. +

+ +
+ 1. Announce — no dialog open + + 2. Open modal dialog + + + 3a. Announce into a span that lives in <body> (outside the dialog) — silenced by VoiceOver + + + + 3b. Announce via InvisibleMessage.announce() — routed inside the open dialog (FIXED) + +
+ + + + + + + +
+

This dialog is modal (aria-modal="true"), so VoiceOver scopes its accessibility tree to this subtree.

+

Use buttons 3a and 3b below (they stay reachable) to compare the two live regions.

+
+ 3a. Announce into <body> span (silenced) + 3b. Announce via API (heard — FIXED) +
+
+ Close +
+ + + diff --git a/packages/website/docs/_components_pages/main/ComboBox/ComboBox.mdx b/packages/website/docs/_components_pages/main/ComboBox/ComboBox.mdx index 8fd3cfa5cd4fd..05f85bd46962b 100644 --- a/packages/website/docs/_components_pages/main/ComboBox/ComboBox.mdx +++ b/packages/website/docs/_components_pages/main/ComboBox/ComboBox.mdx @@ -11,6 +11,7 @@ import SuggestionsWrapping from "../../../_samples/main/ComboBox/SuggestionsWrap import SelectedValue from "../../../_samples/main/ComboBox/SelectedValue/SelectedValue.md"; import SameTextDifferentValues from "../../../_samples/main/ComboBox/SameTextDifferentValues/SameTextDifferentValues.md"; import CustomItems from "../../../_samples/main/ComboBox/CustomItems/CustomItems.md"; +import LazyLoading from "../../../_samples/main/ComboBox/LazyLoading/LazyLoading.md"; <%COMPONENT_OVERVIEW%> @@ -63,4 +64,14 @@ The `additional-text` property helps users distinguish between items visually. Use `ui5-cb-item-custom` to create custom item templates with complex layouts, multiple icons, images, or any HTML content. This allows you to build rich, visually appealing dropdown items beyond the standard text and `additional-text` format. - \ No newline at end of file + + + +### Lazy loaded items +Use the `load-items` event to load items on demand. Its detail carries the `reason` +(`"open"` when the picker is opened with no items, `"input"` when the user types) and the current +`value`. The application can create its own `AbortController` and abort the previous request whenever a +newer `load-items` event is fired - so an outdated request can be cancelled. Set `filter="None"` to +disable client-side filtering and let the server decide which items are returned. + + \ No newline at end of file diff --git a/packages/website/docs/_samples/main/ComboBox/LazyLoading/LazyLoading.md b/packages/website/docs/_samples/main/ComboBox/LazyLoading/LazyLoading.md new file mode 100644 index 0000000000000..3ee9d9a93fba0 --- /dev/null +++ b/packages/website/docs/_samples/main/ComboBox/LazyLoading/LazyLoading.md @@ -0,0 +1,19 @@ +import html from '!!raw-loader!./sample.html'; +import js from '!!raw-loader!./main.js'; +import react from '!!raw-loader!./sample.tsx'; + +import html3 from '!!raw-loader!./sample3.html'; +import js3 from '!!raw-loader!./main3.js'; +import react3 from '!!raw-loader!./sample3.tsx'; + +#### Load on open, cancel and re-query on type + +Items are loaded when the picker opens (arrow down) and re-queried from the "server" as you type. Every keystroke fires a new `load-items` event and the application aborts the previous request via its own `AbortController`, so an in-flight fetch is cancelled in favor of a fresh, server-side filtered one. + + + +#### Search as you type + +There is no preloading - each character you type triggers a server-side search. Outdated requests are aborted via the application's `AbortController`, so only the latest query resolves into items. + + diff --git a/packages/website/docs/_samples/main/ComboBox/LazyLoading/main.js b/packages/website/docs/_samples/main/ComboBox/LazyLoading/main.js new file mode 100644 index 0000000000000..373b918a80368 --- /dev/null +++ b/packages/website/docs/_samples/main/ComboBox/LazyLoading/main.js @@ -0,0 +1,63 @@ +import "@ui5/webcomponents/dist/ComboBox.js"; +import "@ui5/webcomponents/dist/ComboBoxItem.js"; + +const cb = document.getElementById("cbLazy"); +const COUNTRIES = ["Afghanistan", "Albania", "Algeria", "Argentina", "Australia", "Austria", + "Belgium", "Brazil", "Bulgaria", "Canada", "Chile", "China", "Colombia", + "Croatia", "Czech Republic", "Denmark", "Egypt", "Finland", "France", "Germany", + "Greece", "Hungary", "India", "Indonesia", "Iran", "Iraq", "Ireland", "Israel", + "Italy", "Japan", "Jordan", "Kazakhstan", "Kenya", "South Korea", "Malaysia", + "Mexico", "Morocco", "Netherlands", "New Zealand", "Nigeria", "Norway", "Pakistan", + "Peru", "Philippines", "Poland", "Portugal", "Romania", "Russia", "Saudi Arabia", + "Serbia", "Singapore", "South Africa", "Spain", "Sweden", "Switzerland", "Thailand", + "Turkey", "Ukraine", "United Kingdom", "United States", "Vietnam"]; + +// Simulates a server-side search: resolves after a delay with countries filtered +// by "value" (an empty value returns all), and rejects with an "AbortError" when +// the request is cancelled. +const fetchCountries = (value, signal) => new Promise((resolve, reject) => { + const timer = setTimeout(() => { + resolve(COUNTRIES.filter(c => c.toLowerCase().includes(value.toLowerCase()))); + }, 800); + + signal.addEventListener("abort", () => { + clearTimeout(timer); + reject(new DOMException("Aborted", "AbortError")); + }); +}); + +// Arrow down fires load-items with an empty value, so the "server" returns all countries. +// Because filter="None", each typed character fires a new load-items - the app aborts the +// previous request's AbortController, so the in-flight fetch is cancelled and a fresh +// server-side filtered request takes over. +let abortController; +cb.addEventListener("load-items", async (e) => { + const { value, reason } = e.detail; + + // Cancel any in-flight request and create a fresh signal for this one. + abortController?.abort(); + abortController = new AbortController(); + const { signal } = abortController; + + cb.loading = true; + + if(reason !== "open"){ + cb.open = true; + } + + try { + const matches = await fetchCountries(value, signal); + cb.innerHTML = ""; + matches.forEach(country => { + const item = document.createElement("ui5-cb-item"); + item.setAttribute("text", country); + cb.appendChild(item); + }); + cb.loading = false; + } catch (err) { + // A newer load-items event superseded this one - the fresh request owns loading now. + if (err.name !== "AbortError") { + throw err; + } + } +}); diff --git a/packages/website/docs/_samples/main/ComboBox/LazyLoading/main3.js b/packages/website/docs/_samples/main/ComboBox/LazyLoading/main3.js new file mode 100644 index 0000000000000..e01be42761471 --- /dev/null +++ b/packages/website/docs/_samples/main/ComboBox/LazyLoading/main3.js @@ -0,0 +1,66 @@ +import "@ui5/webcomponents/dist/ComboBox.js"; +import "@ui5/webcomponents/dist/ComboBoxItem.js"; + +const cb3 = document.getElementById("cbSearch"); +const COUNTRIES3 = ["Afghanistan", "Albania", "Algeria", "Argentina", "Australia", "Austria", + "Belgium", "Brazil", "Bulgaria", "Canada", "Chile", "China", "Colombia", + "Croatia", "Czech Republic", "Denmark", "Egypt", "Finland", "France", "Germany", + "Greece", "Hungary", "India", "Indonesia", "Iran", "Iraq", "Ireland", "Israel", + "Italy", "Japan", "Jordan", "Kazakhstan", "Kenya", "South Korea", "Malaysia", + "Mexico", "Morocco", "Netherlands", "New Zealand", "Nigeria", "Norway", "Pakistan", + "Peru", "Philippines", "Poland", "Portugal", "Romania", "Russia", "Saudi Arabia", + "Serbia", "Singapore", "South Africa", "Spain", "Sweden", "Switzerland", "Thailand", + "Turkey", "Ukraine", "United Kingdom", "United States", "Vietnam"]; + +// Simulates a server-side search: resolves after a delay with countries filtered +// by "value", and rejects with an "AbortError" when the request is cancelled. +const searchCountries = (value, signal) => new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (value.trim().length > 0) { + resolve(COUNTRIES3.filter(c => c.toLowerCase().includes(value.toLowerCase()))); + } else { + resolve(COUNTRIES3); + } + }, 600); + + signal.addEventListener("abort", () => { + clearTimeout(timer); + reject(new DOMException("Aborted", "AbortError")); + }); +}); + + +// This is a pure "search as you type" flow. With filter="None" every keystroke fires a +// load-items event with reason "input"; the app aborts the previous request's +// AbortController, so an outdated search is cancelled and only the latest query resolves. +let abortController3; +cb3.addEventListener("load-items", async (e) => { + const { value, reason } = e.detail; + + // Cancel any in-flight request and create a fresh signal for this one. + abortController3?.abort(); + abortController3 = new AbortController(); + const { signal } = abortController3; + + cb3.loading = true; + + if(reason === "input"){ + cb3.open = true; + } + + try { + const matches = await searchCountries(value, signal); + cb3.innerHTML = ""; + matches.forEach(country => { + const item = document.createElement("ui5-cb-item"); + item.setAttribute("text", country); + cb3.appendChild(item); + }); + cb3.loading = false; + } catch (err) { + // Superseded by a newer search - the fresh request now owns the loading state. + if (err.name !== "AbortError") { + throw err; + } + } +}); diff --git a/packages/website/docs/_samples/main/ComboBox/LazyLoading/sample.html b/packages/website/docs/_samples/main/ComboBox/LazyLoading/sample.html new file mode 100644 index 0000000000000..25e86a3037af4 --- /dev/null +++ b/packages/website/docs/_samples/main/ComboBox/LazyLoading/sample.html @@ -0,0 +1,21 @@ + + + + + + + + Sample + + + + + + + + + + + + + diff --git a/packages/website/docs/_samples/main/ComboBox/LazyLoading/sample.tsx b/packages/website/docs/_samples/main/ComboBox/LazyLoading/sample.tsx new file mode 100644 index 0000000000000..fb1d5d83dd7d8 --- /dev/null +++ b/packages/website/docs/_samples/main/ComboBox/LazyLoading/sample.tsx @@ -0,0 +1,92 @@ +import createReactComponent from "@ui5/webcomponents-base/dist/createReactComponent.js"; +import ComboBoxClass from "@ui5/webcomponents/dist/ComboBox.js"; +import type { UI5CustomEvent } from "@ui5/webcomponents-base"; +import ComboBoxItemClass from "@ui5/webcomponents/dist/ComboBoxItem.js"; +import { useState, useCallback, useRef } from "react"; + +const ComboBox = createReactComponent(ComboBoxClass); +const ComboBoxItem = createReactComponent(ComboBoxItemClass); + +const COUNTRIES = [ + "Afghanistan", "Albania", "Algeria", "Argentina", "Australia", "Austria", + "Belgium", "Brazil", "Bulgaria", "Canada", "Chile", "China", "Colombia", + "Croatia", "Czech Republic", "Denmark", "Egypt", "Finland", "France", "Germany", + "Greece", "Hungary", "India", "Indonesia", "Iran", "Iraq", "Ireland", "Israel", + "Italy", "Japan", "Jordan", "Kazakhstan", "Kenya", "South Korea", "Malaysia", + "Mexico", "Morocco", "Netherlands", "New Zealand", "Nigeria", "Norway", "Pakistan", + "Peru", "Philippines", "Poland", "Portugal", "Romania", "Russia", "Saudi Arabia", + "Serbia", "Singapore", "South Africa", "Spain", "Sweden", "Switzerland", "Thailand", + "Turkey", "Ukraine", "United Kingdom", "United States", "Vietnam", +]; + +function App() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const abortControllerRef = useRef(); + + // Simulates a server-side search: resolves after a delay with countries filtered + // by "value" (an empty value returns all), and rejects with an "AbortError" when + // the request is cancelled. + const fetchCountries = (value: string, signal : AbortSignal) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + resolve( + COUNTRIES.filter((c) => c.toLowerCase().includes(value.toLowerCase())) + ); + }, 800); + + signal.addEventListener("abort", () => { + clearTimeout(timer); + reject(new DOMException("Aborted", "AbortError")); + }); + }); + + // Arrow down fires load-items with an empty value, so the "server" returns all countries. + // Because filter="None", each typed character fires a new load-items - the app aborts the + // previous request's AbortController, so the in-flight fetch is cancelled and a fresh + // server-side filtered request takes over. + const handleLoadItems = useCallback(async (e: UI5CustomEvent) => { + const { value, reason } = e.detail; + + // Cancel any in-flight request and create a fresh signal for this one. + abortControllerRef.current?.abort(); + abortControllerRef.current = new AbortController(); + const { signal } = abortControllerRef.current; + + setLoading(true); + + if (reason !== "open") { + setOpen(true); + } + + try { + const matches = await fetchCountries(value, signal); + setItems(matches); + setLoading(false); + } catch (err) { + // A newer load-items event superseded this one - the fresh request owns loading now. + if ((err as any).name !== "AbortError") { + throw err; + } + } + }, []); + + return ( + setOpen(true)} + onClose={() => setOpen(false)} + onLoadItems={handleLoadItems} + > + {items.map((country: string) => ( + + ))} + + ); +} + +export default App; diff --git a/packages/website/docs/_samples/main/ComboBox/LazyLoading/sample3.html b/packages/website/docs/_samples/main/ComboBox/LazyLoading/sample3.html new file mode 100644 index 0000000000000..7b39d0ad2b6e5 --- /dev/null +++ b/packages/website/docs/_samples/main/ComboBox/LazyLoading/sample3.html @@ -0,0 +1,21 @@ + + + + + + + + Sample + + + + + + + + + + + + + diff --git a/packages/website/docs/_samples/main/ComboBox/LazyLoading/sample3.tsx b/packages/website/docs/_samples/main/ComboBox/LazyLoading/sample3.tsx new file mode 100644 index 0000000000000..3de5607077e5d --- /dev/null +++ b/packages/website/docs/_samples/main/ComboBox/LazyLoading/sample3.tsx @@ -0,0 +1,95 @@ +import createReactComponent from "@ui5/webcomponents-base/dist/createReactComponent.js"; +import ComboBoxClass from "@ui5/webcomponents/dist/ComboBox.js"; +import ComboBoxItemClass from "@ui5/webcomponents/dist/ComboBoxItem.js"; +import type { UI5CustomEvent } from "@ui5/webcomponents-base"; +import { useState, useCallback, useRef } from "react"; + +const ComboBox = createReactComponent(ComboBoxClass); +const ComboBoxItem = createReactComponent(ComboBoxItemClass); + +const COUNTRIES = [ + "Afghanistan", "Albania", "Algeria", "Argentina", "Australia", "Austria", + "Belgium", "Brazil", "Bulgaria", "Canada", "Chile", "China", "Colombia", + "Croatia", "Czech Republic", "Denmark", "Egypt", "Finland", "France", "Germany", + "Greece", "Hungary", "India", "Indonesia", "Iran", "Iraq", "Ireland", "Israel", + "Italy", "Japan", "Jordan", "Kazakhstan", "Kenya", "South Korea", "Malaysia", + "Mexico", "Morocco", "Netherlands", "New Zealand", "Nigeria", "Norway", "Pakistan", + "Peru", "Philippines", "Poland", "Portugal", "Romania", "Russia", "Saudi Arabia", + "Serbia", "Singapore", "South Africa", "Spain", "Sweden", "Switzerland", "Thailand", + "Turkey", "Ukraine", "United Kingdom", "United States", "Vietnam", +]; + +function App() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const abortControllerRef = useRef(); + + // Simulates a server-side search: resolves after a delay with countries filtered + // by "value" (an empty value returns all), and rejects with an "AbortError" when the + // request is cancelled. + const searchCountries = (value: string, signal: AbortSignal) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (value.trim().length > 0) { + resolve( + COUNTRIES.filter((c) => c.toLowerCase().includes(value.toLowerCase())) + ); + } else { + resolve(COUNTRIES); + } + }, 600); + + signal.addEventListener("abort", () => { + clearTimeout(timer); + reject(new DOMException("Aborted", "AbortError")); + }); + }); + + // This is a pure "search as you type" flow. With filter="None" every keystroke fires a + // load-items event with reason "input"; the app aborts the previous request's + // AbortController, so an outdated search is cancelled and only the latest query resolves. + const handleLoadItems = useCallback(async (e: UI5CustomEvent) => { + const { value, reason } = e.detail; + + // Cancel any in-flight request and create a fresh signal for this one. + abortControllerRef.current?.abort(); + abortControllerRef.current = new AbortController(); + const { signal } = abortControllerRef.current; + + setLoading(true); + + if (reason === "input") { + setOpen(true); + } + + try { + const matches = await searchCountries(value, signal); + setItems(matches); + setLoading(false); + } catch (err) { + // Superseded by a newer search - the fresh request now owns the loading state. + if ((err as any).name !== "AbortError") { + throw err; + } + } + }, []); + + return ( + setOpen(true)} + onClose={() => setOpen(false)} + onLoadItems={handleLoadItems} + > + {items.map((country: string) => ( + + ))} + + ); +} + +export default App;