diff --git a/packages/main/cypress/specs/ComboBox.cy.tsx b/packages/main/cypress/specs/ComboBox.cy.tsx index df370ad08172..629ec0e8f897 100644 --- a/packages/main/cypress/specs/ComboBox.cy.tsx +++ b/packages/main/cypress/specs/ComboBox.cy.tsx @@ -2626,6 +2626,56 @@ describe("Event firing", () => { })); }); + it("fires selection-change when selectedValue changes via keyboard and input", () => { + const selectionChangeSpy = cy.stub().as("selectionChangeSpy"); + cy.mount( + + + + + + + ); + + cy.get("[ui5-combobox]") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.realPress("ArrowDown"); + cy.get("[ui5-combobox]") + .should("have.attr", "selected-value", "bg") + .should("have.attr", "value", "Bulgaria"); + + cy.realPress("ArrowDown"); + cy.get("[ui5-combobox]") + .should("have.attr", "selected-value", "br") + .should("have.attr", "value", "Brazil"); + + cy.get("@selectionChangeSpy") + .should("be.calledTwice"); + cy.get("@selectionChangeSpy").should('have.been.calledWithMatch', Cypress.sinon.match(event => { + return event.detail.item.text === "Brazil"; + })); + + cy.get("[ui5-combobox]") + .shadow() + .find("input") + .realClick() + .realPress("Backspace"); + + cy.get("[ui5-combobox]") + .should("have.attr", "value", "Brazi") + .should("not.have.attr", "selected-value"); + + cy.get("@selectionChangeSpy") + .should("be.calledThrice"); + + cy.get("@selectionChangeSpy").should('have.been.calledWithMatch', Cypress.sinon.match(event => { + return event.detail.item === null; + })); + }); + it("should check clear icon events", () => { cy.mount( <> @@ -3129,3 +3179,124 @@ describe("Validation inside a form", () => { .should("have.been.calledOnce"); }); }); + +describe("SelectedValue API", () => { + it("should clear selectedValue when clear icon is clicked", () => { + cy.mount( + + + + + + ); + + cy.get("[ui5-combobox]") + .as("combo") + .should("have.attr", "selected-value", "DE") + .should("have.attr", "value", "Germany"); + + // Click the clear icon + cy.get("@combo") + .shadow() + .find(".ui5-input-clear-icon-wrapper") + .realClick(); + + cy.get("@combo") + .should("have.attr", "value", "") + .should("not.have.attr", "selected-value"); + }); + + it("should correctly select items with same text but different values", () => { + cy.mount( + + + + + + ); + + cy.get("[ui5-combobox]") + .as("combo") + .invoke('on', 'ui5-selection-change', cy.spy().as('selectionChangeSpy')); + + // Open dropdown and click first John Smith (Sales) + cy.get("@combo") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("[ui5-cb-item]").eq(0).realClick(); + + cy.get("@combo") + .should("have.attr", "value", "John Smith") + .should("have.attr", "selected-value", "emp-101"); + + // Open dropdown and click second John Smith (Engineering) + cy.get("@combo") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("[ui5-cb-item]").eq(1).realClick(); + + cy.get("@combo") + .should("have.attr", "value", "John Smith") + .should("have.attr", "selected-value", "emp-205"); + + cy.get("@selectionChangeSpy").should("have.been.calledTwice"); + }); + + it("should return item value in formFormattedValue for form submission", () => { + cy.mount( +
+ + + + + +
+ ); + + cy.get("[ui5-combobox]") + .as("combo") + .then(($combo) => { + const comboBox = $combo[0] as ComboBox; + // formFormattedValue should return the item's value, not the display text + expect(comboBox.formFormattedValue).to.equal("DE"); + }); + + // Change selection to France + cy.get("@combo") + .shadow() + .find("[ui5-icon]") + .realClick(); + + cy.get("[ui5-cb-item]").eq(2).realClick(); + + cy.get("@combo") + .then(($combo) => { + const comboBox = $combo[0] as ComboBox; + expect(comboBox.formFormattedValue).to.equal("FR"); + }); + }); + + it("should fallback to display text in formFormattedValue when item has no value", () => { + cy.mount( +
+ + + + + +
+ ); + + cy.get("[ui5-combobox]") + .as("combo") + .then(($combo) => { + const comboBox = $combo[0] as ComboBox; + // Without item values, formFormattedValue should return the display text + expect(comboBox.formFormattedValue).to.equal("Germany"); + }); + }); +}); diff --git a/packages/main/src/ComboBox.ts b/packages/main/src/ComboBox.ts index a5c814721e51..fdaf7ed03ac5 100644 --- a/packages/main/src/ComboBox.ts +++ b/packages/main/src/ComboBox.ts @@ -100,6 +100,7 @@ const SKIP_ITEMS_SIZE = 10; interface IComboBoxItem extends UI5Element { text?: string, headerText?: string, + value?: string, focused: boolean, isGroupItem?: boolean, selected?: boolean, @@ -235,6 +236,25 @@ class ComboBox extends UI5Element implements IFormInputElement { @property() value = ""; + /** + * Defines the selected item's value. + * + * Use this property together with the `value` property on `ui5-cb-item` to: + * - Select an item programmatically by its unique identifier + * - Handle items with identical display text but different underlying values + * - Submit machine-readable values in forms (the `value` property is submitted instead of the display text) + * + * When set, the ComboBox finds and selects the item whose `value` matches this property + * and whose `text` matches the ComboBox's `value` (display text). + * + * **Note:** This replaces the deprecated `selected` property on `ui5-cb-item`. + * @default undefined + * @public + * @since 2.19.0 + */ + @property() + selectedValue?: string; + /** * Determines the name by which the component will be identified upon submission in an HTML form. * @@ -457,6 +477,7 @@ class ComboBox extends UI5Element implements IFormInputElement { _lastValue: string; _selectedItemText = ""; _userTypedValue = ""; + _useSelectedValue: boolean = false; _valueStateLinks: Array = []; _composition?: InputComposition; @i18n("@ui5/webcomponents") @@ -476,6 +497,15 @@ class ComboBox extends UI5Element implements IFormInputElement { } get formFormattedValue() { + // Find the selected item + const selectedItem = this._getItems().find(item => item.selected && !item.isGroupItem) as ComboBoxItem | undefined; + + // If selected item has a value property, return it (like Select does) + if (selectedItem && selectedItem.value !== undefined) { + return selectedItem.value; + } + + // Fallback to display text (backward compatibility) return this.value; } @@ -512,6 +542,10 @@ class ComboBox extends UI5Element implements IFormInputElement { this.valueStateOpen = false; } + if (this.selectedValue) { + this._useSelectedValue = true; + } + this._selectMatchingItem(); this._initialRendering = false; @@ -841,12 +875,14 @@ class ComboBox extends UI5Element implements IFormInputElement { if (this.open) { this._itemFocused = true; this.value = isGroupItem ? "" : currentItem.text!; + this.selectedValue = isGroupItem ? "" : currentItem?.value; this.focused = false; currentItem.focused = true; } else { this.focused = true; this.value = isGroupItem ? nextItem.text! : currentItem.text!; + this.selectedValue = currentItem.value; currentItem.focused = false; } @@ -1162,7 +1198,13 @@ class ComboBox extends UI5Element implements IFormInputElement { const matchingItems: Array = this._startsWithMatchingItems(current); if (matchingItems.length) { - const exactMatch = matchingItems.find(item => item.text === current); + let exactMatch; + if (this._useSelectedValue) { + exactMatch = matchingItems.find(item => item.value === (currentlyFocusedItem?.value || this.selectedValue) && item.text === current); + } else { + exactMatch = matchingItems.find(item => item.text === current); + } + return exactMatch ?? matchingItems[0]; } } @@ -1173,11 +1215,16 @@ class ComboBox extends UI5Element implements IFormInputElement { this.inner.value = value; this.inner.setSelectionRange(filterValue.length, value.length); this.value = value; + + if (this._useSelectedValue) { + this.selectedValue = item.value; + } } _selectMatchingItem() { const currentlyFocusedItem = this.items.find(item => item.focused); const shouldSelectionBeCleared = currentlyFocusedItem && currentlyFocusedItem.isGroupItem; + const valueToMatch = currentlyFocusedItem?.value ?? this.selectedValue; let itemToBeSelected: IComboBoxItem | undefined; let previouslySelectedItem: IComboBoxItem | undefined; @@ -1197,7 +1244,19 @@ class ComboBox extends UI5Element implements IFormInputElement { this._filteredItems.forEach(item => { if (!shouldSelectionBeCleared && !itemToBeSelected) { - itemToBeSelected = ((!item.isGroupItem && (item.text === this.value)) ? item : item?.items?.find(i => i.text === this.value)); + if (isInstanceOfComboBoxItemGroup(item)) { + if (this._useSelectedValue) { + itemToBeSelected = item.items.find(i => i.value === valueToMatch && this.value === i.text); + } else { + itemToBeSelected = item.items?.find(i => i.text === this.value); + } + } else { + if (this._useSelectedValue) { + itemToBeSelected = this.items.find(i => i.value === valueToMatch && this.value === i.text); + return; + } + itemToBeSelected = item.text === this.value ? item : undefined; + } } }); @@ -1214,6 +1273,12 @@ class ComboBox extends UI5Element implements IFormInputElement { return item; }); + if (!itemToBeSelected && this._useSelectedValue) { + this.selectedValue = undefined; + } else { + this.selectedValue = itemToBeSelected?.value; + } + const noUserInteraction = !this.focused && !this._isKeyNavigation && !this._selectionPerformed && !this._iconPressed; // Skip firing "selection-change" event if this is initial rendering or if there has been no user interaction yet if (this._initialRendering || noUserInteraction) { @@ -1266,6 +1331,9 @@ class ComboBox extends UI5Element implements IFormInputElement { } this.value = this._selectedItemText; + if (this._useSelectedValue) { + this.selectedValue = item.value; + } if (!item.selected) { this.fireDecoratorEvent("selection-change", { @@ -1308,6 +1376,9 @@ class ComboBox extends UI5Element implements IFormInputElement { } this.value = ""; + if (this._useSelectedValue) { + this.selectedValue = undefined; + } this.fireDecoratorEvent("input"); if (this._isPhone) { diff --git a/packages/main/src/ComboBoxItem.ts b/packages/main/src/ComboBoxItem.ts index a59d7530ec98..253f633ccdd1 100644 --- a/packages/main/src/ComboBoxItem.ts +++ b/packages/main/src/ComboBoxItem.ts @@ -45,6 +45,30 @@ class ComboBoxItem extends ListItemBase implements IComboBoxItem { @property({ type: Boolean, noAttribute: true }) _isVisible = false; + /** + * Defines the value of the `ui5-cb-item`. + * + * Use this property to associate a unique identifier or machine-readable value with the item, + * separate from the display text. This enables: + * - Selecting items programmatically via `selectedValue` on the ComboBox + * - Submitting machine-readable values in forms + * - Distinguishing between items with identical display text + * + * **Example:** + * ```html + * + * + * + * + * ``` + * + * @default undefined + * @public + * @since 2.19.0 + */ + @property() + value?: string; + /** * Indicates whether the item is focssed * @protected @@ -55,6 +79,7 @@ class ComboBoxItem extends ListItemBase implements IComboBoxItem { /** * Indicates whether the item is selected * @protected + * @deprecated use value property of the item and selectedValue property of the ComboBox instead */ @property({ type: Boolean }) selected = false; diff --git a/packages/main/test/pages/ComboBox.html b/packages/main/test/pages/ComboBox.html index 27cf28276793..7d0fe039d9e1 100644 --- a/packages/main/test/pages/ComboBox.html +++ b/packages/main/test/pages/ComboBox.html @@ -27,7 +27,7 @@
Select country: - + @@ -322,6 +322,17 @@

ComboBox in Compact

+
+ ComboBox - items with same text but different values + + + + + + + +
+

ComboBox Composition

diff --git a/packages/website/docs/_components_pages/main/ComboBox/ComboBox.mdx b/packages/website/docs/_components_pages/main/ComboBox/ComboBox.mdx index a7564efccaf0..3087fbf3259c 100644 --- a/packages/website/docs/_components_pages/main/ComboBox/ComboBox.mdx +++ b/packages/website/docs/_components_pages/main/ComboBox/ComboBox.mdx @@ -8,6 +8,8 @@ import Filters from "../../../_samples/main/ComboBox/Filters/Filters.md"; import TwoColumnsLayout from "../../../_samples/main/ComboBox/TwoColumnsLayout/TwoColumnsLayout.md"; import Grouping from "../../../_samples/main/ComboBox/Grouping/Grouping.md"; import SuggestionsWrapping from "../../../_samples/main/ComboBox/SuggestionsWrapping/SuggestionsWrapping.md"; +import SelectedValue from "../../../_samples/main/ComboBox/SelectedValue/SelectedValue.md"; +import SameTextDifferentValues from "../../../_samples/main/ComboBox/SameTextDifferentValues/SameTextDifferentValues.md"; <%COMPONENT_OVERVIEW%> @@ -42,4 +44,16 @@ Grouping of items can be implented via the ui5-cb-group-item web component. ### Items Text Wrapping The sample demonstrates how the text of the items wrap when too long. - \ No newline at end of file + + +### Selected Value +Use the `value` property on items and `selected-value` on the ComboBox to decouple the display text from the underlying selection value. +This is useful for form submission, programmatic selection, and handling items with identical display text. + + + +### Items with Same Text but Different Values +When you have multiple items with identical display text (e.g., employees with the same name), use the `value` property to uniquely identify each item. +The `additional-text` property helps users distinguish between items visually. + + \ No newline at end of file diff --git a/packages/website/docs/_samples/main/ComboBox/SameTextDifferentValues/SameTextDifferentValues.md b/packages/website/docs/_samples/main/ComboBox/SameTextDifferentValues/SameTextDifferentValues.md new file mode 100644 index 000000000000..17798ecc59ab --- /dev/null +++ b/packages/website/docs/_samples/main/ComboBox/SameTextDifferentValues/SameTextDifferentValues.md @@ -0,0 +1,4 @@ +import html from '!!raw-loader!./sample.html'; +import js from '!!raw-loader!./main.js'; + + diff --git a/packages/website/docs/_samples/main/ComboBox/SameTextDifferentValues/main.js b/packages/website/docs/_samples/main/ComboBox/SameTextDifferentValues/main.js new file mode 100644 index 000000000000..5b4450886d02 --- /dev/null +++ b/packages/website/docs/_samples/main/ComboBox/SameTextDifferentValues/main.js @@ -0,0 +1,20 @@ +import "@ui5/webcomponents/dist/ComboBox.js"; +import "@ui5/webcomponents/dist/ComboBoxItem.js"; + +const combo = document.getElementById("employee-combo"); +const employeeId = document.getElementById("employee-id"); +const employeeName = document.getElementById("employee-name"); +const employeeDept = document.getElementById("employee-dept"); + +combo.addEventListener("selection-change", (event) => { + const item = event.detail.item; + if (item) { + employeeId.textContent = item.value; + employeeName.textContent = item.text; + employeeDept.textContent = item.additionalText; + } else { + employeeId.textContent = "-"; + employeeName.textContent = "-"; + employeeDept.textContent = "-"; + } +}); diff --git a/packages/website/docs/_samples/main/ComboBox/SameTextDifferentValues/sample.html b/packages/website/docs/_samples/main/ComboBox/SameTextDifferentValues/sample.html new file mode 100644 index 000000000000..aea438a5a03e --- /dev/null +++ b/packages/website/docs/_samples/main/ComboBox/SameTextDifferentValues/sample.html @@ -0,0 +1,33 @@ + + + + + + + + Sample + + + + + + + + + + + + + +
+
Employee ID: -
+
Name: -
+
Department: -
+
+ + + + + + + diff --git a/packages/website/docs/_samples/main/ComboBox/SelectedValue/SelectedValue.md b/packages/website/docs/_samples/main/ComboBox/SelectedValue/SelectedValue.md new file mode 100644 index 000000000000..17798ecc59ab --- /dev/null +++ b/packages/website/docs/_samples/main/ComboBox/SelectedValue/SelectedValue.md @@ -0,0 +1,4 @@ +import html from '!!raw-loader!./sample.html'; +import js from '!!raw-loader!./main.js'; + + diff --git a/packages/website/docs/_samples/main/ComboBox/SelectedValue/main.js b/packages/website/docs/_samples/main/ComboBox/SelectedValue/main.js new file mode 100644 index 000000000000..2de51c714b2c --- /dev/null +++ b/packages/website/docs/_samples/main/ComboBox/SelectedValue/main.js @@ -0,0 +1,14 @@ +import "@ui5/webcomponents/dist/ComboBox.js"; +import "@ui5/webcomponents/dist/ComboBoxItem.js"; + +const combo = document.getElementById("country-combo"); +const output = document.getElementById("selected-value"); + +combo.addEventListener("selection-change", (event) => { + const item = event.detail.item; + if (item) { + output.textContent = item.value || "(no value)"; + } else { + output.textContent = "(none)"; + } +}); diff --git a/packages/website/docs/_samples/main/ComboBox/SelectedValue/sample.html b/packages/website/docs/_samples/main/ComboBox/SelectedValue/sample.html new file mode 100644 index 000000000000..d62d1c5671df --- /dev/null +++ b/packages/website/docs/_samples/main/ComboBox/SelectedValue/sample.html @@ -0,0 +1,32 @@ + + + + + + + + Sample + + + + + + + + + + + + + + +
+ Selected value: DE +
+ + + + + + +