;
_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 &&
- <>
-
- 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)
+
+
+
+ Log:
+
+
+
+
+
+
+
+
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
+
+
+
+