diff --git a/packages/main/src/ComboBox.ts b/packages/main/src/ComboBox.ts index c7bc268c62c0..3288ddbbf033 100644 --- a/packages/main/src/ComboBox.ts +++ b/packages/main/src/ComboBox.ts @@ -60,6 +60,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, @@ -501,6 +505,8 @@ class ComboBox extends UI5Element implements IFormInputElement { icon!: Slot; _initialRendering = true; + _prevLoading: boolean; + _announceLoading?: boolean; _itemFocused = false; // used only for Safari fix (check onAfterRendering) _autocomplete = false; @@ -546,6 +552,7 @@ 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._prevLoading = this.loading; } onBeforeRendering() { @@ -596,6 +603,16 @@ class ComboBox extends UI5Element implements IFormInputElement { }); this._selectMatchingItem(); + + if (!this._initialRendering) { + if (!this._prevLoading && this.loading) { + this._announceLoading = true; + } else if (this._prevLoading && !this.loading) { + this._announceLoading = false; + } + } + + this._prevLoading = this.loading; this._initialRendering = false; this.style.setProperty("--_ui5-input-icons-count", `${this.iconsCount}`); @@ -616,6 +633,15 @@ class ComboBox extends UI5Element implements IFormInputElement { this.storeResponsivePopoverWidth(); + if (this._announceLoading) { + announce(ComboBox.i18nBundle.getText(COMBOBOX_LOADING), InvisibleMessageMode.Polite); + } else if (this._announceLoading === false) { + const count = this._getItems().filter(item => !item.isGroupItem && item._isVisible).length; + const itemsLoadedMessage = count === 1 ? ComboBox.i18nBundle.getText(COMBOBOX_LOADED_ITEM) : ComboBox.i18nBundle.getText(COMBOBOX_LOADED_ITEMS, count); + announce(`${ComboBox.i18nBundle.getText(COMBOBOX_LOADED)}. ${itemsLoadedMessage}`, InvisibleMessageMode.Polite); + } + this._announceLoading = undefined; + if (!arraysAreEqual(this._valueStateLinks, this.linksInAriaValueStateHiddenText)) { this._removeLinksEventListeners(); this._addLinksEventListeners(); diff --git a/packages/main/src/ComboBoxPopoverTemplate.tsx b/packages/main/src/ComboBoxPopoverTemplate.tsx index b4dd0ebcc93d..61cebaf5a033 100644 --- a/packages/main/src/ComboBoxPopoverTemplate.tsx +++ b/packages/main/src/ComboBoxPopoverTemplate.tsx @@ -12,6 +12,8 @@ import generateHighlightedMarkupFirstMatch from "@ui5/webcomponents-base/dist/ut import type ComboBox from "./ComboBox.js"; export default function ComboBoxPopoverTemplate(this: ComboBox) { + const loadingOnDesktopWithValueState = this.loading && !this._isPhone && this.hasValueState; + const loadingDelay = 100; return ( <> - {this.loading && - - } + {this._isPhone && + <> +
+
+ + {this._headerTitleText} + +
- {!this.loading && this._isPhone && - <> -
-
- - {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 ; + })} + +
-
- - { 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.hasValueStateText && -
- - { this.open && valueStateMessage.call(this) } -
- } - + {this.hasValueStateText && +
+ + {this.open && valueStateMessage.call(this)} +
+ } + } {!this._isPhone && this.hasValueStateText && -
- - { this.open && valueStateMessage.call(this) } -
+
+ + {this.open && valueStateMessage.call(this)} +
} - {!this.loading && !!this._filteredItems.length && - - { this._filteredItems.map(item => )} - + {(this._isPhone || !this.hasValueState) && this.loading && } + {((!this.loading && !!this._filteredItems.length) || loadingOnDesktopWithValueState) && + + {loadingOnDesktopWithValueState && } + {!this.loading && this._filteredItems.map(item => )} + } {this._isPhone && - + } {this.shouldOpenValueStateMessagePopover && - -
- - { valueStateMessage.call(this) } -
-
+ +
+ + {valueStateMessage.call(this)} +
+
} ); @@ -158,7 +158,7 @@ export default function ComboBoxPopoverTemplate(this: ComboBox) { function valueStateMessage(this: ComboBox) { return ( <> - { this.shouldDisplayDefaultValueStateMessage ? this.valueStateDefaultText : } + {this.shouldDisplayDefaultValueStateMessage ? this.valueStateDefaultText : } ); } diff --git a/packages/main/src/MultiComboBox.ts b/packages/main/src/MultiComboBox.ts index 085142308936..4383e77a6dd1 100644 --- a/packages/main/src/MultiComboBox.ts +++ b/packages/main/src/MultiComboBox.ts @@ -61,6 +61,8 @@ import type { IFormInputElement } from "@ui5/webcomponents-base/dist/features/In import MultiComboBoxItem, { isInstanceOfMultiComboBoxItem } from "./MultiComboBoxItem.js"; import MultiComboBoxItemGroup, { isInstanceOfMultiComboBoxItemGroup } from "./MultiComboBoxItemGroup.js"; import ListItemGroup from "./ListItemGroup.js"; +import InvisibleMessageMode from "@ui5/webcomponents-base/dist/types/InvisibleMessageMode.js"; +import announce from "@ui5/webcomponents-base/dist/util/InvisibleMessage.js"; import Tokenizer, { getTokensCountText } from "./Tokenizer.js"; import type { TokenizerTokenDeleteEventDetail } from "./Tokenizer.js"; import Token from "./Token.js"; @@ -97,6 +99,10 @@ import { MCB_SELECTED_ITEMS, INPUT_CLEAR_ICON_ACC_NAME, FORM_MIXED_TEXTFIELD_REQUIRED, + MULTICOMBOBOX_LOADING, + MULTICOMBOBOX_LOADED, + MULTICOMBOBOX_LOADED_ITEMS, + MULTICOMBOBOX_LOADED_ITEM, } from "./generated/i18n/i18n-defaults.js"; // Templates @@ -148,6 +154,10 @@ type MultiComboBoxValueStateChangeEventDetail = { valueState: `${ValueState}`, } +type MultiComboBoxLoadingStart = { + shouldOpenPicker: boolean; +} + /** * @class * @@ -262,6 +272,13 @@ type MultiComboBoxValueStateChangeEventDetail = { cancelable: true, }) +/* + * @public + */ +@event("load-started", { + bubbles: true, +}) + /** * Fired before the value state of the component is updated internally. * The event is preventable, meaning that if it's default action is @@ -281,6 +298,7 @@ class MultiComboBox extends UI5Element implements IFormInputElement { input: void, open: void, close: void, + "load-started": MultiComboBoxLoadingStart, "selection-change": MultiComboBoxSelectionChangeEventDetail, "value-state-change": MultiComboBoxValueStateChangeEventDetail, } @@ -590,6 +608,8 @@ class MultiComboBox extends UI5Element implements IFormInputElement { selectedItems: Array; _valueStateLinks: Array; _composition?: InputComposition; + _prevLoading: boolean; + _announceLoading?: boolean; _suppressNextLiveChange: boolean; // prevent unwanted live change events during IME composition @i18n("@ui5/webcomponents") static i18nBundle: I18nBundle; @@ -643,6 +663,7 @@ class MultiComboBox extends UI5Element implements IFormInputElement { this.currentItemIdx = -1; this._valueStateLinks = []; this._suppressNextLiveChange = false; + this._prevLoading = this.loading; } onEnterDOM() { @@ -722,6 +743,9 @@ class MultiComboBox extends UI5Element implements IFormInputElement { } togglePopoverByDropdownIcon() { + if (!this.open && !this.loading && this._getItems().length === 0) { + this.fireDecoratorEvent("load-started", { shouldOpenPicker: false }); + } this._shouldFilterItems = false; this.open = !this.open; this.tokenizerOpen = false; @@ -757,6 +781,8 @@ class MultiComboBox extends UI5Element implements IFormInputElement { return; } + this.fireDecoratorEvent("load-started", { shouldOpenPicker: true }); + const input = e.target as HTMLInputElement; const value: string = input.value; const filteredItems: Array = this._filterItems(value); @@ -795,7 +821,7 @@ class MultiComboBox extends UI5Element implements IFormInputElement { this.value = input.value; this._filteredItems = filteredItems; - if (!isPhone()) { + if (!isPhone() && !this.loading) { if (filteredItems.length === 0) { this.open = false; } else { @@ -1215,6 +1241,10 @@ class MultiComboBox extends UI5Element implements IFormInputElement { } _onItemKeydown(e: KeyboardEvent) { + if (this.filterSelected) { + return; + } + const isFirstItemGroup = this.list?.getSlottedNodes("items")[1] === e.target && this.list?.getSlottedNodes("items")[0].hasAttribute("ui5-li-group"); const isFirstItem = this.list?.getSlottedNodes("items")[0] === e.target || isFirstItemGroup; const isArrowUp = isUp(e) || isUpCtrl(e); @@ -1699,6 +1729,9 @@ class MultiComboBox extends UI5Element implements IFormInputElement { _click() { if (isPhone() && !this.readonly && !this._showMorePressed && !this._deleting) { this.open = true; + if (this._getItems().length === 0) { + this.fireDecoratorEvent("load-started", { shouldOpenPicker: true }); + } } this._showMorePressed = false; @@ -1853,6 +1886,13 @@ class MultiComboBox extends UI5Element implements IFormInputElement { const autoCompletedChars = input && (input.selectionEnd || 0) - (input.selectionStart || 0); const value = input && input.value; + if (!this._prevLoading && this.loading) { + this._announceLoading = true; + } else if (this._prevLoading && !this.loading) { + this._announceLoading = false; + } + this._prevLoading = this.loading; + if (this.open) { const list = this._getList(); const selectedListItemsCount = this.items.filter(item => item.selected).length; @@ -1922,6 +1962,15 @@ class MultiComboBox extends UI5Element implements IFormInputElement { this._addLinksEventListeners(); this._valueStateLinks = this.linksInAriaValueStateHiddenText; } + + if (this._announceLoading) { + announce(MultiComboBox.i18nBundle.getText(MULTICOMBOBOX_LOADING), InvisibleMessageMode.Polite); + } else if (this._announceLoading === false) { + const count = this._getItems().filter(item => item._isVisible).length; + const itemsLoadedMessage = count === 1 ? MultiComboBox.i18nBundle.getText(MULTICOMBOBOX_LOADED_ITEM) : MultiComboBox.i18nBundle.getText(MULTICOMBOBOX_LOADED_ITEMS, count); + announce(`${MultiComboBox.i18nBundle.getText(MULTICOMBOBOX_LOADED)}. ${itemsLoadedMessage}`, InvisibleMessageMode.Polite); + } + this._announceLoading = undefined; } get _isPhone() { @@ -2372,8 +2421,8 @@ class MultiComboBox extends UI5Element implements IFormInputElement { const remSizeIxPx = parseInt(getComputedStyle(document.documentElement).fontSize); return { popoverValueStateMessage: { - "width": `${this._listWidth || 0}px`, - "display": this._listWidth === 0 ? "none" : "inline-block", + "width": this._listWidth ? `${this._listWidth}px` : "100%", + "display": "inline-block", }, popoverHeader: { "max-width": isPhone() ? "100%" : `22rem`, @@ -2392,6 +2441,7 @@ export default MultiComboBox; export type { IMultiComboBoxItem, + MultiComboBoxLoadingStart, MultiComboBoxSelectionChangeEventDetail, MultiComboBoxValueStateChangeEventDetail, }; diff --git a/packages/main/src/MultiComboBoxPopoverTemplate.tsx b/packages/main/src/MultiComboBoxPopoverTemplate.tsx index 0393b075a1f3..a630b8892145 100644 --- a/packages/main/src/MultiComboBoxPopoverTemplate.tsx +++ b/packages/main/src/MultiComboBoxPopoverTemplate.tsx @@ -13,6 +13,8 @@ import CheckBox from "./CheckBox.js"; import Title from "./Title.js"; import BusyIndicator from "./BusyIndicator.js"; +const LOADING_DELAY = 100; + export default function MultiComboBoxPopoverTemplate(this: MultiComboBox) { return (<> - {this.loading && - - } - - {!this.loading && this._isPhone && <> -
-
- - {this._headerTitleText} - -
-
- - {this._filteredItems.map(item => ( - - ))} - - -
-
{this.hasValueStateMessage && -
- - {this.open && valueStateMessage.call(this)} -
- } - - {selectAllWrapper.call(this)} - } + {this._isPhone && dialogHeader.call(this)} - {!this.loading && !this._isPhone && <> - {this.hasValueStateMessage && -
- - {this.open && valueStateMessage.call(this)} -
- } + {!this._isPhone && popoverHeader.call(this)} - {selectAllWrapper.call(this)} - } + {popoverContent.call(this)} - {!this.loading && this.filterSelected ? - - {this.selectedItems.map(item => )} - - : !this.loading && - - {this._filteredItems.map(item => )} - - } - - {this._isPhone && - - } + {this._isPhone && dialogFooter.call(this)}
{this.hasValueStateMessage && @@ -161,3 +85,97 @@ function selectAllWrapper(this: MultiComboBox) { ); } } + +function dialogHeader(this: MultiComboBox) { + return <> +
+
+ + {this._headerTitleText} + +
+
+ + {this._filteredItems.map(item => ( + + ))} + + +
+
+ {this.hasValueStateMessage && +
+ + {this.open && valueStateMessage.call(this)} +
+ } + ; +} + +function popoverHeader(this: MultiComboBox) { + return this.hasValueStateMessage && +
+ + {this.open && valueStateMessage.call(this)} +
; +} + +function dialogFooter(this: MultiComboBox) { + return <> + + ; +} + +function popoverContent(this: MultiComboBox) { + if (this.loading && (this._isPhone || !this.hasValueState)) { + return ; + } + + return <> + {!this.loading && selectAllWrapper.call(this)} + + {this.loading && !this._isPhone && this.hasValueState && } + {!this.loading && + (this.filterSelected + ? this.selectedItems.map(item => ) + : this._filteredItems.map(item => ) + )} + + ; +} diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties index b43e02c4c878..9cc79b6a6038 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -444,6 +444,30 @@ 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 MultiComboBox starts loading items +MULTICOMBOBOX_LOADING=Loading data + +#XACT: ARIA announcement when MultiComboBox ends loading items +MULTICOMBOBOX_LOADED=Data loaded + +#XACT: ARIA announcement when MultiComboBox finishes loading, {0} is the number of loaded items +MULTICOMBOBOX_LOADED_ITEMS={0} results are available + +#XACT: ARIA announcement when MultiComboBox finishes loading and there is 1 item +MULTICOMBOBOX_LOADED_ITEM=1 result is available + +#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 8e0921c82728..b7a685c7b95d 100644 --- a/packages/main/src/themes/ComboBoxPopover.css +++ b/packages/main/src/themes/ComboBoxPopover.css @@ -17,4 +17,9 @@ [ui5-responsive-popover] [ui5-input] { width: 100%; +} + + +.ui5-combobox-items-list:has(.ui5-combobox-busy[active]) { + min-height: 1.6rem; } \ No newline at end of file diff --git a/packages/main/src/themes/MultiComboBoxPopover.css b/packages/main/src/themes/MultiComboBoxPopover.css index 21115cc5c4a9..638ceefc7069 100644 --- a/packages/main/src/themes/MultiComboBoxPopover.css +++ b/packages/main/src/themes/MultiComboBoxPopover.css @@ -57,3 +57,7 @@ [ui5-responsive-popover] [ui5-input] { width: 100%; } + +.ui5-multi-combobox-all-items-list:has(.ui5-multi-combobox-busy[active]) { + min-height: 1.6rem; +} diff --git a/packages/main/test/pages/ComboBox.html b/packages/main/test/pages/ComboBox.html index 0d2e49ee54cf..9a66281d2222 100644 --- a/packages/main/test/pages/ComboBox.html +++ b/packages/main/test/pages/ComboBox.html @@ -191,17 +191,15 @@
- Toggle Loading State + Loading State

- - - - + +
Custom error value state message.
- Toggle Loading + Clear items
@@ -518,7 +516,7 @@

ComboBox Composition

Dialog Header Title from Label