Skip to content

Commit a38752a

Browse files
authored
fix(ui5-multi-combobox): fix RTL arrow navigation to and from the tokens (#11857)
* fix(ui5-multi-combobox): fix RTL arrow navigation to and from the tokens fixes: #11826
1 parent 130e9c5 commit a38752a

File tree

2 files changed

+305
-3
lines changed

2 files changed

+305
-3
lines changed

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

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,292 @@ describe("Event firing", () => {
182182
});
183183
});
184184

185+
describe("MultiComboBox RTL/LTR Arrow Navigation", () => {
186+
it("should focus last token on arrow right in RTL mode when input is at start", () => {
187+
cy.mount(
188+
<div dir="rtl">
189+
<MultiComboBox noValidation={true}>
190+
<MultiComboBoxItem selected text="Token 1"></MultiComboBoxItem>
191+
<MultiComboBoxItem selected text="Token 2"></MultiComboBoxItem>
192+
<MultiComboBoxItem selected text="Token 3"></MultiComboBoxItem>
193+
<MultiComboBoxItem text="Item 4"></MultiComboBoxItem>
194+
<MultiComboBoxItem text="Item 5"></MultiComboBoxItem>
195+
</MultiComboBox>
196+
</div>
197+
);
198+
199+
cy.get("[ui5-multi-combobox]")
200+
.as("mcb")
201+
.realClick();
202+
cy.get("@mcb")
203+
.should("be.focused");
204+
205+
cy.get("@mcb")
206+
.shadow()
207+
.find("input")
208+
.as("input")
209+
.then(($input) => {
210+
($input[0] as HTMLInputElement).setSelectionRange(0, 0);
211+
})
212+
.should(($input) => {
213+
expect(($input[0] as HTMLInputElement).selectionStart).to.equal(0);
214+
});
215+
216+
cy.get("@mcb").realPress("ArrowRight");
217+
cy.get("@mcb")
218+
.shadow()
219+
.find("[ui5-tokenizer]")
220+
.find("[ui5-token]")
221+
.last()
222+
.should("be.visible")
223+
.should("be.focused");
224+
});
225+
226+
it("should focus last token on arrow left in LTR mode when input is at start", () => {
227+
cy.mount(
228+
<div dir="ltr">
229+
<MultiComboBox noValidation={true}>
230+
<MultiComboBoxItem selected text="Token 1"></MultiComboBoxItem>
231+
<MultiComboBoxItem selected text="Token 2"></MultiComboBoxItem>
232+
<MultiComboBoxItem selected text="Token 3"></MultiComboBoxItem>
233+
<MultiComboBoxItem text="Item 4"></MultiComboBoxItem>
234+
<MultiComboBoxItem text="Item 5"></MultiComboBoxItem>
235+
</MultiComboBox>
236+
</div>
237+
);
238+
239+
cy.get("[ui5-multi-combobox]")
240+
.as("mcb")
241+
.realClick();
242+
243+
cy.get("@mcb")
244+
.should("be.focused");
245+
246+
cy.get("@mcb")
247+
.shadow()
248+
.find("input")
249+
.as("input")
250+
.realClick()
251+
.should("have.focus")
252+
.then(($input) => {
253+
($input[0] as HTMLInputElement).setSelectionRange(0, 0);
254+
})
255+
.should(($input) => {
256+
expect(($input[0] as HTMLInputElement).selectionStart).to.equal(0);
257+
});
258+
259+
cy.get("@mcb").realPress("ArrowLeft");
260+
261+
cy.get("@mcb")
262+
.shadow()
263+
.find("[ui5-tokenizer]")
264+
.find("[ui5-token]")
265+
.last()
266+
.should("be.visible")
267+
.should("be.focused");
268+
});
269+
270+
it("should not focus token when cursor is not at start of input in RTL mode", () => {
271+
cy.mount(
272+
<div dir="rtl">
273+
<MultiComboBox noValidation={true} value="test text">
274+
<MultiComboBoxItem selected text="Token 1"></MultiComboBoxItem>
275+
<MultiComboBoxItem selected text="Token 2"></MultiComboBoxItem>
276+
<MultiComboBoxItem text="Item 3"></MultiComboBoxItem>
277+
</MultiComboBox>
278+
</div>
279+
);
280+
281+
cy.get("[ui5-multi-combobox]")
282+
.as("mcb")
283+
.realClick();
284+
285+
cy.get("@mcb").should("be.focused");
286+
287+
288+
cy.get("@mcb")
289+
.shadow()
290+
.find("input")
291+
.as("input")
292+
.realClick()
293+
.should("be.focused")
294+
.then(($input) => {
295+
($input[0] as HTMLInputElement).setSelectionRange(2, 2);
296+
});
297+
298+
cy.get("@mcb").realPress("ArrowRight");
299+
300+
cy.get("@mcb")
301+
.shadow()
302+
.find("input")
303+
.as("input")
304+
.realClick();
305+
306+
cy.get("@input")
307+
.should("be.focused")
308+
.should(($input) => {
309+
expect(($input[0] as HTMLInputElement).selectionStart).to.equal(3);
310+
});
311+
312+
cy.get("@mcb")
313+
.shadow()
314+
.find("[ui5-tokenizer]")
315+
.find("[ui5-token]")
316+
.should("not.be.focused");
317+
});
318+
319+
it("should not focus token when text is selected in RTL mode", () => {
320+
cy.mount(
321+
<div dir="rtl">
322+
<MultiComboBox noValidation={true} value="test">
323+
<MultiComboBoxItem selected text="Token 1"></MultiComboBoxItem>
324+
<MultiComboBoxItem selected text="Token 2"></MultiComboBoxItem>
325+
<MultiComboBoxItem text="Item 3"></MultiComboBoxItem>
326+
</MultiComboBox>
327+
</div>
328+
);
329+
330+
cy.get("[ui5-multi-combobox]")
331+
.as("mcb")
332+
.realClick();
333+
334+
cy.get("@mcb").should("be.focused");
335+
336+
cy.get("@mcb")
337+
.shadow()
338+
.find("input")
339+
.as("input")
340+
.realClick()
341+
.realPress(["Control", "a"]);
342+
343+
cy.get("@input")
344+
.should(($input) => {
345+
expect(($input[0] as HTMLInputElement).selectionStart).to.equal(0);
346+
expect(($input[0] as HTMLInputElement).selectionEnd).to.equal(4);
347+
});
348+
349+
cy.get("@mcb")
350+
.shadow()
351+
.find("[ui5-tokenizer]")
352+
.find("[ui5-token]")
353+
.should("not.have.focus");
354+
});
355+
356+
it("should navigate from last token back to input with arrow left in RTL mode", () => {
357+
cy.mount(
358+
<div dir="rtl">
359+
<MultiComboBox noValidation={true}>
360+
<MultiComboBoxItem selected text="Token 1"></MultiComboBoxItem>
361+
<MultiComboBoxItem selected text="Token 2"></MultiComboBoxItem>
362+
<MultiComboBoxItem selected text="Token 3"></MultiComboBoxItem>
363+
<MultiComboBoxItem text="Item 4"></MultiComboBoxItem>
364+
</MultiComboBox>
365+
</div>
366+
);
367+
368+
cy.get("[ui5-multi-combobox]")
369+
.as("mcb")
370+
.realClick()
371+
372+
cy.get("@mcb")
373+
.should("be.focused")
374+
.realPress("ArrowRight");
375+
376+
cy.get("@mcb")
377+
.shadow()
378+
.find("[ui5-tokenizer]")
379+
.find("[ui5-token]")
380+
.last()
381+
.as("lastToken")
382+
.should("have.focus");
383+
384+
cy.get("@lastToken")
385+
.should("be.focused")
386+
.realPress("ArrowLeft");
387+
388+
cy.get("@mcb")
389+
.shadow()
390+
.find("input")
391+
.should("be.focused");
392+
});
393+
394+
it("should navigate from last token back to input with arrow right in LTR mode", () => {
395+
cy.mount(
396+
<div dir="ltr">
397+
<MultiComboBox noValidation={true}>
398+
<MultiComboBoxItem selected text="Token 1"></MultiComboBoxItem>
399+
<MultiComboBoxItem selected text="Token 2"></MultiComboBoxItem>
400+
<MultiComboBoxItem selected text="Token 3"></MultiComboBoxItem>
401+
<MultiComboBoxItem text="Item 4"></MultiComboBoxItem>
402+
</MultiComboBox>
403+
</div>
404+
);
405+
406+
cy.get("[ui5-multi-combobox]")
407+
.as("mcb")
408+
.realClick();
409+
410+
cy.get("@mcb")
411+
.should("be.focused")
412+
.realPress("ArrowLeft");
413+
414+
cy.get("@mcb")
415+
.shadow()
416+
.find("[ui5-tokenizer]")
417+
.find("[ui5-token]")
418+
.last()
419+
.as("lastToken")
420+
.should("be.focused");
421+
422+
cy.get("@lastToken").realPress("ArrowRight");
423+
424+
cy.get("@mcb")
425+
.shadow()
426+
.find("input")
427+
.should("be.focused");
428+
});
429+
430+
it("should handle empty input case in RTL mode", () => {
431+
cy.mount(
432+
<div dir="rtl">
433+
<MultiComboBox noValidation={true}>
434+
<MultiComboBoxItem selected text="Token 1"></MultiComboBoxItem>
435+
<MultiComboBoxItem selected text="Token 2"></MultiComboBoxItem>
436+
<MultiComboBoxItem text="Item 3"></MultiComboBoxItem>
437+
</MultiComboBox>
438+
</div>
439+
);
440+
441+
cy.get("[ui5-multi-combobox]")
442+
.as("mcb")
443+
.realClick();
444+
445+
cy.get("@mcb").should("be.focused");
446+
447+
cy.get("@mcb")
448+
.shadow()
449+
.find("input")
450+
.as("input")
451+
.realClick()
452+
.should("have.focus")
453+
454+
cy.get("@input")
455+
.should("have.value", "")
456+
.should(($input) => {
457+
expect(($input[0] as HTMLInputElement).selectionStart).to.equal(0);
458+
});
459+
460+
cy.get("@mcb").realPress("ArrowRight");
461+
462+
cy.get("@mcb")
463+
.shadow()
464+
.find("[ui5-tokenizer]")
465+
.find("[ui5-token]")
466+
.last()
467+
.should("have.focus");
468+
});
469+
});
470+
185471
describe("Accessibility", () => {
186472
it("should announce the associated label when MultiComboBox is focused", () => {
187473
const label = "MultiComboBox aria-label";

packages/main/src/MultiComboBox.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
isSpaceCtrl,
1818
isSpaceShift,
1919
isRight,
20+
isLeft,
2021
isHome,
2122
isEnd,
2223
isTabNext,
@@ -734,16 +735,30 @@ class MultiComboBox extends UI5Element implements IFormInputElement {
734735
return this.placeholder || "";
735736
}
736737

737-
_handleArrowLeft() {
738+
// If the input is focused and the cursor is at the beginning/end of the input,
739+
// focus the last token if the direction is LTR/ RTL
740+
get _shouldFocusLastToken(): boolean {
738741
const inputDomRef = this._inputDom;
739742
const cursorPosition = inputDomRef.selectionStart || 0;
740743
const isTextSelected = ((inputDomRef.selectionEnd || 0) - cursorPosition) > 0;
741744

742-
if (cursorPosition === 0 && !isTextSelected) {
745+
return cursorPosition === 0 && !isTextSelected;
746+
}
747+
748+
_handleArrowKey(direction: string) {
749+
if (this._shouldFocusLastToken && this.effectiveDir === direction) {
743750
this._tokenizer._focusLastToken();
744751
}
745752
}
746753

754+
_handleArrowLeft() {
755+
this._handleArrowKey("ltr");
756+
}
757+
758+
_handleArrowRight() {
759+
this._handleArrowKey("rtl");
760+
}
761+
747762
_onPopoverFocusOut() {
748763
if (!isPhone()) {
749764
this._tokenizer.expanded = this.open;
@@ -829,6 +844,7 @@ class MultiComboBox extends UI5Element implements IFormInputElement {
829844

830845
if (
831846
e.key === "ArrowLeft"
847+
|| e.key === "ArrowRight"
832848
|| e.key === "Show"
833849
|| e.key === "PageUp"
834850
|| e.key === "PageDown"
@@ -1327,7 +1343,7 @@ class MultiComboBox extends UI5Element implements IFormInputElement {
13271343
}
13281344

13291345
_onTokenizerKeydown(e: KeyboardEvent) {
1330-
if (isRight(e)) {
1346+
if ((isRight(e) && this.effectiveDir === "ltr") || (isLeft(e) && this.effectiveDir === "rtl")) {
13311347
const lastTokenIndex = this._tokenizer.tokens.length - this._tokenizer.overflownTokens.length - 1;
13321348

13331349
if (e.target === this._tokenizer.tokens[lastTokenIndex]) {

0 commit comments

Comments
 (0)