diff --git a/packages/main/cypress/specs/MultiComboBox.cy.tsx b/packages/main/cypress/specs/MultiComboBox.cy.tsx index 078a48a5f6e4c..ee9196b156615 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", () => { @@ -343,36 +345,54 @@ describe("General", () => { cy.get("[ui5-multi-combobox]") .as("mcb") + .then($mcb => { + $mcb.get(0).addEventListener("ui5-selection-change", cy.stub().as("selectionChange")); + }); + + // With new overflow logic, both tokens should be hidden and show "2 items" + cy.get("@mcb") .shadow() .find("[ui5-tokenizer]") .as("tokenizer") - .invoke('on', 'ui5-token-delete', cy.spy().as('tokenDelete')); - - // The first token is the long one and should be hidden in the n-more, so we target the second token - cy.get("@tokenizer") - .find("[ui5-token]") - .eq(1) - .as("token") - .should("exist"); + .shadow() + .find(".ui5-tokenizer-more-text") + .should("exist") + .should("contain.text", "2") + .realClick(); - cy.get("@token") + // In MultiComboBox, clicking "n items" opens the main dropdown + cy.get("@mcb") .shadow() - .find("[ui5-icon]") + .find("ui5-responsive-popover") + .as("popover") + .ui5ResponsivePopoverOpened(); + + // Find and click the first item (the long token) to deselect it + cy.get("@mcb") + .find("[ui5-mcb-item]") + .first() + .should("have.attr", "text", "This is an extremely long token text that will definitely trigger the problematic code path in the deletion flow and should be properly deletable") .realClick(); - cy.get("@tokenDelete") - .should("have.been.calledOnce") - .should("have.been.calledWithMatch", Cypress.sinon.match(event => { - return event.detail.tokens.length === 1; - })); + cy.get("@selectionChange") + .should("have.been.called"); - cy.get("@token") + // After removing the long token, the remaining "Item" token should now be visible + cy.get("@tokenizer") + .find("[ui5-token]") + .should("have.length", 1) + .should("have.attr", "text", "Item"); + + // No "n more" should be shown since the single token fits + cy.get("@tokenizer") + .shadow() + .find(".ui5-tokenizer-more-text") .should("not.exist"); }); it("Should delete token after focus change when tokenizer collapses", () => { cy.mount( - + @@ -833,7 +853,7 @@ describe("General", () => { .should("have.text", "BG"); }); - it("N-more translation", () => { + it("N-items translation", () => { cy.mount( @@ -856,11 +876,12 @@ describe("General", () => { }) }); - it("N-items translation", () => { + it("N-more translation", () => { cy.mount( - - + + + ); @@ -875,7 +896,7 @@ describe("General", () => { .find("[ui5-tokenizer]") .shadow() .find(".ui5-tokenizer-more-text") - .should("have.text", resourceBundle.getText(MULTIINPUT_SHOW_MORE_TOKENS.defaultText, 1)); + .should("have.text", resourceBundle.getText(MULTIINPUT_SHOW_MORE_TOKENS.defaultText, 2)); }) }); @@ -5590,3 +5611,45 @@ describe("Select All with Groups", () => { .should("not.have.attr", "checked"); }); }); + +describe("Tokenizer overflow calculation", () => { + afterEach(() => { + // Reset language regardless of test outcome to avoid leaking into other specs. + cy.wrap(setLanguage("en")); + }); + + it("should not cause a render loop when the 'n more' width toggles overflow at boundary widths", () => { + // Regression for a render loop: at ~208px the number of overflowing tokens depends on + // the "n more" indicator width, which itself changes with the number of visible tokens, + // so the overflow count oscillates. Korean copy is wider than English, exposing the bug + // at this width. A loop trips RenderQueue's "processed too many times" guard, which + // surfaces as an uncaught exception and fails this test automatically — no polling needed. + cy.wrap(setLanguage("ko")); + + cy.mount( + + + + + + ); + + cy.get("[ui5-multi-combobox]") + .shadow() + .find("[ui5-tokenizer]") + .as("tokenizer"); + + // The component must settle on a stable state: all three tokens overflow, so the + // indicator reports "3". Cypress retries this until it holds; a loop would throw first. + cy.get("@tokenizer") + .shadow() + .find(".ui5-tokenizer-more-text") + .should("exist") + .and("contain.text", "3"); + + // All three tokens remain in the DOM (overflowing tokens are hidden, not removed). + cy.get("@tokenizer") + .find("[ui5-token]") + .should("have.length", 3); + }); +}); diff --git a/packages/main/cypress/specs/MultiInput.cy.tsx b/packages/main/cypress/specs/MultiInput.cy.tsx index f3f603373d6df..e05902fa83665 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 48bedecca72d6..184490575e5eb 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 a7d43780d5cfa..c567282e8d938 100644 --- a/packages/main/src/Tokenizer.ts +++ b/packages/main/src/Tokenizer.ts @@ -1272,25 +1272,47 @@ 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 sequentially: show tokens only if token + "n more" indicator both fit + let firstOverflowIndex = -1; + + tokensArray.forEach((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; + + // For the last token, check if it fits without "n more" + // For other tokens, check if token + "n more" fits together + const effectiveParentEnd = isLastToken ? parentEnd : Number((parentRect.right - nMoreWidth).toFixed(2)); - return token.overflows; + const tokenOverflows = !this.expanded && ((tokenStart < parentStart) || (tokenEnd > effectiveParentEnd)); + + if (tokenOverflows && firstOverflowIndex === -1) { + firstOverflowIndex = index; + } + + // Mark this and all subsequent tokens as overflow + token.overflows = firstOverflowIndex !== -1 && index >= firstOverflowIndex; }); + + return tokensArray.filter(token => token.overflows); } get _isPhone() { diff --git a/packages/main/test/pages/styles/Tokenizer.css b/packages/main/test/pages/styles/Tokenizer.css index 9475f6d75451c..4719fdb340a3a 100644 --- a/packages/main/test/pages/styles/Tokenizer.css +++ b/packages/main/test/pages/styles/Tokenizer.css @@ -22,7 +22,7 @@ } ui5-tokenizer { - width: 240px; + width: 100%; } ui5-tokenizer#expanded-tokenizer { @@ -34,7 +34,7 @@ ui5-tokenizer#nmore-tokenizer { } .tokenizer-container { - width: 240px; + width: 300px; } .wrapper {