Skip to content

Commit

Permalink
feat(ui5-input): implement type ahead (autocomplete) (#5211)
Browse files Browse the repository at this point in the history
* feat(ui5-input): implement type ahead (autocomplete)

* feat(ui5-input): typeahead

add property, refactor filters

* feat(ui5-input): typeahead
refactor filters to be used by all input components

* feat(ui5-input): typeahead

retrigger tests
  • Loading branch information
ndeshev authored May 25, 2022
1 parent 0946207 commit ec44888
Show file tree
Hide file tree
Showing 8 changed files with 193 additions and 52 deletions.
6 changes: 3 additions & 3 deletions packages/main/src/ComboBox.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
isHome,
isEnd,
} from "@ui5/webcomponents-base/dist/Keys.js";
import * as Filters from "./ComboBoxFilters.js";
import * as Filters from "./Filters.js";

import {
VALUE_STATE_SUCCESS,
Expand Down Expand Up @@ -598,7 +598,7 @@ class ComboBox extends UI5Element {
}

_startsWithMatchingItems(str) {
return Filters.StartsWith(str, this._filteredItems);
return Filters.StartsWith(str, this._filteredItems, "text");
}

_clearFocus() {
Expand Down Expand Up @@ -845,7 +845,7 @@ class ComboBox extends UI5Element {

_filterItems(str) {
const itemsToFilter = this.items.filter(item => !item.isGroupItem);
const filteredItems = (Filters[this.filter] || Filters.StartsWithPerTerm)(str, itemsToFilter);
const filteredItems = (Filters[this.filter] || Filters.StartsWithPerTerm)(str, itemsToFilter, "text");

// Return the filtered items and their group items
return this.items.filter((item, idx, allItems) => ComboBox._groupItemFilter(item, ++idx, allItems, filteredItems) || filteredItems.indexOf(item) !== -1);
Expand Down
40 changes: 0 additions & 40 deletions packages/main/src/ComboBoxFilters.js

This file was deleted.

28 changes: 28 additions & 0 deletions packages/main/src/Filters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const escapeReg = /[[\]{}()*+?.\\^$|]/g;

const escapeRegExp = str => {
return str.replace(escapeReg, "\\$&");
};

const StartsWithPerTerm = (value, items, propName) => {
const reg = new RegExp(`(^|\\s)${escapeRegExp(value.toLowerCase())}.*`, "g");

return items.filter(item => {
const text = item[propName];

reg.lastIndex = 0;

return reg.test(text.toLowerCase());
});
};

const StartsWith = (value, items, propName) => items.filter(item => item[propName].toLowerCase().startsWith(value.toLowerCase()));
const Contains = (value, items, propName) => items.filter(item => item[propName].toLowerCase().includes(value.toLowerCase()));
const None = (_, items) => items;

export {
StartsWithPerTerm,
StartsWith,
Contains,
None,
};
109 changes: 107 additions & 2 deletions packages/main/src/Input.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js";
import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js";
import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js";
import { isIE, isPhone, isSafari } from "@ui5/webcomponents-base/dist/Device.js";
import {
isIE,
isPhone,
isSafari,
isAndroid,
} from "@ui5/webcomponents-base/dist/Device.js";
import ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js";
import { getFeature } from "@ui5/webcomponents-base/dist/FeaturesRegistry.js";
import {
Expand Down Expand Up @@ -35,6 +40,7 @@ import Icon from "./Icon.js";
// Templates
import InputTemplate from "./generated/templates/InputTemplate.lit.js";
import InputPopoverTemplate from "./generated/templates/InputPopoverTemplate.lit.js";
import * as Filters from "./Filters.js";

import {
VALUE_STATE_SUCCESS,
Expand Down Expand Up @@ -212,6 +218,18 @@ const metadata = {
type: Boolean,
},

/**
* Defines whether the value will be autcompleted to match an item
*
* @type {boolean}
* @defaultvalue false
* @public
* @since 1.4.0
*/
noTypeahead: {
type: Boolean,
},

/**
* Defines the HTML type of the component.
* Available options are: <code>Text</code>, <code>Email</code>,
Expand Down Expand Up @@ -597,6 +615,9 @@ class Input extends UI5Element {
// The last value confirmed by the user with "ENTER"
this.lastConfirmedValue = "";

// The value that the user is typed in the input
this.valueBeforeAutoComplete = "";

// Indicates, if the user pressed the BACKSPACE key.
this._backspaceKeyDown = false;

Expand Down Expand Up @@ -652,6 +673,25 @@ class Input extends UI5Element {
} else if (this.name) {
console.warn(`In order for the "name" property to have effect, you should also: import "@ui5/webcomponents/dist/features/InputElementsFormSupport.js";`); // eslint-disable-line
}

const value = this.value;
const innerInput = this.getInputDOMRefSync();

if (!innerInput || !value) {
return;
}

const autoCompletedChars = innerInput.selectionEnd - innerInput.selectionStart;

// Typehead causes issues on Android devices, so we disable it for now
// If there is already a selection the autocomplete has already been performed
if (this._shouldAutocomplete && !isAndroid() && !autoCompletedChars && !this._isKeyNavigation) {
const item = this._getFirstMatchingItem(value);

// Keep the original typed in text intact
this.valueBeforeAutoComplete += value.slice(this.valueBeforeAutoComplete.length, value.length);
this._handleTypeAhead(item, value);
}
}

async onAfterRendering() {
Expand All @@ -671,6 +711,9 @@ class Input extends UI5Element {
}

_onkeydown(event) {
this._isKeyNavigation = true;
this._shouldAutocomplete = !this.noTypeahead && !(isBackSpace(event) || isDelete(event) || isEscape(event));

if (isUp(event)) {
return this._handleUp(event);
}
Expand Down Expand Up @@ -721,6 +764,7 @@ class Input extends UI5Element {
}

this._keyDown = true;
this._isKeyNavigation = false;
}

_onkeyup(event) {
Expand Down Expand Up @@ -761,6 +805,21 @@ class Input extends UI5Element {
_handleEnter(event) {
const itemPressed = !!(this.Suggestions && this.Suggestions.onEnter(event));

// Check for autocompleted item
const matchingItem = this.suggestionItems.find(item => {
return (item.text && item.text === this.value) || (item.textContent === this.value);
});

if (matchingItem) {
const itemText = matchingItem.text ? matchingItem.text : matchingItem.textContent;

this.getInputDOMRefSync().setSelectionRange(itemText.length, itemText.length);
if (!itemPressed) {
this.selectSuggestion(matchingItem, true);
this.open = false;
}
}

if (!itemPressed) {
this.fireEventByAction(this.ACTION_ENTER);
this.lastConfirmedValue = this.value;
Expand Down Expand Up @@ -806,6 +865,8 @@ class Input extends UI5Element {
_handleEscape() {
const hasSuggestions = this.showSuggestions && !!this.Suggestions;
const isOpen = hasSuggestions && this.open;
const innerInput = this.getInputDOMRefSync();
const isAutoCompleted = innerInput.selectionEnd - innerInput.selectionStart > 0;

if (!isOpen) {
this.value = this.lastConfirmedValue ? this.lastConfirmedValue : this.previousValue;
Expand All @@ -814,12 +875,18 @@ class Input extends UI5Element {

if (hasSuggestions && isOpen && this.Suggestions._isItemOnTarget()) {
// Restore the value.
this.value = this.valueBeforeItemPreview;
this.value = this.valueBeforeAutoComplete || this.valueBeforeItemPreview;

// Mark that the selection has been canceled, so the popover can close
// and not reopen, due to receiving focus.
this.suggestionSelectionCanceled = true;
this.focused = true;

return;
}

if (isAutoCompleted) {
this.value = this.valueBeforeAutoComplete;
}

if (this._isValueStateFocused) {
Expand All @@ -831,9 +898,11 @@ class Input extends UI5Element {
async _onfocusin(event) {
await this.getInputDOMRef();

this.valueBeforeAutoComplete = "";
this.focused = true; // invalidating property
this.previousValue = this.value;
this.valueBeforeItemPreview = this.value;
this._shouldAutocomplete = false;

this._inputIconFocused = event.target && event.target === this.querySelector("[ui5-icon]");
}
Expand Down Expand Up @@ -992,6 +1061,38 @@ class Input extends UI5Element {
this.isTyping = true;
}

_startsWithMatchingItems(str) {
const textProp = this.suggestionItems[0].text ? "text" : "textContent";
return Filters.StartsWith(str, this.suggestionItems, textProp);
}

_getFirstMatchingItem(current) {
if (!this.suggestionItems.length) {
return;
}

const matchingItems = this._startsWithMatchingItems(current).filter(item => !item.groupItem);

if (matchingItems.length) {
return matchingItems[0];
}
}

_handleTypeAhead(item, filterValue) {
if (!item) {
return;
}

const value = item.text ? item.text : item.textContent || "";
const innerInput = this.getInputDOMRefSync();

filterValue = filterValue || "";
this.value = value;

innerInput.value = value;
innerInput.setSelectionRange(filterValue.length, value.length);
}

_handleResize() {
this._inputWidth = this.offsetWidth;
}
Expand Down Expand Up @@ -1119,8 +1220,12 @@ class Input extends UI5Element {
*/
updateValueOnPreview(item) {
const noPreview = item.type === "Inactive" || item.group;
const innerInput = this.getInputDOMRefSync();
const itemValue = noPreview ? this.valueBeforeItemPreview : (item.effectiveTitle || item.textContent);

this.value = itemValue;
innerInput.value = itemValue;
innerInput.setSelectionRange(this.valueBeforeAutoComplete.length, this.value.length);
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/main/src/MultiComboBox.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import ResponsivePopover from "./ResponsivePopover.js";
import List from "./List.js";
import StandardListItem from "./StandardListItem.js";
import ToggleButton from "./ToggleButton.js";
import * as Filters from "./ComboBoxFilters.js";
import * as Filters from "./Filters.js";
import Button from "./Button.js";
import {
VALUE_STATE_SUCCESS,
Expand Down Expand Up @@ -1082,7 +1082,7 @@ class MultiComboBox extends UI5Element {
}

_filterItems(str) {
return (Filters[this.filter] || Filters.StartsWithPerTerm)(str, this.items);
return (Filters[this.filter] || Filters.StartsWithPerTerm)(str, this.items, "text");
}

_afterOpenPicker() {
Expand Down
1 change: 1 addition & 0 deletions packages/main/src/features/InputSuggestions.js
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,7 @@ class Suggestions {
this.component.focused = false;
this.component.hasSuggestionItemSelected = false;
this.selectedItemIndex = null;
this.component.value = this.component.valueBeforeAutoComplete;

items && this._scrollItemIntoView(items[0]);
this._deselectItems();
Expand Down
10 changes: 10 additions & 0 deletions packages/main/test/pages/Input.html
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,16 @@ <h3>Input with long suggestions with valueState and ui5-li</h3>
<ui5-li>Condensed</ui5-li>
</ui5-input>

<h3>Input with disabled autocomplete (type-ahead)</h3>
<ui5-input id="input-disabled-autocomplete" show-suggestions no-typeahead="true">
<ui5-li>Cozy</ui5-li>
<ui5-li>Extra long text used as a list item. Extra long text used as a list item -2.Extra long text used as a list item. Extra long text used as a list item -2.Extra long text used as a list item. Extra long text used as a list item -2.Extra long text used as a list item. Extra long text used as a list item -2.Extra long text used as a list item. Extra long text used as a list item -2.Extra long text used as a list item. Extra long text used as a list item -2. text used as a list item -2.Extra long text used as a list item. Extra long text used as a list item -2.Extra long text used as a list item. Extra long text used as a list item -2.Extra long text used as a list item. Extra long text used as a list item -2.Extra long text used as a list item. Extra long text used as a list item -2.</ui5-li>
<ui5-li>Condensed</ui5-li>
<ui5-li>CozyTwo</ui5-li>
<ui5-li>Compact</ui5-li>
<ui5-li>Condensed</ui5-li>
</ui5-input>

<h3> 'change' event result</h3>
<ui5-input id="inputResult"></ui5-input>

Expand Down
Loading

0 comments on commit ec44888

Please sign in to comment.