Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions packages/main/cypress/specs/ComboBox.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ComboBox onSelectionChange={selectionChangeSpy}>
<ComboBoxItem text="Bulgaria" value="bg"></ComboBoxItem>
<ComboBoxItem text="Brazil" value="br"></ComboBoxItem>
<ComboBoxItem text="China" value="ch"></ComboBoxItem>
<ComboBoxItem text="Germany" value="de"></ComboBoxItem>
</ComboBox>
);

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(
<>
Expand Down Expand Up @@ -3129,3 +3179,160 @@ describe("Validation inside a form", () => {
.should("have.been.calledOnce");
});
});

describe("SelectedValue API", () => {
it("should clear selectedValue when clear icon is clicked", () => {
cy.mount(
<ComboBox value="Germany" selectedValue="DE" showClearIcon>
<ComboBoxItem text="Austria" value="AT"></ComboBoxItem>
<ComboBoxItem text="Germany" value="DE"></ComboBoxItem>
<ComboBoxItem text="France" value="FR"></ComboBoxItem>
</ComboBox>
);

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(
<ComboBox>
<ComboBoxItem text="John Smith" additionalText="Sales" value="emp-101"></ComboBoxItem>
<ComboBoxItem text="John Smith" additionalText="Engineering" value="emp-205"></ComboBoxItem>
<ComboBoxItem text="John Smith" additionalText="Marketing" value="emp-342"></ComboBoxItem>
</ComboBox>
);

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(
<form id="test-form">
<ComboBox name="country" value="Germany" selectedValue="DE">
<ComboBoxItem text="Austria" value="AT"></ComboBoxItem>
<ComboBoxItem text="Germany" value="DE"></ComboBoxItem>
<ComboBoxItem text="France" value="FR"></ComboBoxItem>
</ComboBox>
</form>
);

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(
<form id="test-form">
<ComboBox name="country" value="Germany">
<ComboBoxItem text="Austria"></ComboBoxItem>
<ComboBoxItem text="Germany"></ComboBoxItem>
<ComboBoxItem text="France"></ComboBoxItem>
</ComboBox>
</form>
);

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");
});
});

it("should populate input value from selectedValue on initial render", () => {
cy.mount(
<ComboBox selectedValue="DE">
<ComboBoxItem text="Austria" value="AT"></ComboBoxItem>
<ComboBoxItem text="Germany" value="DE"></ComboBoxItem>
<ComboBoxItem text="France" value="FR"></ComboBoxItem>
</ComboBox>
);

cy.get("[ui5-combobox]")
.should("have.prop", "value", "Germany")
.should("have.prop", "selectedValue", "DE");

cy.get("[ui5-cb-item]").eq(1)
.should("have.prop", "selected", true);
});

it("should select correct item by selectedValue when multiple items have same text", () => {
cy.mount(
<ComboBox selectedValue="emp-205">
<ComboBoxItem text="John Smith" additionalText="Sales" value="emp-101"></ComboBoxItem>
<ComboBoxItem text="John Smith" additionalText="Engineering" value="emp-205"></ComboBoxItem>
<ComboBoxItem text="John Smith" additionalText="Marketing" value="emp-342"></ComboBoxItem>
</ComboBox>
);

cy.get("[ui5-combobox]")
.should("have.prop", "value", "John Smith")
.should("have.prop", "selectedValue", "emp-205");

// The second item (Engineering) should be selected, not the first
cy.get("[ui5-cb-item]").eq(0).should("have.prop", "selected", false);
cy.get("[ui5-cb-item]").eq(1).should("have.prop", "selected", true);
cy.get("[ui5-cb-item]").eq(2).should("have.prop", "selected", false);
});
});
80 changes: 78 additions & 2 deletions packages/main/src/ComboBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -457,6 +477,7 @@ class ComboBox extends UI5Element implements IFormInputElement {
_lastValue: string;
_selectedItemText = "";
_userTypedValue = "";
_useSelectedValue = false;
_valueStateLinks: Array<HTMLElement> = [];
_composition?: InputComposition;
@i18n("@ui5/webcomponents")
Expand All @@ -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;
}

Expand Down Expand Up @@ -512,6 +542,10 @@ class ComboBox extends UI5Element implements IFormInputElement {
this.valueStateOpen = false;
}

if (this.selectedValue) {
this._useSelectedValue = true;
}

this._selectMatchingItem();
this._initialRendering = false;

Expand Down Expand Up @@ -841,12 +875,14 @@ class ComboBox extends UI5Element implements IFormInputElement {
if (this.open) {
this._itemFocused = true;
this.value = isGroupItem ? "" : currentItem.text!;
this.selectedValue = isGroupItem ? undefined : 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;
}

Expand Down Expand Up @@ -1162,7 +1198,13 @@ class ComboBox extends UI5Element implements IFormInputElement {
const matchingItems: Array<IComboBoxItem> = 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];
}
}
Expand All @@ -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;

Expand All @@ -1197,10 +1244,27 @@ 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 === "" || 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 === "" || this.value === i.text));
return;
}
itemToBeSelected = item.text === this.value ? item : undefined;
}
}
});

// When selectedValue matched an item but value is empty (initial render), populate value from the item's text
if (itemToBeSelected && this._initialRendering && this.value === "") {
this.value = itemToBeSelected.text || "";
}

this._filteredItems = this._filteredItems.map(item => {
if (!isInstanceOfComboBoxItemGroup(item)) {
item.selected = item === itemToBeSelected;
Expand All @@ -1214,6 +1278,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) {
Expand Down Expand Up @@ -1266,6 +1336,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", {
Expand Down Expand Up @@ -1308,6 +1381,9 @@ class ComboBox extends UI5Element implements IFormInputElement {
}

this.value = "";
if (this._useSelectedValue) {
this.selectedValue = undefined;
}
this.fireDecoratorEvent("input");

if (this._isPhone) {
Expand Down
Loading
Loading