Skip to content

Commit 7ae9f51

Browse files
committed
fix(ui5-multi-input): fix arrow left key behavior
Fixes: #13357 Do not fire change event when user navigates inside the multi-input with the arrow left and right key between inner input and tokenizer. Change event should only be fired when the user leaves the multi-input.
1 parent a904c5e commit 7ae9f51

3 files changed

Lines changed: 189 additions & 11 deletions

File tree

packages/main/cypress/specs/MultiInput.cy.tsx

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1502,6 +1502,145 @@ describe("Keyboard handling", () => {
15021502
cy.get("@changeSpy")
15031503
.should("have.been.calledOnce");
15041504
});
1505+
1506+
it("should focus last token on ArrowLeft at start of input, keep suggestions open, and not fire change event", () => {
1507+
cy.mount(
1508+
<MultiInput showSuggestions>
1509+
<Token slot="tokens" text="Amet"></Token>
1510+
<SuggestionItem text="Bulgaria"></SuggestionItem>
1511+
<SuggestionItem text="Brazil"></SuggestionItem>
1512+
</MultiInput>
1513+
);
1514+
1515+
const changeSpy = cy.stub().as("changeSpy");
1516+
1517+
cy.get("[ui5-multi-input]")
1518+
.then(multiInput => {
1519+
multiInput[0].addEventListener("ui5-change", changeSpy);
1520+
});
1521+
1522+
cy.get("[ui5-multi-input]")
1523+
.shadow()
1524+
.find("input")
1525+
.as("input");
1526+
1527+
cy.get("@input")
1528+
.realClick();
1529+
1530+
cy.realType("a");
1531+
1532+
cy.get("[ui5-multi-input]")
1533+
.shadow()
1534+
.find<ResponsivePopover>("[ui5-responsive-popover]")
1535+
.ui5ResponsivePopoverOpened();
1536+
1537+
cy.realPress("ArrowLeft"); // cursor: pos 1 → pos 0
1538+
cy.realPress("ArrowLeft"); // cursor at pos 0 → focuses last token
1539+
1540+
cy.get("[ui5-token]")
1541+
.should("have.length", 1);
1542+
1543+
cy.get("[ui5-token]")
1544+
.should("be.focused");
1545+
1546+
cy.get("[ui5-multi-input]")
1547+
.shadow()
1548+
.find<ResponsivePopover>("[ui5-responsive-popover]")
1549+
.ui5ResponsivePopoverOpened();
1550+
1551+
cy.get("@changeSpy")
1552+
.should("not.have.been.called");
1553+
});
1554+
1555+
it("should fire change event when returning from tokenizer to input via ArrowRight and pressing Tab", () => {
1556+
cy.mount(
1557+
<MultiInput showSuggestions>
1558+
<Token slot="tokens" text="Amet"></Token>
1559+
<SuggestionItem text="Bulgaria"></SuggestionItem>
1560+
<SuggestionItem text="Brazil"></SuggestionItem>
1561+
</MultiInput>
1562+
);
1563+
1564+
const changeSpy = cy.stub().as("changeSpy");
1565+
1566+
cy.get("[ui5-multi-input]")
1567+
.then(multiInput => {
1568+
multiInput[0].addEventListener("ui5-change", changeSpy);
1569+
});
1570+
1571+
cy.get("[ui5-multi-input]")
1572+
.shadow()
1573+
.find("input")
1574+
.as("input");
1575+
1576+
cy.get("@input")
1577+
.realClick();
1578+
1579+
cy.realType("a");
1580+
1581+
// focus last token
1582+
cy.realPress("ArrowLeft");
1583+
cy.realPress("ArrowLeft");
1584+
1585+
cy.get("[ui5-token]")
1586+
.should("be.focused");
1587+
1588+
// return to input
1589+
cy.realPress("ArrowRight");
1590+
1591+
cy.get("[ui5-multi-input]")
1592+
.should("be.focused");
1593+
1594+
cy.realPress("Tab");
1595+
1596+
cy.get("@changeSpy")
1597+
.should("have.been.calledOnce");
1598+
});
1599+
1600+
it("should fire change event when returning from tokenizer to input via Tab and pressing Enter", () => {
1601+
cy.mount(
1602+
<MultiInput showSuggestions noTypeahead>
1603+
<Token slot="tokens" text="Amet"></Token>
1604+
<SuggestionItem text="Bulgaria"></SuggestionItem>
1605+
<SuggestionItem text="Brazil"></SuggestionItem>
1606+
</MultiInput>
1607+
);
1608+
1609+
const changeSpy = cy.stub().as("changeSpy");
1610+
1611+
cy.get("[ui5-multi-input]")
1612+
.then(multiInput => {
1613+
multiInput[0].addEventListener("ui5-change", changeSpy);
1614+
});
1615+
1616+
cy.get("[ui5-multi-input]")
1617+
.shadow()
1618+
.find("input")
1619+
.as("input");
1620+
1621+
cy.get("@input")
1622+
.realClick();
1623+
1624+
cy.realType("b");
1625+
1626+
// focus last token
1627+
cy.realPress("ArrowLeft");
1628+
cy.realPress("ArrowLeft");
1629+
1630+
cy.get("[ui5-token]")
1631+
.should("be.focused");
1632+
1633+
// return to input
1634+
cy.realPress("Tab");
1635+
1636+
cy.get("[ui5-multi-input]")
1637+
.should("be.focused");
1638+
1639+
cy.realPress("Enter");
1640+
1641+
cy.get("@changeSpy")
1642+
.should("have.been.calledOnce");
1643+
});
15051644
});
15061645

15071646
describe("MultiInput Composition", () => {

packages/main/src/Input.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -631,8 +631,8 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
631631
previousValue: string;
632632
firstRendering: boolean;
633633
typedInValue: string;
634-
lastConfirmedValue: string
635-
isTyping: boolean
634+
lastConfirmedValue: string;
635+
isTyping: boolean;
636636
_handleResizeBound: ResizeObserverCallback;
637637
_shouldAutocomplete?: boolean;
638638
_enterKeyDown?: boolean;

packages/main/src/MultiInput.ts

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import {
1313
isHome,
1414
isEnd,
1515
isDown,
16-
16+
isEnter,
17+
isTabNext,
1718
} from "@ui5/webcomponents-base/dist/Keys.js";
1819
import { isPhone } from "@ui5/webcomponents-base/dist/Device.js";
1920
import type { ITabbable } from "@ui5/webcomponents-base/dist/delegate/ItemNavigation.js";
@@ -153,6 +154,8 @@ class MultiInput extends Input implements IFormInputElement {
153154

154155
_skipOpenSuggestions: boolean;
155156
_valueHelpIconPressed: boolean;
157+
_focusInTokenizer: boolean;
158+
_returningFromTokenizer: boolean;
156159

157160
get formValidityMessage() {
158161
return MultiInput.i18nBundle.getText(FORM_MIXED_TEXTFIELD_REQUIRED);
@@ -188,6 +191,8 @@ class MultiInput extends Input implements IFormInputElement {
188191
// Prevent suggestions' opening.
189192
this._skipOpenSuggestions = false;
190193
this._valueHelpIconPressed = false;
194+
this._focusInTokenizer = false;
195+
this._returningFromTokenizer = false;
191196
}
192197

193198
valueHelpPress() {
@@ -225,6 +230,10 @@ class MultiInput extends Input implements IFormInputElement {
225230
if (!this.contains(e.relatedTarget as HTMLElement) && !this.shadowRoot!.contains(e.relatedTarget as HTMLElement)) {
226231
this.tokenizer._tokens.forEach(token => { token.selected = false; });
227232
}
233+
if (this.shadowRoot!.contains(e.relatedTarget as HTMLElement)) {
234+
this._returningFromTokenizer = true;
235+
}
236+
this._focusInTokenizer = false;
228237
}
229238

230239
valueHelpMouseUp() {
@@ -248,6 +257,7 @@ class MultiInput extends Input implements IFormInputElement {
248257

249258
_onkeydown(e: KeyboardEvent) {
250259
!this._isComposing && super._onkeydown(e);
260+
this._isKeyNavigation = true;
251261

252262
const target = e.target as HTMLInputElement;
253263
const isHomeInBeginning = isHome(e) && target.selectionStart === 0;
@@ -269,9 +279,16 @@ class MultiInput extends Input implements IFormInputElement {
269279

270280
this._skipOpenSuggestions = false;
271281

282+
if ((isEnter(e) || isTabNext(e)) && this.previousValue !== this.value) {
283+
this._handleChange();
284+
return;
285+
}
286+
272287
if (isShow(e)) {
273288
this.valueHelpPress();
274289
}
290+
291+
this._isKeyNavigation = false;
275292
}
276293

277294
_onTokenizerKeydown(e: KeyboardEvent) {
@@ -281,6 +298,7 @@ class MultiInput extends Input implements IFormInputElement {
281298
const lastTokenIndex = this.tokens.length - 1;
282299

283300
if (e.target === this.tokens[lastTokenIndex] && this.tokens[lastTokenIndex] === document.activeElement) {
301+
this._returningFromTokenizer = true;
284302
setTimeout(() => {
285303
this.focus();
286304
}, 0);
@@ -296,9 +314,25 @@ class MultiInput extends Input implements IFormInputElement {
296314
// selectionStart property applies only to inputs of types text, search, URL, tel, and password
297315
if (((cursorPosition === null && !this.value) || cursorPosition === 0) && lastToken) {
298316
e.preventDefault();
299-
lastToken.focus();
300-
this.tokenizer._itemNav.setCurrentItem(lastToken);
317+
this._focusToken(lastToken);
318+
}
319+
}
320+
321+
_focusToken(tokenToFocus: IToken) {
322+
this._focusInTokenizer = true;
323+
tokenToFocus.focus();
324+
this.tokenizer._itemNav.setCurrentItem(tokenToFocus);
325+
}
326+
327+
/**
328+
* @override
329+
*/
330+
_handleChange() {
331+
if (this._focusInTokenizer) {
332+
return;
301333
}
334+
335+
super._handleChange();
302336
}
303337

304338
_handleBackspace(e: KeyboardEvent) {
@@ -308,8 +342,7 @@ class MultiInput extends Input implements IFormInputElement {
308342
// Only move focus to the last token if the input is empty
309343
if (!this.value && lastToken) {
310344
e.preventDefault();
311-
lastToken.focus();
312-
this.tokenizer._itemNav.setCurrentItem(lastToken);
345+
this._focusToken(lastToken);
313346
}
314347
}
315348

@@ -319,9 +352,7 @@ class MultiInput extends Input implements IFormInputElement {
319352

320353
if (firstToken) {
321354
e.preventDefault();
322-
323-
firstToken.focus();
324-
this.tokenizer._itemNav.setCurrentItem(firstToken);
355+
this._focusToken(firstToken);
325356
}
326357
}
327358

@@ -347,7 +378,15 @@ class MultiInput extends Input implements IFormInputElement {
347378
const inputDomRef = this.getInputDOMRef();
348379

349380
if (e.target === inputDomRef) {
350-
super._onfocusin(e);
381+
if (this._returningFromTokenizer) {
382+
this._returningFromTokenizer = false;
383+
this.focused = true;
384+
this.open = true;
385+
this._inputIconFocused = false;
386+
this._focusedAfterClear = false;
387+
} else {
388+
super._onfocusin(e);
389+
}
351390
}
352391
}
353392

0 commit comments

Comments
 (0)