blob: 6121134566287e818035facc9aae977653d3f8dd [file] [log] [blame]
/**
* @module modules/suggest-input-sk
* @description A custom element that implements regex and substring match
* suggestions. These are selectable via click or up/down/enter.
*
* @attr {Boolean} accept-custom-value - Whether users can enter values not listed
* in this.options.
*
* @event value-changed - Any time the user selected or inputted value is
* committed. Event is of the form { value: <newValue> }
*/
import { $$ } from 'common-sk/modules/dom';
import { define } from 'elements-sk/define';
import { html } from 'lit-html';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
const DOWN_ARROW = '40';
const UP_ARROW = '38';
const ENTER = '13';
export class SuggestInputSk extends ElementSk {
private _options: string[] = [];
private _suggestions: string[] = [];
private _suggestionSelected: number = -1;
private _label: string = '';
constructor() {
super(SuggestInputSk.template);
this._upgradeProperty('options');
this._upgradeProperty('acceptCustomValue');
this._upgradeProperty('label');
}
// TODO(westont): We should probably use input-sk here.
private static template = (ele: SuggestInputSk) => html`
<div class=suggest-input-container>
<input class=suggest-input autocomplete=off required
@focus=${ele._refresh}
@input=${ele._refresh}
@keyup=${ele._keyup}
@blur=${ele._blur}>
</input>
<label class="suggest-label">${ele.label}</label>
<div class=suggest-underline-container>
<div class=suggest-underline></div>
<div class=suggest-underline-background ></div>
</div>
<div class=suggest-list
?hidden=${!(ele._suggestions && ele._suggestions.length > 0)}
@click=${ele._suggestionClick}>
<ul>
${ele._suggestions.map((s, i) => (ele._suggestionSelected === i
? SuggestInputSk.selectedOptionTemplate(s) : SuggestInputSk.optionTemplate(s)))}
</ul>
</div>
</div>
`;
// tabindex so the fields populate FocusEvent.relatedTarget on blur.
private static optionTemplate = (option: string) => html`
<li tabindex=-1 class=suggestion>${option}</li>
`;
private static selectedOptionTemplate = (option: string) => html`
<li tabindex=-1 class="suggestion selected">${option}</li>
`;
connectedCallback(): void {
super.connectedCallback();
this._render();
}
/**
* @prop {string} value - Content of the input element from typing,
* selection, etc.
*/
get value(): string {
// We back our value with input.value directly, to avoid issues with the
// input value changing without changing our value property, causing
// element re-rendering to be skipped.
return ($$('input', this) as HTMLInputElement).value;
}
set value(v: string) {
($$('input', this) as HTMLInputElement).value = v;
}
/**
* @prop {Array<string>} options - Values for suggestion list.
*/
get options(): string[] {
return this._options;
}
set options(o: string[]) {
this._options = o;
}
/**
* @prop {Boolean} acceptCustomValue - Mirrors the
* 'accept-custom-value' attribute.
*/
get acceptCustomValue(): boolean {
return this.hasAttribute('accept-custom-value');
}
set acceptCustomValue(val: boolean) {
if (val) {
this.setAttribute('accept-custom-value', '');
} else {
this.removeAttribute('accept-custom-value');
}
}
/**
* @prop string label - Label to display to guide user input.
*/
get label(): string {
return this._label;
}
set label(o: string) {
this._label = o;
}
_blur(e: MouseEvent): void {
// Ignore if this blur is preceding _suggestionClick.
const blurredElem = e.relatedTarget as HTMLElement;
if (blurredElem && blurredElem.classList.contains('suggestion')) {
return;
}
this._commit();
}
_commit(): void {
if (this._suggestionSelected > -1) {
this.value = this._suggestions[this._suggestionSelected];
} else if (!this._options.includes(this.value) && !this.acceptCustomValue) {
this.value = '';
}
this._suggestions = [];
this._suggestionSelected = -1;
this._render();
this.dispatchEvent(new CustomEvent('value-changed',
{ bubbles: true, detail: { value: this.value } }));
}
_keyup(e: KeyboardEvent): void {
// Allow the user to scroll through suggestions using arrow keys.
const len = this._suggestions.length;
const key = e.key || e.code;
if ((key === 'ArrowDown' || key === DOWN_ARROW) && len > 0) {
this._suggestionSelected = (this._suggestionSelected + 1) % len;
this._render();
} else if ((key === 'ArrowUp' || key === UP_ARROW) && len > 0) {
this._suggestionSelected = (this._suggestionSelected + len - 1) % len;
this._render();
} else if (key === 'Enter' || key === ENTER) {
// This also commits the current selection (if present) or custom
// value (if allowed).
($$('input', this) as HTMLInputElement).dispatchEvent(
new Event('blur', { bubbles: true, cancelable: true }),
);
}
}
_refresh(): void {
const v = this.value;
let re: {test: (str: string)=> boolean; };
try {
re = new RegExp(v, 'i'); // case-insensitive.
} catch (err) {
// If the user enters an invalid expression, just use substring
// match.
re = {
test: function(str: string) {
return str.indexOf(v) !== -1;
},
};
}
this._suggestions = this._options.filter((s) => re.test(s));
this._suggestionSelected = -1;
this._render();
}
_suggestionClick(e: Event): void {
const item = e.target as HTMLElement;
if (item.tagName !== 'LI') {
return;
}
const index = Array.from(item.parentNode!.children).indexOf(item);
this._suggestionSelected = index;
this._commit();
}
}
define('suggest-input-sk', SuggestInputSk);