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 2 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
2 changes: 1 addition & 1 deletion 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
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ const escapeRegExp = str => {

const StartsWith = (value, items) => {
return items.filter(item => {
const lowerText = item.text.toLowerCase();
const text = item.text || item.textContent;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens if a new poperty pops up next week, should we add another || ? Can we add the propName as parameter to the filter function? not big deal, just an idea

const lowerText = text.toLowerCase();

return lowerText.startsWith(value.toLowerCase());
});
Expand All @@ -16,15 +17,18 @@ const StartsWithPerTerm = (value, items) => {
const reg = new RegExp(`(^|\\s)${escapeRegExp(value.toLowerCase())}.*`, "g");

return items.filter(item => {
const text = item.text || item.textContent;

reg.lastIndex = 0;

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

const Contains = (value, items) => {
return items.filter(item => {
const lowerText = item.text.toLowerCase();
const text = item.text || item.textContent;
const lowerText = text.toLowerCase();

return lowerText.includes(value.toLowerCase());
});
Expand Down
100 changes: 98 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 true
* @public
* @since 1.4.0
*/
disableAutocomplete: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dont like that naming @ilhan007 ideas? :D

type: Boolean,
},

/**
* Defines the HTML type of the component.
* Available options are: <code>Text</code>, <code>Email</code>,
Expand Down Expand Up @@ -596,6 +614,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 @@ -651,6 +672,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._autocomplete(item, value);
}
}

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

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

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

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

_onkeyup(event) {
Expand Down Expand Up @@ -760,6 +804,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 @@ -805,6 +864,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 @@ -813,12 +874,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 @@ -830,9 +897,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 @@ -991,6 +1060,33 @@ class Input extends UI5Element {
this.isTyping = true;
}

_startsWithMatchingItems(str) {
return Filters.StartsWith(str, this.suggestionItems);
}

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

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

_autocomplete(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
2 changes: 1 addition & 1 deletion packages/main/src/MultiComboBox.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,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 {
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 disable-autocomplete="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>Cozy</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
47 changes: 42 additions & 5 deletions packages/main/test/specs/Input.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,8 @@ describe("Input general interaction", () => {
});

it("handles suggestions selection cancel with ESC", async () => {
await browser.url(`http://localhost:${PORT}/test-resources/pages/Input.html`);

const suggestionsInput = await browser.$("#myInputEsc").shadow$("input");

// act
Expand Down Expand Up @@ -504,7 +506,39 @@ describe("Input general interaction", () => {
assert.strictEqual(await innerInputError.getAttribute("aria-invalid"), "true", "aria-invalid is set to true");
});

it("Tests autocomplete(type-ahead)", async () => {
let hasSelection;

const input = await browser.$("#myInputHighlighted").shadow$("input");
const EXPTECTED_VALUE = "Adam D";

await input.click();
await input.keys("a");

hasSelection = await browser.execute(() =>{
const input = document.getElementById("myInputHighlighted").shadowRoot.querySelector("input");
return input.selectionEnd - input.selectionStart > 0;
});


assert.strictEqual(await input.getProperty("value"), EXPTECTED_VALUE, "Value is autocompleted");
assert.strictEqual(hasSelection, true, "Autocompleted text is selected");
});

it("Tests disabled autocomplete(type-ahead)", async () => {
let hasSelection;

const input = await browser.$("#input-disabled-autocomplete").shadow$("input");

await input.click();
await input.keys("c");

assert.strictEqual(await input.getProperty("value"), "c", "Value is not autocompleted");
});

it("Tests suggestions highlighting", async () => {
await browser.url(`http://localhost:${PORT}/test-resources/pages/Input.html`);

const input = await browser.$("#myInputHighlighted").shadow$("input");
const staticAreaItemClassName = await browser.getStaticAreaItemClassName("#myInputHighlighted");
const EXPTECTED_TEXT = "<b>Ad</b>am";
Expand Down Expand Up @@ -603,7 +637,7 @@ describe("Input general interaction", () => {
const suggestionsCount = await inputSuggestions.shadow$(`#${await inputSuggestions.getProperty("_id")}-suggestionsCount`);

//act
await dynamicSuggestionsInnerInput.click();
await inputDynamicSuggestions.click();

//assert
assert.strictEqual(await dynamicSuggestionsCount.getText(), "", "Suggestions count is not available");
Expand All @@ -615,6 +649,9 @@ describe("Input general interaction", () => {
assert.strictEqual(await dynamicSuggestionsCount.getText(), "4 results are available", "Suggestions count is available since value is entered");
await dynamicSuggestionsInnerInput.keys("Backspace");

// Close suggestions to not intercept the click in the input below for the last test
await dynamicSuggestionsInnerInput.keys("Escape");

//act
await inputSuggestions.click();
await (await inputSuggestions.shadow$("input")).keys("c");
Expand Down Expand Up @@ -976,7 +1013,7 @@ describe("Input END navigation", () => {
return document.getElementById("myInput2").getCaretPosition();
});

assert.strictEqual(caretPosition, 1, "Caret has been moved to end of Input");
assert.strictEqual(caretPosition, 4, "Caret has been moved to end of Input");
assert.strictEqual(await suggestionsInput.getProperty("focused"), true, "Input is focused");
assert.strictEqual(await firstListItem.getProperty("focused"), false, "First list item is not focused");
});
Expand Down Expand Up @@ -1020,7 +1057,7 @@ describe("Input PAGEUP/PAGEDOWN navigation", () => {
return document.getElementById("myInput2").getCaretPosition();
});

assert.strictEqual(caretPosition, 1, "Caret is at end of Input");
assert.strictEqual(caretPosition, 4, "Caret is at end of Input");
assert.strictEqual(await suggestionsInput.getProperty("focused"), true, "Input is focused");
assert.strictEqual(await firstListItem.getProperty("focused"), false, "Responsive popover is open and first list item is not focused");

Expand All @@ -1030,7 +1067,7 @@ describe("Input PAGEUP/PAGEDOWN navigation", () => {
return document.getElementById("myInput2").getCaretPosition();
});

assert.strictEqual(caretPosition, 1, "Caret is still at end of Input");
assert.strictEqual(caretPosition, 4, "Caret is still at end of Input");
assert.strictEqual(await suggestionsInput.getProperty("focused"), true, "Input is focused");
assert.strictEqual(await firstListItem.getProperty("focused"), false, "Responsive popover remains open and first list item is not focused");

Expand All @@ -1040,7 +1077,7 @@ describe("Input PAGEUP/PAGEDOWN navigation", () => {
return document.getElementById("myInput2").getCaretPosition();
});

assert.strictEqual(caretPosition, 1, "Caret is still at end of Input");
assert.strictEqual(caretPosition, 4, "Caret is still at end of Input");
assert.strictEqual(await suggestionsInput.getProperty("focused"), true, "Input is focused");
assert.strictEqual(await firstListItem.getProperty("focused"), false, "Responsive popover remains open and first list item is not focused");

Expand Down