Skip to content

Commit dc2ae6d

Browse files
authored
feat(ui5-multiinput, ui5-multi-combobox): implement keyboard handling (#2166)
Navigation between the tokens is now possible with the Arrow keys both in MultiComboBox and MultiInput components.
1 parent 904da0e commit dc2ae6d

File tree

9 files changed

+175
-25
lines changed

9 files changed

+175
-25
lines changed

packages/main/src/Input.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -625,10 +625,15 @@ class Input extends UI5Element {
625625
}
626626

627627
async _onfocusin(event) {
628+
const inputDomRef = await this.getInputDOMRef();
629+
630+
if (event.target !== inputDomRef) {
631+
return;
632+
}
633+
628634
this.focused = true; // invalidating property
629635
this.previousValue = this.value;
630636

631-
await this.getInputDOMRef();
632637
this._inputIconFocused = event.target && event.target === this.querySelector("[ui5-icon]");
633638
}
634639

packages/main/src/MultiComboBox.hbs

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
@ui5-token-delete="{{_tokenDelete}}"
1717
@focusout="{{_tokenizerFocusOut}}"
1818
@click={{_click}}
19+
@keydown="{{_onTokenizerKeydown}}"
1920
?expanded="{{_tokenizerExpanded}}"
2021
>
2122
{{#each items}}

packages/main/src/MultiComboBox.js

+46-12
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js";
22
import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js";
33
import ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js";
44
import {
5-
isShow, isDown, isBackSpace, isSpace,
5+
isShow,
6+
isDown,
7+
isBackSpace,
8+
isSpace,
9+
isLeft,
10+
isRight,
611
} from "@ui5/webcomponents-base/dist/Keys.js";
712
import "@ui5/webcomponents-icons/dist/icons/slim-arrow-down.js";
813
import { isIE, isPhone } from "@ui5/webcomponents-base/dist/Device.js";
@@ -444,13 +449,33 @@ class MultiComboBox extends UI5Element {
444449
this.fireSelectionChange();
445450
}
446451

447-
_tokenizerFocusOut() {
452+
_handleLeft() {
453+
const cursorPosition = this.getDomRef().querySelector(`input`).selectionStart;
454+
455+
if (cursorPosition === 0) {
456+
this._focusLastToken();
457+
}
458+
}
459+
460+
_focusLastToken() {
461+
const lastTokenIndex = this._tokenizer.tokens.length - 1;
462+
463+
if (lastTokenIndex < 0) {
464+
return;
465+
}
466+
467+
this._tokenizer.tokens[lastTokenIndex].focus();
468+
this._tokenizer._itemNav.currentIndex = lastTokenIndex;
469+
}
470+
471+
_tokenizerFocusOut(event) {
448472
const tokenizer = this.shadowRoot.querySelector("[ui5-tokenizer]");
449473
const tokensCount = tokenizer.tokens.length - 1;
450474

451-
tokenizer.tokens.forEach(token => { token.selected = false; });
452-
453-
this._tokenizer.scrollToStart();
475+
if (!event.relatedTarget || event.relatedTarget.localName !== "ui5-token") {
476+
this._tokenizer.tokens.forEach(token => { token.selected = false; });
477+
this._tokenizer.scrollToStart();
478+
}
454479

455480
if (tokensCount === 0 && this._deleting) {
456481
setTimeout(() => {
@@ -468,6 +493,10 @@ class MultiComboBox extends UI5Element {
468493
}
469494

470495
async _onkeydown(event) {
496+
if (isLeft(event)) {
497+
this._handleLeft(event);
498+
}
499+
471500
if (isShow(event) && !this.readonly && !this.disabled) {
472501
event.preventDefault();
473502
this._toggleRespPopover();
@@ -483,17 +512,22 @@ class MultiComboBox extends UI5Element {
483512
if (isBackSpace(event) && event.target.value === "") {
484513
event.preventDefault();
485514

515+
this._focusLastToken();
516+
}
517+
518+
this._keyDown = true;
519+
}
520+
521+
_onTokenizerKeydown(event) {
522+
if (isRight(event)) {
486523
const lastTokenIndex = this._tokenizer.tokens.length - 1;
487524

488-
if (lastTokenIndex < 0) {
489-
return;
525+
if (this._tokenizer.tokens[lastTokenIndex] === document.activeElement.shadowRoot.activeElement) {
526+
setTimeout(() => {
527+
this.shadowRoot.querySelector("input").focus();
528+
}, 0);
490529
}
491-
492-
this._tokenizer.tokens[lastTokenIndex].focus();
493-
this._tokenizer._itemNav.currentIndex = lastTokenIndex;
494530
}
495-
496-
this._keyDown = true;
497531
}
498532

499533
_filterItems(value) {

packages/main/src/MultiInput.hbs

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
.popoverMinWidth={{_inputWidth}}
88
?expanded="{{expandedTokenizer}}"
99
show-more
10+
@keydown="{{_onTokenizerKeydown}}"
1011
@show-more-items-press={{showMorePress}}
1112
@token-delete={{tokenDelete}}
1213
@focusout="{{_tokenizerFocusOut}}"

packages/main/src/MultiInput.js

+59-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js";
2-
import { isShow } from "@ui5/webcomponents-base/dist/Keys.js";
2+
import {
3+
isShow,
4+
isBackSpace,
5+
isLeft,
6+
isRight,
7+
} from "@ui5/webcomponents-base/dist/Keys.js";
38
import Input from "./Input.js";
49
import MultiInputTemplate from "./generated/templates/MultiInputTemplate.lit.js";
510
import styles from "./generated/themes/MultiInput.css.js";
@@ -120,6 +125,13 @@ class MultiInput extends Input {
120125
return [Input.styles, styles];
121126
}
122127

128+
constructor() {
129+
super();
130+
131+
// Prevent suggestions' opening.
132+
this._skipOpenSuggestions = false;
133+
}
134+
123135
valueHelpPress(event) {
124136
this.closePopover();
125137
this.fireEvent("value-help-trigger", {});
@@ -147,6 +159,7 @@ class MultiInput extends Input {
147159

148160
_tokenizerFocusOut(event) {
149161
if (!this.contains(event.relatedTarget)) {
162+
this.tokenizer._tokens.forEach(token => { token.selected = false; });
150163
this.tokenizer.scrollToStart();
151164
}
152165
}
@@ -164,11 +177,55 @@ class MultiInput extends Input {
164177
_onkeydown(event) {
165178
super._onkeydown(event);
166179

180+
if (isLeft(event)) {
181+
this._skipOpenSuggestions = true; // Prevent input focus when navigating through the tokens.
182+
183+
return this._handleLeft(event);
184+
}
185+
186+
this._skipOpenSuggestions = false;
187+
if (isBackSpace(event) && event.target.value === "") {
188+
event.preventDefault();
189+
190+
this._focusLastToken();
191+
}
192+
167193
if (isShow(event)) {
168194
this.valueHelpPress();
169195
}
170196
}
171197

198+
_onTokenizerKeydown(event) {
199+
if (isRight(event)) {
200+
const lastTokenIndex = this.tokenizer._tokens.length - 1;
201+
202+
if (this.tokenizer._tokens[lastTokenIndex] === document.activeElement) {
203+
setTimeout(() => {
204+
this.focus();
205+
}, 0);
206+
}
207+
}
208+
}
209+
210+
_handleLeft() {
211+
const cursorPosition = this.getDomRef().querySelector(`input`).selectionStart;
212+
213+
if (cursorPosition === 0) {
214+
this._focusLastToken();
215+
}
216+
}
217+
218+
_focusLastToken() {
219+
const lastTokenIndex = this.tokenizer._tokens.length - 1;
220+
221+
if (lastTokenIndex < 0) {
222+
return;
223+
}
224+
225+
this.tokenizer._itemNav.currentIndex = lastTokenIndex;
226+
this.tokenizer._tokens[lastTokenIndex].focus();
227+
}
228+
172229
_onfocusout(event) {
173230
super._onfocusout(event);
174231
const relatedTarget = event.relatedTarget;
@@ -185,7 +242,7 @@ class MultiInput extends Input {
185242
const valueHelpPressed = this._valueHelpIconPressed;
186243
const nonEmptyValue = this.value !== "";
187244

188-
return parent && nonEmptyValue && !valueHelpPressed;
245+
return parent && nonEmptyValue && !valueHelpPressed && !this._skipOpenSuggestions;
189246
}
190247

191248
lastItemDeleted() {

packages/main/src/Token.hbs

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<div
22
tabindex="{{_tabIndex}}"
3-
@click="{{_select}}"
3+
@click="{{_handleSelect}}"
44
@keydown="{{_keydown}}"
55
class="ui5-token--wrapper"
66
dir="{{effectiveDir}}"

packages/main/src/Token.js

+29-7
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js";
33
import { getTheme } from "@ui5/webcomponents-base/dist/config/Theme.js";
44
import {
55
isBackSpace,
6-
isEnter,
76
isSpace,
87
isDelete,
98
} from "@ui5/webcomponents-base/dist/Keys.js";
@@ -52,6 +51,20 @@ const metadata = {
5251
* @private
5352
*/
5453
overflows: { type: Boolean },
54+
55+
/** Defines whether the <code>ui5-token</code> is selected or not.
56+
*
57+
* @type {boolean}
58+
* @public
59+
*/
60+
selected: { type: Boolean },
61+
62+
/**
63+
* Defines the tabIndex of the component.
64+
* @type {string}
65+
* @private
66+
*/
67+
_tabIndex: { type: String, defaultValue: "-1", noAttribute: true },
5568
},
5669

5770
events: /** @lends sap.ui.webcomponents.main.Token.prototype */ {
@@ -70,6 +83,14 @@ const metadata = {
7083
"delete": { type: Boolean },
7184
},
7285
},
86+
87+
/**
88+
* Fired when the a <code>ui5-token</code> is selected by user interaction with mouse or clicking space.
89+
*
90+
* @event
91+
* @public
92+
*/
93+
select: {},
7394
},
7495
};
7596

@@ -114,10 +135,10 @@ class Token extends UI5Element {
114135
this.i18nBundle = getI18nBundle("@ui5/webcomponents");
115136
}
116137

117-
_select() {
138+
_handleSelect() {
139+
this.selected = !this.selected;
118140
this.fireEvent("select");
119-
this.selected = true;
120-
}
141+
}
121142

122143
_delete() {
123144
this.fireEvent("delete");
@@ -136,9 +157,10 @@ class Token extends UI5Element {
136157
});
137158
}
138159

139-
if (isEnter(event) || isSpace(event)) {
140-
this.fireEvent("select", {});
141-
this.selected = true;
160+
if (isSpace(event)) {
161+
event.preventDefault();
162+
163+
this._handleSelect();
142164
}
143165
}
144166

packages/main/src/Tokenizer.hbs

+4-1
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
class="{{classes.wrapper}}"
33
>
44
<span id="{{_id}}-hiddenText" class="ui5-hidden-text">{{tokenizerLabel}}</span>
5-
5+
66
<div
77
class="{{classes.content}}"
88
@ui5-delete="{{_tokenDelete}}"
9+
@click="{{_click}}"
10+
@mousedown="{{_onmousedown}}"
11+
@keydown="{{_onkeydown}}"
912
role="listbox"
1013
aria-labelledby="{{_id}}-hiddenText"
1114
>

packages/main/src/Tokenizer.js

+28-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import ItemNavigation from "@ui5/webcomponents-base/dist/delegate/ItemNavigation
55
import ScrollEnablement from "@ui5/webcomponents-base/dist/delegate/ScrollEnablement.js";
66
import Integer from "@ui5/webcomponents-base/dist/types/Integer.js";
77
import { fetchI18nBundle, getI18nBundle } from "@ui5/webcomponents-base/dist/i18nBundle.js";
8+
import { isSpace } from "@ui5/webcomponents-base/dist/Keys.js";
89
import ResponsivePopover from "./ResponsivePopover.js";
910
import List from "./List.js";
1011
import StandardListItem from "./StandardListItem.js";
@@ -108,7 +109,7 @@ class Tokenizer extends UI5Element {
108109
super();
109110

110111
this._resizeHandler = this._handleResize.bind(this);
111-
this._itemNav = new ItemNavigation(this);
112+
this._itemNav = new ItemNavigation(this, { currentIndex: "-1" });
112113
this._itemNav.getItemsCallback = this._getVisibleTokens.bind(this);
113114
this._scrollEnablement = new ScrollEnablement(this);
114115
this.i18nBundle = getI18nBundle("@ui5/webcomponents");
@@ -181,6 +182,32 @@ class Tokenizer extends UI5Element {
181182
this.fireEvent("token-delete", { ref: token });
182183
}
183184

185+
_onkeydown(event) {
186+
if (isSpace(event)) {
187+
event.preventDefault();
188+
189+
this._handleTokenSelection(event);
190+
}
191+
}
192+
193+
_click(event) {
194+
this._handleTokenSelection(event);
195+
}
196+
197+
_onmousedown(event) {
198+
this._itemNav.update(event.target);
199+
}
200+
201+
_handleTokenSelection(event) {
202+
if (event.target.localName === "ui5-token") {
203+
this._tokens.forEach(token => {
204+
if (token !== event.target) {
205+
token.selected = false;
206+
}
207+
});
208+
}
209+
}
210+
184211
/* Keyboard handling */
185212

186213
_updateAndFocus() {

0 commit comments

Comments
 (0)