Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui5-input): implement type ahead (autocomplete) #5211

Merged
merged 5 commits into from
May 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -1081,7 +1081,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