From 379428aa3bf57c316bb039b8578c6411ee24e230 Mon Sep 17 00:00:00 2001 From: Ivanova Terzieva Date: Fri, 14 Aug 2026 17:25:17 +0300 Subject: [PATCH 1/2] chore(ui5-combobox): improve accessibility when items are loaded with delay --- packages/main/cypress/specs/ComboBox.cy.tsx | 234 ++++++++++++++++- .../main/cypress/specs/ComboBox.mobile.cy.tsx | 74 ++++++ packages/main/src/ComboBox.ts | 67 ++++- packages/main/src/ComboBoxPopoverTemplate.tsx | 246 ++++++++++-------- .../main/src/features/ComboBoxLazyLoading.ts | 73 ++++++ .../main/src/i18n/messagebundle.properties | 12 + packages/main/src/themes/ComboBoxPopover.css | 6 + packages/main/test/pages/ComboBox.html | 245 +++++++++++++---- .../main/ComboBox/ComboBox.mdx | 13 +- .../main/ComboBox/LazyLoading/LazyLoading.md | 29 +++ .../main/ComboBox/LazyLoading/main.js | 63 +++++ .../main/ComboBox/LazyLoading/main2.js | 39 +++ .../main/ComboBox/LazyLoading/main3.js | 66 +++++ .../main/ComboBox/LazyLoading/sample.html | 21 ++ .../main/ComboBox/LazyLoading/sample.tsx | 92 +++++++ .../main/ComboBox/LazyLoading/sample2.html | 21 ++ .../main/ComboBox/LazyLoading/sample2.tsx | 62 +++++ .../main/ComboBox/LazyLoading/sample3.html | 21 ++ .../main/ComboBox/LazyLoading/sample3.tsx | 95 +++++++ 19 files changed, 1316 insertions(+), 163 deletions(-) create mode 100644 packages/main/src/features/ComboBoxLazyLoading.ts create mode 100644 packages/website/docs/_samples/main/ComboBox/LazyLoading/LazyLoading.md create mode 100644 packages/website/docs/_samples/main/ComboBox/LazyLoading/main.js create mode 100644 packages/website/docs/_samples/main/ComboBox/LazyLoading/main2.js create mode 100644 packages/website/docs/_samples/main/ComboBox/LazyLoading/main3.js create mode 100644 packages/website/docs/_samples/main/ComboBox/LazyLoading/sample.html create mode 100644 packages/website/docs/_samples/main/ComboBox/LazyLoading/sample.tsx create mode 100644 packages/website/docs/_samples/main/ComboBox/LazyLoading/sample2.html create mode 100644 packages/website/docs/_samples/main/ComboBox/LazyLoading/sample2.tsx create mode 100644 packages/website/docs/_samples/main/ComboBox/LazyLoading/sample3.html create mode 100644 packages/website/docs/_samples/main/ComboBox/LazyLoading/sample3.tsx diff --git a/packages/main/cypress/specs/ComboBox.cy.tsx b/packages/main/cypress/specs/ComboBox.cy.tsx index a956c757cbf60..b939b43a4a484 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,223 @@ 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"); + }); +}); 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..ca39b539a1498 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,8 @@ class ComboBox extends UI5Element implements IFormInputElement { icon!: Slot; _initialRendering = true; + _loadingDelegate: ComboBoxLazyLoading; + _isArrowClicked = false; _itemFocused = false; // used only for Safari fix (check onAfterRendering) _autocomplete = false; @@ -559,6 +584,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 => 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: () => { + const visibleItems = this._isArrowClicked + ? this._getItems().filter(item => !item.isGroupItem && item._isVisible) + : this._filterItems(this.value); + this._isArrowClicked = false; + if (visibleItems.length === 0 && this.value) { + this._closeRespPopover(); + } + }, + }); + this._loadingDelegate.init(this.loading); } onBeforeRendering() { @@ -609,6 +654,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 +675,8 @@ class ComboBox extends UI5Element implements IFormInputElement { this.storeResponsivePopoverWidth(); + this._loadingDelegate.announceLoadingState(); + if (!arraysAreEqual(this._valueStateLinks, this.linksInAriaValueStateHiddenText)) { this._removeLinksEventListeners(); this._addLinksEventListeners(); @@ -788,6 +836,11 @@ class ComboBox extends UI5Element implements IFormInputElement { this._lastSelectedValue = this.selectedValue; } + if (!this.open && !this.loading && this._getItems().length === 0) { + this._loadingDelegate.fireOnDropdownOpen(); + this._isArrowClicked = true; + } + this._toggleRespPopover(); } @@ -829,12 +882,14 @@ class ComboBox extends UI5Element implements IFormInputElement { } this.fireDecoratorEvent("input"); + this._isArrowClicked = false; + this._loadingDelegate.fireOnInput(); if (isPhone()) { return; } - if (!this._filteredItems.length || value === "") { + if (!this.loading && (!this._filteredItems.length || value === "")) { this._closeRespPopover(); } else { this._openRespPopover(); @@ -978,6 +1033,9 @@ class ComboBox extends UI5Element implements IFormInputElement { } _handleArrowDown(e: KeyboardEvent, indexOfItem: number) { + if (this.loading) { + return; + } this._selectionTrigger = "Keyboard"; const isOpen = this.open; @@ -1110,6 +1168,7 @@ class ComboBox extends UI5Element implements IFormInputElement { e.preventDefault(); this._resetFilter(); + this._loadingDelegate.fireOnDropdownOpen(); this._toggleRespPopover(); const selectedItem = allItems.find(item => { @@ -1201,6 +1260,9 @@ class ComboBox extends UI5Element implements IFormInputElement { _click() { if (isPhone() && !this.readonly) { + if (!this.loading && this._getItems().length === 0) { + this._loadingDelegate.fireOnDropdownOpen(); + } this._openRespPopover(); } } @@ -1748,7 +1810,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 +1848,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/features/ComboBoxLazyLoading.ts b/packages/main/src/features/ComboBoxLazyLoading.ts new file mode 100644 index 0000000000000..56c45923d3dcb --- /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"; + +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/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..92967170bb3f1 100644 --- a/packages/main/test/pages/ComboBox.html +++ b/packages/main/test/pages/ComboBox.html @@ -191,30 +191,29 @@
- Toggle Loading State + Lazy loading using the 'load-items' event

- - - - - - Toggle Loading + + Clear items
- Lazy Loading - example does not work on IE11 + Lazy loading using the 'load-items' event (with value state message)

- + +
Custom error.
+
+ Clear items
- Lazy Loading with filter='None' + Lazy loading using the 'input' event and the entered text for creating items

@@ -613,40 +612,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..83988e19baaff --- /dev/null +++ b/packages/website/docs/_samples/main/ComboBox/LazyLoading/LazyLoading.md @@ -0,0 +1,29 @@ +import html from '!!raw-loader!./sample.html'; +import js from '!!raw-loader!./main.js'; +import react from '!!raw-loader!./sample.tsx'; + +import html2 from '!!raw-loader!./sample2.html'; +import js2 from '!!raw-loader!./main2.js'; +import react2 from '!!raw-loader!./sample2.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. + + + +#### Load all on open, filter on the client + +Items are fetched once, when the picker opens with no items or when user starts typing. The request is not aborted, so typing while it is in flight does not cancel it - the default filter narrows the loaded list on the client once the data arrives. + + + +#### 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/main2.js b/packages/website/docs/_samples/main/ComboBox/LazyLoading/main2.js new file mode 100644 index 0000000000000..6925a6d6a3523 --- /dev/null +++ b/packages/website/docs/_samples/main/ComboBox/LazyLoading/main2.js @@ -0,0 +1,39 @@ +import "@ui5/webcomponents/dist/ComboBox.js"; +import "@ui5/webcomponents/dist/ComboBoxItem.js"; + +const cb2 = document.getElementById("cbWait"); +const COUNTRIES2 = ["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 network request that returns the full list after a delay. +// This request is intentionally NOT abortable - once started, it runs to completion. +const fetchAllCountries = () => new Promise((resolve) => { + setTimeout(() => resolve(COUNTRIES2.slice()), 1500); +}); + +// The default filter is left in place, so the ComboBox filters the loaded items on the +// client as the user types. We only fetch once - when the picker opens with no items or when user starts typing. +// The request is not abortable, so typing while it is in flight does NOT cancel it. +// When it resolves, the already-typed value filters the list client-side. +cb2.addEventListener("load-items", async (e) => { + if (e.detail.reason === "input" && cb2.loading) { + return; + } + + cb2.loading = true; + + const matches = await fetchAllCountries(); + matches.forEach(country => { + const item = document.createElement("ui5-cb-item"); + item.setAttribute("text", country); + cb2.appendChild(item); + }); + cb2.loading = false; +}); 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/sample2.html b/packages/website/docs/_samples/main/ComboBox/LazyLoading/sample2.html new file mode 100644 index 0000000000000..492bee6a4fa65 --- /dev/null +++ b/packages/website/docs/_samples/main/ComboBox/LazyLoading/sample2.html @@ -0,0 +1,21 @@ + + + + + + + + Sample + + + + + + + + + + + + + diff --git a/packages/website/docs/_samples/main/ComboBox/LazyLoading/sample2.tsx b/packages/website/docs/_samples/main/ComboBox/LazyLoading/sample2.tsx new file mode 100644 index 0000000000000..cfc16506fb445 --- /dev/null +++ b/packages/website/docs/_samples/main/ComboBox/LazyLoading/sample2.tsx @@ -0,0 +1,62 @@ +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 } 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); + + // Simulates a network request that returns the full list after a delay. + // This request is intentionally NOT abortable - once started, it runs to completion. + const fetchAllCountries = () => + new Promise((resolve) => { + setTimeout(() => resolve(COUNTRIES.slice()), 1500); + }); + + // The default filter is left in place, so the ComboBox filters the loaded items on the + // client as the user types. We only fetch once - when the picker opens with no items or when + // the user starts typing. The request is not abortable, so typing while it is in flight does + // NOT cancel it. When it resolves, the already-typed value filters the list client-side. + const handleLoadItems = useCallback(async (e: UI5CustomEvent) => { + if (e.detail.reason === "input" && (e.target as unknown as ComboBoxClass).loading) { + return; + } + + setLoading(true); + + const matches = await fetchAllCountries(); + setItems(matches); + setLoading(false); + }, []); + + return ( + + {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; From 0cb787c80e1602be5a4eae259fb806e2c840d094 Mon Sep 17 00:00:00 2001 From: Ivanova Terzieva Date: Mon, 17 Aug 2026 15:43:22 +0300 Subject: [PATCH 2/2] chore: prevent keyboard interaction with arrow keys while loading items --- packages/main/src/ComboBox.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/main/src/ComboBox.ts b/packages/main/src/ComboBox.ts index ca39b539a1498..b3b491db1cb9c 100644 --- a/packages/main/src/ComboBox.ts +++ b/packages/main/src/ComboBox.ts @@ -1036,6 +1036,7 @@ class ComboBox extends UI5Element implements IFormInputElement { if (this.loading) { return; } + this._selectionTrigger = "Keyboard"; const isOpen = this.open; @@ -1058,6 +1059,10 @@ class ComboBox extends UI5Element implements IFormInputElement { } _handleArrowUp(e: KeyboardEvent, indexOfItem: number) { + if (this.loading) { + return; + } + this._selectionTrigger = "Keyboard"; const isOpen = this.open; @@ -1122,7 +1127,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); }