| /** |
| * @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 { html } from 'lit-html'; |
| import { $$ } from '../../../infra-sk/modules/dom'; |
| import { define } from '../../../elements-sk/modules/define'; |
| |
| 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); |