From dc2ae6da2335b8f2e276ac2bb2d0dd06a846e95e Mon Sep 17 00:00:00 2001 From: Ivaylo Plashkov Date: Fri, 4 Sep 2020 18:50:14 +0300 Subject: [PATCH] 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. --- packages/main/src/Input.js | 7 +++- packages/main/src/MultiComboBox.hbs | 1 + packages/main/src/MultiComboBox.js | 58 +++++++++++++++++++++------ packages/main/src/MultiInput.hbs | 1 + packages/main/src/MultiInput.js | 61 ++++++++++++++++++++++++++++- packages/main/src/Token.hbs | 2 +- packages/main/src/Token.js | 36 +++++++++++++---- packages/main/src/Tokenizer.hbs | 5 ++- packages/main/src/Tokenizer.js | 29 +++++++++++++- 9 files changed, 175 insertions(+), 25 deletions(-) diff --git a/packages/main/src/Input.js b/packages/main/src/Input.js index 4e1f5f912038..9a15d0b155de 100644 --- a/packages/main/src/Input.js +++ b/packages/main/src/Input.js @@ -625,10 +625,15 @@ class Input extends UI5Element { } async _onfocusin(event) { + const inputDomRef = await this.getInputDOMRef(); + + if (event.target !== inputDomRef) { + return; + } + this.focused = true; // invalidating property this.previousValue = this.value; - await this.getInputDOMRef(); this._inputIconFocused = event.target && event.target === this.querySelector("[ui5-icon]"); } diff --git a/packages/main/src/MultiComboBox.hbs b/packages/main/src/MultiComboBox.hbs index 04ee6efb0d9b..d1f820b0e399 100644 --- a/packages/main/src/MultiComboBox.hbs +++ b/packages/main/src/MultiComboBox.hbs @@ -16,6 +16,7 @@ @ui5-token-delete="{{_tokenDelete}}" @focusout="{{_tokenizerFocusOut}}" @click={{_click}} + @keydown="{{_onTokenizerKeydown}}" ?expanded="{{_tokenizerExpanded}}" > {{#each items}} diff --git a/packages/main/src/MultiComboBox.js b/packages/main/src/MultiComboBox.js index 8f69518b2943..c61cc9193053 100644 --- a/packages/main/src/MultiComboBox.js +++ b/packages/main/src/MultiComboBox.js @@ -2,7 +2,12 @@ import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js"; import ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js"; import { - isShow, isDown, isBackSpace, isSpace, + isShow, + isDown, + isBackSpace, + isSpace, + isLeft, + isRight, } from "@ui5/webcomponents-base/dist/Keys.js"; import "@ui5/webcomponents-icons/dist/icons/slim-arrow-down.js"; import { isIE, isPhone } from "@ui5/webcomponents-base/dist/Device.js"; @@ -444,13 +449,33 @@ class MultiComboBox extends UI5Element { this.fireSelectionChange(); } - _tokenizerFocusOut() { + _handleLeft() { + const cursorPosition = this.getDomRef().querySelector(`input`).selectionStart; + + if (cursorPosition === 0) { + this._focusLastToken(); + } + } + + _focusLastToken() { + const lastTokenIndex = this._tokenizer.tokens.length - 1; + + if (lastTokenIndex < 0) { + return; + } + + this._tokenizer.tokens[lastTokenIndex].focus(); + this._tokenizer._itemNav.currentIndex = lastTokenIndex; + } + + _tokenizerFocusOut(event) { const tokenizer = this.shadowRoot.querySelector("[ui5-tokenizer]"); const tokensCount = tokenizer.tokens.length - 1; - tokenizer.tokens.forEach(token => { token.selected = false; }); - - this._tokenizer.scrollToStart(); + if (!event.relatedTarget || event.relatedTarget.localName !== "ui5-token") { + this._tokenizer.tokens.forEach(token => { token.selected = false; }); + this._tokenizer.scrollToStart(); + } if (tokensCount === 0 && this._deleting) { setTimeout(() => { @@ -468,6 +493,10 @@ class MultiComboBox extends UI5Element { } async _onkeydown(event) { + if (isLeft(event)) { + this._handleLeft(event); + } + if (isShow(event) && !this.readonly && !this.disabled) { event.preventDefault(); this._toggleRespPopover(); @@ -483,17 +512,22 @@ class MultiComboBox extends UI5Element { if (isBackSpace(event) && event.target.value === "") { event.preventDefault(); + this._focusLastToken(); + } + + this._keyDown = true; + } + + _onTokenizerKeydown(event) { + if (isRight(event)) { const lastTokenIndex = this._tokenizer.tokens.length - 1; - if (lastTokenIndex < 0) { - return; + if (this._tokenizer.tokens[lastTokenIndex] === document.activeElement.shadowRoot.activeElement) { + setTimeout(() => { + this.shadowRoot.querySelector("input").focus(); + }, 0); } - - this._tokenizer.tokens[lastTokenIndex].focus(); - this._tokenizer._itemNav.currentIndex = lastTokenIndex; } - - this._keyDown = true; } _filterItems(value) { diff --git a/packages/main/src/MultiInput.hbs b/packages/main/src/MultiInput.hbs index a637b5eb0d46..462946071e08 100644 --- a/packages/main/src/MultiInput.hbs +++ b/packages/main/src/MultiInput.hbs @@ -7,6 +7,7 @@ .popoverMinWidth={{_inputWidth}} ?expanded="{{expandedTokenizer}}" show-more + @keydown="{{_onTokenizerKeydown}}" @show-more-items-press={{showMorePress}} @token-delete={{tokenDelete}} @focusout="{{_tokenizerFocusOut}}" diff --git a/packages/main/src/MultiInput.js b/packages/main/src/MultiInput.js index 20f0793fe1fe..c900a807c7a0 100644 --- a/packages/main/src/MultiInput.js +++ b/packages/main/src/MultiInput.js @@ -1,5 +1,10 @@ import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js"; -import { isShow } from "@ui5/webcomponents-base/dist/Keys.js"; +import { + isShow, + isBackSpace, + isLeft, + isRight, +} from "@ui5/webcomponents-base/dist/Keys.js"; import Input from "./Input.js"; import MultiInputTemplate from "./generated/templates/MultiInputTemplate.lit.js"; import styles from "./generated/themes/MultiInput.css.js"; @@ -120,6 +125,13 @@ class MultiInput extends Input { return [Input.styles, styles]; } + constructor() { + super(); + + // Prevent suggestions' opening. + this._skipOpenSuggestions = false; + } + valueHelpPress(event) { this.closePopover(); this.fireEvent("value-help-trigger", {}); @@ -147,6 +159,7 @@ class MultiInput extends Input { _tokenizerFocusOut(event) { if (!this.contains(event.relatedTarget)) { + this.tokenizer._tokens.forEach(token => { token.selected = false; }); this.tokenizer.scrollToStart(); } } @@ -164,11 +177,55 @@ class MultiInput extends Input { _onkeydown(event) { super._onkeydown(event); + if (isLeft(event)) { + this._skipOpenSuggestions = true; // Prevent input focus when navigating through the tokens. + + return this._handleLeft(event); + } + + this._skipOpenSuggestions = false; + if (isBackSpace(event) && event.target.value === "") { + event.preventDefault(); + + this._focusLastToken(); + } + if (isShow(event)) { this.valueHelpPress(); } } + _onTokenizerKeydown(event) { + if (isRight(event)) { + const lastTokenIndex = this.tokenizer._tokens.length - 1; + + if (this.tokenizer._tokens[lastTokenIndex] === document.activeElement) { + setTimeout(() => { + this.focus(); + }, 0); + } + } + } + + _handleLeft() { + const cursorPosition = this.getDomRef().querySelector(`input`).selectionStart; + + if (cursorPosition === 0) { + this._focusLastToken(); + } + } + + _focusLastToken() { + const lastTokenIndex = this.tokenizer._tokens.length - 1; + + if (lastTokenIndex < 0) { + return; + } + + this.tokenizer._itemNav.currentIndex = lastTokenIndex; + this.tokenizer._tokens[lastTokenIndex].focus(); + } + _onfocusout(event) { super._onfocusout(event); const relatedTarget = event.relatedTarget; @@ -185,7 +242,7 @@ class MultiInput extends Input { const valueHelpPressed = this._valueHelpIconPressed; const nonEmptyValue = this.value !== ""; - return parent && nonEmptyValue && !valueHelpPressed; + return parent && nonEmptyValue && !valueHelpPressed && !this._skipOpenSuggestions; } lastItemDeleted() { diff --git a/packages/main/src/Token.hbs b/packages/main/src/Token.hbs index d99ecdf4ef17..6ffc1997a47c 100644 --- a/packages/main/src/Token.hbs +++ b/packages/main/src/Token.hbs @@ -1,6 +1,6 @@
ui5-token is selected or not. + * + * @type {boolean} + * @public + */ + selected: { type: Boolean }, + + /** + * Defines the tabIndex of the component. + * @type {string} + * @private + */ + _tabIndex: { type: String, defaultValue: "-1", noAttribute: true }, }, events: /** @lends sap.ui.webcomponents.main.Token.prototype */ { @@ -70,6 +83,14 @@ const metadata = { "delete": { type: Boolean }, }, }, + + /** + * Fired when the a ui5-token is selected by user interaction with mouse or clicking space. + * + * @event + * @public + */ + select: {}, }, }; @@ -114,10 +135,10 @@ class Token extends UI5Element { this.i18nBundle = getI18nBundle("@ui5/webcomponents"); } - _select() { + _handleSelect() { + this.selected = !this.selected; this.fireEvent("select"); - this.selected = true; - } + } _delete() { this.fireEvent("delete"); @@ -136,9 +157,10 @@ class Token extends UI5Element { }); } - if (isEnter(event) || isSpace(event)) { - this.fireEvent("select", {}); - this.selected = true; + if (isSpace(event)) { + event.preventDefault(); + + this._handleSelect(); } } diff --git a/packages/main/src/Tokenizer.hbs b/packages/main/src/Tokenizer.hbs index 98da0ad7a026..938410a06ce8 100644 --- a/packages/main/src/Tokenizer.hbs +++ b/packages/main/src/Tokenizer.hbs @@ -2,10 +2,13 @@ class="{{classes.wrapper}}" > {{tokenizerLabel}} - +
diff --git a/packages/main/src/Tokenizer.js b/packages/main/src/Tokenizer.js index f95222a77a2d..5ff84ace22d2 100644 --- a/packages/main/src/Tokenizer.js +++ b/packages/main/src/Tokenizer.js @@ -5,6 +5,7 @@ import ItemNavigation from "@ui5/webcomponents-base/dist/delegate/ItemNavigation import ScrollEnablement from "@ui5/webcomponents-base/dist/delegate/ScrollEnablement.js"; import Integer from "@ui5/webcomponents-base/dist/types/Integer.js"; import { fetchI18nBundle, getI18nBundle } from "@ui5/webcomponents-base/dist/i18nBundle.js"; +import { isSpace } from "@ui5/webcomponents-base/dist/Keys.js"; import ResponsivePopover from "./ResponsivePopover.js"; import List from "./List.js"; import StandardListItem from "./StandardListItem.js"; @@ -108,7 +109,7 @@ class Tokenizer extends UI5Element { super(); this._resizeHandler = this._handleResize.bind(this); - this._itemNav = new ItemNavigation(this); + this._itemNav = new ItemNavigation(this, { currentIndex: "-1" }); this._itemNav.getItemsCallback = this._getVisibleTokens.bind(this); this._scrollEnablement = new ScrollEnablement(this); this.i18nBundle = getI18nBundle("@ui5/webcomponents"); @@ -181,6 +182,32 @@ class Tokenizer extends UI5Element { this.fireEvent("token-delete", { ref: token }); } + _onkeydown(event) { + if (isSpace(event)) { + event.preventDefault(); + + this._handleTokenSelection(event); + } + } + + _click(event) { + this._handleTokenSelection(event); + } + + _onmousedown(event) { + this._itemNav.update(event.target); + } + + _handleTokenSelection(event) { + if (event.target.localName === "ui5-token") { + this._tokens.forEach(token => { + if (token !== event.target) { + token.selected = false; + } + }); + } + } + /* Keyboard handling */ _updateAndFocus() {