Skip to content
Open
107 changes: 85 additions & 22 deletions packages/main/cypress/specs/MultiComboBox.cy.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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<ResponsivePopover>("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(
<MultiComboBox style="width: 250px;">
<MultiComboBox style="width: 280px;">
<MultiComboBoxItem selected={true} text="Albania"></MultiComboBoxItem>
<MultiComboBoxItem selected={true} text="Argentina"></MultiComboBoxItem>
<MultiComboBoxItem selected={true} text="Bulgaria"></MultiComboBoxItem>
Expand Down Expand Up @@ -833,7 +853,7 @@ describe("General", () => {
.should("have.text", "BG");
});

it("N-more translation", () => {
it("N-items translation", () => {
cy.mount(
<MultiComboBox style="width: 100px">
<MultiComboBoxItem selected={true} text="This is a token with ridicilously long long long text"></MultiComboBoxItem>
Expand All @@ -856,11 +876,12 @@ describe("General", () => {
})
});

it("N-items translation", () => {
it("N-more translation", () => {
cy.mount(
<MultiComboBox style="width: 100%">
<MultiComboBoxItem selected={true} text="This is a token with ridicilously long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long text"></MultiComboBoxItem>
<MultiComboBox style="width: 400px">
<MultiComboBoxItem selected={true} text="This is a long token"></MultiComboBoxItem>
<MultiComboBoxItem selected={true} text="Item 1"></MultiComboBoxItem>
<MultiComboBoxItem selected={true} text="Item 2"></MultiComboBoxItem>
</MultiComboBox>
);

Expand All @@ -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));
})
});

Expand Down Expand Up @@ -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(
<MultiComboBox style="width: 208px">
<MultiComboBoxItem selected text="보기"></MultiComboBoxItem>
<MultiComboBoxItem selected text="임포트"></MultiComboBoxItem>
<MultiComboBoxItem selected text="편집"></MultiComboBoxItem>
</MultiComboBox>
);

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);
});
});
4 changes: 2 additions & 2 deletions packages/main/cypress/specs/MultiInput.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -571,7 +571,7 @@ describe("MultiInput tokens", () => {
<Token text="Lorem ipsum 1" slot="tokens"></Token>
</MultiInput>
<MultiInput id="mi-more">
<Token text="Token 1" slot="tokens"></Token>
<Token text="AC" slot="tokens"></Token>
<Token text="Enim do esse anim magna enim fugiat Lorem enim nostrud sit laborum ea." slot="tokens"></Token>
</MultiInput>
</>
Expand Down
2 changes: 1 addition & 1 deletion packages/main/cypress/specs/Tokenizer.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
});
});
Expand Down
40 changes: 31 additions & 9 deletions packages/main/src/Tokenizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
4 changes: 2 additions & 2 deletions packages/main/test/pages/styles/Tokenizer.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
}

ui5-tokenizer {
width: 240px;
width: 100%;
}

ui5-tokenizer#expanded-tokenizer {
Expand All @@ -34,7 +34,7 @@ ui5-tokenizer#nmore-tokenizer {
}

.tokenizer-container {
width: 240px;
width: 300px;
}

.wrapper {
Expand Down
Loading