diff --git a/packages/main/cypress/specs/MultiComboBox.cy.tsx b/packages/main/cypress/specs/MultiComboBox.cy.tsx index 273b523ca49c..edcb77376853 100644 --- a/packages/main/cypress/specs/MultiComboBox.cy.tsx +++ b/packages/main/cypress/specs/MultiComboBox.cy.tsx @@ -1,3 +1,4 @@ +import "../../src/Assets.js"; import MultiComboBox from "../../src/MultiComboBox.js"; import MultiComboBoxItem from "../../src/MultiComboBoxItem.js"; import MultiComboBoxItemCustom from "../../src/MultiComboBoxItemCustom.js"; @@ -6,6 +7,7 @@ import ResponsivePopover from "../../src/ResponsivePopover.js"; import Button from "../../src/Button.js"; import Link from "../../src/Link.js"; import Input from "../../src/Input.js"; +import { setLanguage } from "@ui5/webcomponents-base/dist/config/Language.js"; import { MULTIINPUT_SHOW_MORE_TOKENS, TOKENIZER_ARIA_CONTAIN_ONE_TOKEN, TOKENIZER_ARIA_CONTAIN_SEVERAL_TOKENS, TOKENIZER_ARIA_CONTAIN_TOKEN, TOKENIZER_SHOW_ALL_ITEMS, VALUE_STATE_ERROR, VALUE_STATE_TYPE_ERROR, VALUE_STATE_TYPE_SUCCESS, VALUE_STATE_TYPE_WARNING, VALUE_STATE_WARNING } from "../../src/generated/i18n/i18n-defaults.js"; describe("Security", () => { @@ -372,7 +374,7 @@ describe("General", () => { it("Should delete token after focus change when tokenizer collapses", () => { cy.mount( - + @@ -5503,3 +5505,80 @@ describe("MultiComboBoxItemCustom - Mixed Selection", () => { cy.get("@multiCombobox").shadow().find("[ui5-token]").should("have.length", 2); }); }); + +describe("Tokenizer overflow calculation", () => { + it("should not cause render loop at specific widths with medium-length tokens", () => { + // This test verifies the fix for a render loop that occurred when: + // - MultiComboBox has a specific width (208px) + // - First token is medium-length (3-4 characters) + // - The "n more" indicator in Korean (wider text) causes oscillation + // Korean "1 more" = "1개 더" which is significantly wider than English + // The loop is reproducable in english as well but we need to change the english + // translation to be 1 character longer (instead of "more" use "moree") + + // Set language to Korean and wait for it to load + cy.wrap({ setLanguage }) + .then(api => api.setLanguage("ko")); + + cy.mount( + + + + + + ); + + cy.get("[ui5-multi-combobox]") + .as("mcb"); + + // Get the tokenizer element + cy.get("@mcb") + .shadow() + .find("[ui5-tokenizer]") + .as("tokenizer"); + + // Wait for language to fully apply and rendering to stabilize + cy.wait(300); + + // Get the "n more" text element with timeout - should exist with 3 selected tokens at 208px + cy.get("@tokenizer") + .shadow() + .find(".ui5-tokenizer-more-text") + .should("exist") + .as("nMoreText"); + + // Verify Korean text is shown (contains Korean characters) + // Korean shows "N개 항목" (wider than English "N more") + cy.get("@nMoreText") + .invoke("text") + .should("match", /개|항목/); // Korean text contains these characters + + // Capture the initial text of the "n more" indicator + cy.get("@nMoreText") + .invoke("text") + .as("initialText"); + + // Wait a bit to ensure no render loop is happening + cy.wait(500); + + // Verify the "n more" text hasn't changed (would change continuously in a render loop) + cy.get("@nMoreText") + .invoke("text") + .then(currentText => { + cy.get("@initialText").should("equal", currentText); + }); + + // Verify the tokenizer state is stable + cy.get("@tokenizer") + .should("exist"); + + // Verify tokens exist in the tokenizer (tokens are created from selected items) + cy.get("@tokenizer") + .find("[ui5-token]") + .should("have.length.at.least", 1); + + // Reset language + cy.wrap({ setLanguage }) + .then(api => api.setLanguage("en")); + }); +}); diff --git a/packages/main/cypress/specs/MultiInput.cy.tsx b/packages/main/cypress/specs/MultiInput.cy.tsx index f3f603373d6d..e05902fa8366 100644 --- a/packages/main/cypress/specs/MultiInput.cy.tsx +++ b/packages/main/cypress/specs/MultiInput.cy.tsx @@ -355,7 +355,7 @@ describe("MultiInput tokens", () => { cy.get("[ui5-token]") .eq(1) - .should("not.have.attr", "overflows"); + .should("have.attr", "overflows"); cy.get("[ui5-token]") .eq(2) @@ -571,7 +571,7 @@ describe("MultiInput tokens", () => { - + diff --git a/packages/main/cypress/specs/Tokenizer.cy.tsx b/packages/main/cypress/specs/Tokenizer.cy.tsx index 48bedecca72d..184490575e5e 100755 --- a/packages/main/cypress/specs/Tokenizer.cy.tsx +++ b/packages/main/cypress/specs/Tokenizer.cy.tsx @@ -798,7 +798,7 @@ describe("Accessibility", () => { const resourceBundle = (tokenizer.constructor as any).i18nBundle; cy.get("@nMoreLabel") - .should("have.text", resourceBundle.getText(MULTIINPUT_SHOW_MORE_TOKENS.defaultText, 2)); + .should("have.text", resourceBundle.getText(MULTIINPUT_SHOW_MORE_TOKENS.defaultText, 3)); }); }); }); diff --git a/packages/main/src/Tokenizer.ts b/packages/main/src/Tokenizer.ts index a7d43780d5cf..8cd2430b945f 100644 --- a/packages/main/src/Tokenizer.ts +++ b/packages/main/src/Tokenizer.ts @@ -1272,22 +1272,32 @@ class Tokenizer extends UI5Element implements IFormInputElement { const tokensArray = this._tokens; - // Reset the overflow prop of the tokens first in order - // to use their dimensions for calculation because already - // hidden tokens are set to 'display: none' + // Reset overflow to measure all tokens tokensArray.forEach(token => { token.overflows = false; }); - return tokensArray.filter(token => { - const parentRect = this.contentDom.getBoundingClientRect(); + const parentRect = this.contentDom.getBoundingClientRect(); + const parentEnd = Number(parentRect.right.toFixed(2)); + const parentStart = Number(parentRect.left.toFixed(2)); + + // Measure "n more" width + let nMoreWidth = 0; + const nMoreElement = this.moreLink; + if (nMoreElement) { + nMoreWidth = nMoreElement.getBoundingClientRect().width; + } + + // Calculate overflow, reserving space for "n more" except on last token + return tokensArray.filter((token, index) => { const tokenRect = token.getBoundingClientRect(); const tokenEnd = Number(tokenRect.right.toFixed(2)); - const parentEnd = Number(parentRect.right.toFixed(2)); const tokenStart = Number(tokenRect.left.toFixed(2)); - const parentStart = Number(parentRect.left.toFixed(2)); - token.overflows = !this.expanded && ((tokenStart < parentStart) || (tokenEnd > parentEnd)); + const isLastToken = index === tokensArray.length - 1; + const effectiveParentEnd = isLastToken ? parentEnd : Number((parentRect.right - nMoreWidth).toFixed(2)); + + token.overflows = !this.expanded && ((tokenStart < parentStart) || (tokenEnd > effectiveParentEnd)); return token.overflows; });