Skip to content
Open
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
81 changes: 80 additions & 1 deletion 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 @@ -372,7 +374,7 @@ describe("General", () => {

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

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"));
});
});
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
26 changes: 18 additions & 8 deletions packages/main/src/Tokenizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand Down
Loading