diff --git a/packages/main/cypress/specs/Tokenizer.cy.tsx b/packages/main/cypress/specs/Tokenizer.cy.tsx
old mode 100644
new mode 100755
index 3a2ab92cae00..c30ae1259df1
--- a/packages/main/cypress/specs/Tokenizer.cy.tsx
+++ b/packages/main/cypress/specs/Tokenizer.cy.tsx
@@ -803,6 +803,245 @@ describe("Accessibility", () => {
});
});
+describe("Scrolling Behavior", () => {
+ it("should scroll to end when tokenizer is expanded without focused token", () => {
+ cy.mount(
+
+
+
+
+
+
+
+
+
+ );
+
+ // Verify there's actually overflow to test
+ cy.get("[ui5-tokenizer]")
+ .shadow()
+ .find(".ui5-tokenizer--content")
+ .then($content => {
+ const element = $content[0];
+ // Only proceed if there's actual overflow
+ if (element.scrollWidth > element.clientWidth) {
+ expect(element.scrollLeft).to.equal(0);
+ }
+ });
+
+ // Click on tokenizer container to expand it
+ cy.get("[ui5-tokenizer]")
+ .realClick();
+
+ // Verify tokenizer is expanded
+ cy.get("[ui5-tokenizer]")
+ .should("have.attr", "expanded");
+
+ // Should scroll to end when expanded without focused token (only if there's overflow)
+ cy.get("[ui5-tokenizer]")
+ .shadow()
+ .find(".ui5-tokenizer--content")
+ .then($content => {
+ const element = $content[0];
+ const maxScroll = element.scrollWidth - element.clientWidth;
+ if (maxScroll > 0) {
+ // Should be scrolled to the end (or close to it)
+ expect(element.scrollLeft).to.be.greaterThan(maxScroll * 0.5);
+ }
+ });
+ });
+
+ it("should scroll to specific token when token is clicked", () => {
+ cy.mount(
+
+
+
+
+
+
+
+
+
+ );
+
+ // Click on the third token (middle token)
+ cy.get("[ui5-token]")
+ .eq(2)
+ .as("thirdToken")
+ .realClick();
+
+ // Verify token is selected and focused (separate assertions)
+ cy.get("@thirdToken")
+ .should("have.attr", "selected");
+
+ cy.get("@thirdToken")
+ .should("have.attr", "focused");
+
+ // Verify tokenizer is expanded
+ cy.get("[ui5-tokenizer]")
+ .should("have.attr", "expanded");
+
+ // Should scroll to show the selected token
+ cy.get("[ui5-tokenizer]")
+ .shadow()
+ .find(".ui5-tokenizer--content")
+ .then($content => {
+ const element = $content[0];
+ const maxScroll = element.scrollWidth - element.clientWidth;
+ if (maxScroll > 0) {
+ // Should be scrolled some amount to show the token
+ expect(element.scrollLeft).to.be.greaterThan(0);
+ }
+ });
+ });
+
+ it("should scroll when navigating with Home and End keys", () => {
+ cy.mount(
+
+
+
+
+
+
+
+
+
+ );
+
+ // Click on first token
+ cy.get("[ui5-token]")
+ .eq(0)
+ .realClick();
+
+ // Navigate to the last token using End key
+ cy.realPress("End");
+
+ cy.get("[ui5-token]")
+ .eq(4)
+ .should("have.attr", "focused");
+
+ // Should scroll to end for last token
+ cy.get("[ui5-tokenizer]")
+ .shadow()
+ .find(".ui5-tokenizer--content")
+ .then($content => {
+ const element = $content[0];
+ const maxScroll = element.scrollWidth - element.clientWidth;
+ if (maxScroll > 0) {
+ expect(element.scrollLeft).to.be.closeTo(maxScroll, 20);
+ }
+ });
+
+ // Navigate back to first token using Home key
+ cy.realPress("Home");
+
+ cy.get("[ui5-token]")
+ .eq(0)
+ .should("have.attr", "focused");
+
+ // Should scroll back to start for first token
+ cy.get("[ui5-tokenizer]")
+ .shadow()
+ .find(".ui5-tokenizer--content")
+ .then($content => {
+ const element = $content[0];
+ expect(element.scrollLeft).to.be.closeTo(0, 20);
+ });
+ });
+
+ it("should maintain scroll position when token selection is toggled", () => {
+ cy.mount(
+
+
+
+
+
+
+
+
+
+ );
+
+ // Click on middle token to select it
+ cy.get("[ui5-token]")
+ .eq(2)
+ .as("middleToken")
+ .realClick();
+
+ // Get scroll position after clicking middle token
+ let scrollAfterClick;
+ cy.get("[ui5-tokenizer]")
+ .shadow()
+ .find(".ui5-tokenizer--content")
+ .then($content => {
+ scrollAfterClick = $content[0].scrollLeft;
+ });
+
+ // Toggle selection with space (should deselect but maintain focus)
+ cy.realPress("Space");
+
+ cy.get("@middleToken")
+ .should("not.have.attr", "selected");
+
+ cy.get("@middleToken")
+ .should("have.attr", "focused");
+
+ // Scroll position should remain the same since token is still focused
+ cy.get("[ui5-tokenizer]")
+ .shadow()
+ .find(".ui5-tokenizer--content")
+ .then($content => {
+ expect($content[0].scrollLeft).to.equal(scrollAfterClick);
+ });
+ });
+
+ it("should scroll to end when tokenizer regains focus without focused token", () => {
+ cy.mount(
+
+
+
+
+
+
+
+
+
+
+ );
+
+ // Click on a token to expand tokenizer
+ cy.get("[ui5-token]")
+ .eq(1)
+ .realClick();
+
+ // Tab to external button (lose focus)
+ cy.realPress("Tab");
+
+ // Verify tokenizer is collapsed
+ cy.get("[ui5-tokenizer]")
+ .should("not.have.attr", "expanded");
+
+ // Tab back to tokenizer (regain focus)
+ cy.realPress(["Shift", "Tab"]);
+
+ // Verify tokenizer is expanded again
+ cy.get("[ui5-tokenizer]")
+ .should("have.attr", "expanded");
+
+ // Should scroll to end when regaining focus (as per _scrollToEndIfNeeded logic)
+ cy.get("[ui5-tokenizer]")
+ .shadow()
+ .find(".ui5-tokenizer--content")
+ .then($content => {
+ const element = $content[0];
+ const maxScroll = element.scrollWidth - element.clientWidth;
+ if (maxScroll > 0) {
+ expect(element.scrollLeft).to.be.greaterThan(maxScroll * 0.5);
+ }
+ });
+ });
+});
+
describe("Keyboard Handling", () => {
beforeEach(() => {
cy.mount(
diff --git a/packages/main/src/MultiComboBox.ts b/packages/main/src/MultiComboBox.ts
index 50838865898f..521ef5efbd0b 100644
--- a/packages/main/src/MultiComboBox.ts
+++ b/packages/main/src/MultiComboBox.ts
@@ -678,6 +678,7 @@ class MultiComboBox extends UI5Element implements IFormInputElement {
_showFilteredItems() {
this.filterSelected = true;
this._showMorePressed = true;
+ this._tokenizer._scrollToEndOnExpand = true;
this._toggleTokenizerPopover();
}
@@ -1778,13 +1779,10 @@ class MultiComboBox extends UI5Element implements IFormInputElement {
this._tokenizer.preventInitialFocus = true;
if (this.open && !isPhone()) {
+ this._tokenizer._scrollToEndOnExpand = true;
this._tokenizer.expanded = true;
}
- if (this._tokenizer.expanded && this.hasAttribute("focused")) {
- this._tokenizer.scrollToEnd();
- }
-
if (!arraysAreEqual(this._valueStateLinks, this.linksInAriaValueStateHiddenText)) {
this._removeLinksEventListeners();
this._addLinksEventListeners();
@@ -1882,8 +1880,8 @@ class MultiComboBox extends UI5Element implements IFormInputElement {
inputFocusIn(e: FocusEvent) {
if (!isPhone()) {
this.focused = true;
+ this._tokenizer._scrollToEndOnExpand = true;
this._tokenizer.expanded = true;
- this._tokenizer.scrollToEnd();
} else {
this._innerInput.blur();
}
diff --git a/packages/main/src/MultiInput.ts b/packages/main/src/MultiInput.ts
index ee87cc09e75b..733b198214bc 100644
--- a/packages/main/src/MultiInput.ts
+++ b/packages/main/src/MultiInput.ts
@@ -199,7 +199,6 @@ class MultiInput extends Input implements IFormInputElement {
_tokenizerFocusOut(e: FocusEvent) {
if (!this.contains(e.relatedTarget as HTMLElement) && !this.shadowRoot!.contains(e.relatedTarget as HTMLElement)) {
this.tokenizer._tokens.forEach(token => { token.selected = false; });
- this.tokenizer.scrollToStart();
}
}
@@ -210,15 +209,19 @@ class MultiInput extends Input implements IFormInputElement {
}
innerFocusIn() {
+ this.tokenizer._scrollToEndOnExpand = true;
this.tokenizer.expanded = true;
this.focused = true;
- this.tokenizer.scrollToEnd();
this.tokens.forEach(token => {
token.selected = false;
});
}
+ _showMoreItemsPress() {
+ this.tokenizer._scrollToEndOnExpand = true;
+ }
+
_onkeydown(e: KeyboardEvent) {
!this._isComposing && super._onkeydown(e);
@@ -346,12 +349,6 @@ class MultiInput extends Input implements IFormInputElement {
super.onAfterRendering();
this.tokenizer.preventInitialFocus = true;
-
- if (this.tokenizer.expanded) {
- this.tokenizer.scrollToEnd();
- } else {
- this.tokenizer.scrollToStart();
- }
}
get iconsCount() {
diff --git a/packages/main/src/MultiInputTemplate.tsx b/packages/main/src/MultiInputTemplate.tsx
index d6ee8b24e41a..25bbe7cb0e00 100644
--- a/packages/main/src/MultiInputTemplate.tsx
+++ b/packages/main/src/MultiInputTemplate.tsx
@@ -27,6 +27,7 @@ function preContent(this: MultiInput) {
onKeyDown={this._onTokenizerKeydown}
onTokenDelete={this.tokenDelete}
onFocusOut={this._tokenizerFocusOut}
+ onShowMoreItemsPress={this._showMoreItemsPress}
>
{ this.tokens.map(token => )}
diff --git a/packages/main/src/Tokenizer.ts b/packages/main/src/Tokenizer.ts
index 3158e75e463b..aa38c2d02d5c 100644
--- a/packages/main/src/Tokenizer.ts
+++ b/packages/main/src/Tokenizer.ts
@@ -350,6 +350,11 @@ class Tokenizer extends UI5Element {
_previousToken: Token | null = null;
_focusedElementBeforeOpen?: HTMLElement | null;
_deletedDialogItems!: Token[];
+ /**
+ * Scroll to end when tokenizer is expanded
+ * @private
+ */
+ _scrollToEndOnExpand = false;
_handleResize() {
this._nMoreCount = this.overflownTokens.length;
@@ -403,7 +408,6 @@ class Tokenizer extends UI5Element {
if (!this.preventPopoverOpen) {
this.open = true;
- this.scrollToEnd();
}
this._tokens.forEach(token => {
@@ -418,14 +422,13 @@ class Tokenizer extends UI5Element {
_onmousedown(e: MouseEvent) {
if ((e.target as HTMLElement).hasAttribute("ui5-token")) {
const target = e.target as Token;
- this.expanded = true;
if (this.open) {
this._preventCollapse = true;
}
if (!target.toBeDeleted) {
- this._itemNav.setCurrentItem(target);
+ this._addTokenToNavigation(target);
this._scrollToToken(target);
}
}
@@ -474,9 +477,26 @@ class Tokenizer extends UI5Element {
this._expandedScrollWidth = this.contentDom.scrollWidth;
}
+ this._scrollToEndIfNeeded();
this._tokenDeleting = false;
}
+ /**
+ * Scrolls the container to the end to ensure very long tokens are visible at their end.
+ * Otherwise, tokens may appear visually cut off.
+ * @protected
+ */
+ _scrollToEndIfNeeded() {
+ // if scroll to end is prevented, skip scroll to the end
+ if (!this._scrollToEndOnExpand) {
+ return;
+ }
+
+ if (this.tokens.length || this.expanded) {
+ this.scrollToEnd();
+ }
+ }
+
_delete(e: CustomEvent) {
const target = e.target as Token;
@@ -894,13 +914,14 @@ class Tokenizer extends UI5Element {
}
_onfocusin(e: FocusEvent) {
- const target = e.target as Token;
this.open = false;
- this._itemNav.setCurrentItem(target);
+ this.expanded = true;
+ this._addTokenToNavigation(e.target as Token);
+ }
- if (!this.expanded) {
- this.expanded = true;
- }
+ _addTokenToNavigation(token: Token) {
+ this._scrollToEndOnExpand = false;
+ this._itemNav.setCurrentItem(token);
}
_onfocusout(e: FocusEvent) {
@@ -1004,7 +1025,8 @@ class Tokenizer extends UI5Element {
/**
* Scrolls token to the visible area of the container.
- * Adds 4 pixels to the scroll position to ensure padding and border visibility on both ends
+ * Adds 5 pixels to the scroll position to ensure padding and border visibility on both ends
+ * For the last token, if its width is more than the needed space, scroll to the end without offset
* @protected
*/
_scrollToToken(token: IToken) {
@@ -1014,11 +1036,18 @@ class Tokenizer extends UI5Element {
const tokenRect = token.getBoundingClientRect();
const tokenContainerRect = this.contentDom.getBoundingClientRect();
+ const oneSideBorderAndPaddingOffset = 5;
+
+ const isLastToken = this._tokens.indexOf(token as Token) === this._tokens.length - 1;
+ if (isLastToken) {
+ this.scrollToEnd();
+ return;
+ }
if (tokenRect.left < tokenContainerRect.left) {
- this._scrollEnablement?.scrollTo(this.contentDom.scrollLeft - (tokenContainerRect.left - tokenRect.left + 5), 0);
+ this._scrollEnablement?.scrollTo(this.contentDom.scrollLeft - (tokenContainerRect.left - tokenRect.left + oneSideBorderAndPaddingOffset), 0);
} else if (tokenRect.right > tokenContainerRect.right) {
- this._scrollEnablement?.scrollTo(this.contentDom.scrollLeft + (tokenRect.right - tokenContainerRect.right + 5), 0);
+ this._scrollEnablement?.scrollTo(this.contentDom.scrollLeft + (tokenRect.right - tokenContainerRect.right + oneSideBorderAndPaddingOffset), 0);
}
}