/**
* A text field supporting autocompletion "a la XXE desktop".
* <p>Part of the XUI module which, for now, has an undocumented API.
*/
export class AutocompleteField extends HTMLElement {
constructor() {
super();
this._lenientTextChecker = null;
this._onSelectionChanged = this.onSelectionChanged.bind(this);
this._onDoubleClickList = this.onDoubleClickList.bind(this);
this._onKeydownList = this.onKeydownList.bind(this);
this._field = null;
this._list = null;
this._completionKey = null;
this._completionKeyIsAlt = this._completionKeyIsCtrl =
this._completionKeyIsMeta = this._completionKeyIsShift = false;
this._attrBeingChanged = null;
}
// -----------------------------------------------------------------------
// Custom element
// -----------------------------------------------------------------------
connectedCallback() {
if (this._field === null) {
// User code may have already added its own classes.
this.classList.add("xui-control");
if (!this.hasAttribute("for")) {
this.for = null;
}
if (!this.hasAttribute("acceptmode")) {
this.acceptMode = "strict";
}
// Force parsing completion key spec.
this.completionKey = this.getAttribute("completionkey");
// Nothing to do for "singleclicktoaccept".
this._field = document.createElement("input");
// User code may have already added its own classes.
this._field.classList.add("xui-control", "xui-acfld-field");
this._field.setAttribute("type", "text");
this._field.setAttribute("autocomplete", "off");
this._field.setAttribute("spellcheck", "false");
this.appendChild(this._field);
this._field.addEventListener("keydown", this.onKeydown.bind(this));
this._field.addEventListener("input", this.onInput.bind(this));
}
}
static get observedAttributes() {
return [ "for", "acceptmode", "completionkey", "singleclicktoaccept" ];
}
attributeChangedCallback(attrName, oldVal, newVal) {
if (this._attrBeingChanged !== null) {
return;
}
switch (attrName) {
case "for":
this.for = newVal;
break;
case "acceptmode":
this.acceptMode = newVal;
break;
case "completionkey":
this.completionKey = newVal;
break;
case "singleclicktoaccept":
this.singleClickToAccept = newVal;
break;
}
}
// -----------------------------------------------------------------------
// Input events
// -----------------------------------------------------------------------
// --------------------------
// XUI.List listeners
// --------------------------
onSelectionChanged(event) {
let sel = event.xuiSelection;
if (sel < 0) {
this._field.value = "";
} else {
this._field.value = this._list.getLabel(sel);
if (event.xuiClickCount === 1 && this.singleClickToAccept) {
this.acceptSelection();
}
}
}
onDoubleClickList(event) {
if (event.button === 0) {
// Second click on primary button.
Util.consumeEvent(event);
if (!this.singleClickToAccept) {
this.acceptSelection();
}
}
}
acceptSelection() {
if (this._list === null) {
return false;
}
let acceptedText = this._field.value.trim();
if (acceptedText.length === 0) {
return false;
}
let selectedItem = null;
let selectedIndex = this._list.getSelection();
if (selectedIndex >= 0) { // (Hence cannot be disabled.)
let selectedLabel = this._list.getLabel(selectedIndex);
// Typing a prefix suffices, not matter acceptMode.
if (selectedLabel.startsWith(acceptedText)) {
selectedItem = this._list.get(selectedIndex);
this._field.value = acceptedText = selectedLabel;
}
}
if (selectedItem === null) {
// What has been typed does not match the selection, if any.
if (this.acceptMode === "lenient") {
if (this._lenientTextChecker !== null) {
let checked = false;
try {
checked = this._lenientTextChecker(acceptedText);
} catch (error) {
console.error(`XUI.AutocompleteField.lenientTextChecker \
has failed: ${error}`);
}
if (!checked) {
acceptedText = null;
}
}
// Otherwise, any non-empty text is OK.
} else {
// Strict mode.
acceptedText = null;
}
}
if (acceptedText === null) {
this._field.select();
return false;
}
let event =
new Event("selectionaccepted",
{ bubbles: true, cancelable: false, composed: true });
event.xuiAcceptedText = acceptedText;
event.xuiSelectedIndex = selectedIndex;
event.xuiSelectedItem = selectedItem;
this.dispatchEvent(event);
return true;
}
onKeydownList(event) {
if (event.key === "Enter") { // Whatever its modifiers.
Util.consumeEvent(event);
let sel = this._list.getSelection();
if (sel >= 0) {
let label = this._list.getLabel(sel);
if (label !== this._field.value) {
this._field.value = label;
// Just in case.
this._list.setAnchor(sel);
this._list.ensureIsVisible(sel);
}
this.acceptSelection();
}
}
}
// --------------------------
// Input type=text listeners
// --------------------------
onKeydown(event) {
if (this._list === null) {
return;
}
let isHotKey = true; // Whatever its modifiers.
switch (event.key) {
case "ArrowUp":
case "Up":
case "ArrowDown":
case "Down":
{
let sel = this._list.getSelection();
let label;
if (sel >= 0 &&
(label = this._list.getLabel(sel)) !== this._field.value) {
this._field.value = label;
// Just in case.
this._list.setAnchor(sel);
this._list.ensureIsVisible(sel);
} else {
if (event.key === "ArrowUp" || event.key === "Up") {
this._list.onUpKey();
} else {
this._list.onDownKey();
}
}
}
break;
case "Enter":
this.acceptSelection();
break;
default:
if (event.key === this._completionKey &&
event.altKey === this._completionKeyIsAlt &&
event.ctrlKey === this._completionKeyIsCtrl &&
event.metaKey === this._completionKeyIsMeta &&
event.shiftKey === this._completionKeyIsShift) {
this.autoComplete();
} else {
isHotKey = false;
}
break;
}
if (isHotKey) {
Util.consumeEvent(event);
}
}
autoComplete() {
if (this._list === null) {
return;
}
let prefix = this._field.value.trim();
if (prefix.length === 0) {
return;
}
let index = this.findByPrefix(prefix);
if (index < 0) {
return;
}
let curLabel = this._list.getLabel(index);
let nextLabels = [];
const count = this._list.length;
for (let i = index+1; i < count; ++i) {
let label = this._list.getLabel(i);
if (!label.startsWith(prefix)) {
// Here we assume that items are sorted by their labels.
break;
}
nextLabels.push(label);
}
let longestPrefix;
if (nextLabels.length === 0) {
longestPrefix = curLabel;
} else {
let j = prefix.length;
const curLabelLength = curLabel.length;
loop: for (; j < curLabelLength; ++j) {
let c = curLabel.charAt(j);
for (let nextLabel of nextLabels) {
if (j >= nextLabel.length ||
nextLabel.charAt(j) !== c) {
break loop;
}
}
}
longestPrefix = curLabel.substring(0, j);
}
if (longestPrefix !== prefix) {
this.text = longestPrefix; // This invokes onInput. See below.
}
}
findByPrefix(text) {
let index = -1;
const items = this._list.getAll(/*copy*/ false);
const count = items.length;
for (let i = 0; i < count; ++i) {
let item = items[i];
if (this._list.itemLabel(item).startsWith(text)) {
index = i;
break;
}
}
return index;
}
onInput(event) {
if (this._list === null) {
return;
}
let prefix = this._field.value.trim();
if (prefix.length === 0) {
this._list.clearSelection();
// Anchor is not set, so we rely on default behavior here.
this._list.ensureIsVisible(0);
return;
}
let index = this.findByPrefix(prefix);
if (index < 0) {
this._list.clearSelection();
// Be conservative. Do not scroll the list.
// Anchor is not set, so we rely on default behavior here.
} else {
if (this._list.isDisabled(index)) {
this._list.clearSelection();
} else {
this._list.setSelection(index);
}
this._list.setAnchor(index);
this._list.ensureIsVisible(index);
}
}
// -----------------------------------------------------------------------
// API
// -----------------------------------------------------------------------
get for() {
return this.getAttribute("for");
}
set for(id) {
this._attrBeingChanged = "for";
if (this._list !== null) {
this._list.removeEventListener("selectionchanged",
this._onSelectionChanged);
this._list.removeEventListener("dblclick", this._onDoubleClickList);
this._list.removeEventListener("keydown", this._onKeydownList);
this._list = null;
}
if (id !== null && (id = id.trim()).length > 0) {
this._list = document.getElementById(id);
if (this._list !== null && this._list.localName !== "xui-list") {
this._list = null;
}
}
if (this._list === null) {
this.removeAttribute("for");
} else {
this._list.selectionMode = "single";
this._list.addEventListener("selectionchanged",
this._onSelectionChanged);
this._list.addEventListener("dblclick", this._onDoubleClickList);
this._list.addEventListener("keydown", this._onKeydownList);
// Clicking on an item already selected in the list will not cause
// the selectionchanged event to be sent hence, without this
// trick, acceptSelection would not be invoked.
this._list.singleClickToAccept = this.singleClickToAccept;
this.setAttribute("for", id);
}
this._attrBeingChanged = null;
}
get acceptMode() {
return this.getAttribute("acceptmode");
}
set acceptMode(mode) {
this._attrBeingChanged = "acceptmode";
this.setAttribute("acceptmode", (mode === "lenient")? mode : "strict");
this._attrBeingChanged = null;
}
get completionKey() {
return this.getAttribute("completionkey");
}
set completionKey(spec) {
this._attrBeingChanged = "completionkey";
this._completionKey = null;
this._completionKeyIsAlt = this._completionKeyIsCtrl =
this._completionKeyIsMeta = this._completionKeyIsShift = false;
if (spec !== null && spec.length > 0) {
let segments = spec.toLowerCase().split(/[-+]/); // Do not trim.
if (segments.length > 0) {
// Key names are listed here
// https://developer.mozilla.org/
// en-US/docs/Web/API/KeyboardEvent/key/Key_Values
this._completionKey = segments[segments.length-1];
if (this._completionKey === "space") {
this._completionKey = " "; // Space.
}
segments.pop();
if (segments.length > 0) {
for (let segment of segments) {
switch (segment) {
case "alt":
this._completionKeyIsAlt = true;
break;
case "ctrl":
this._completionKeyIsCtrl = true;
break;
case "meta":
this._completionKeyIsMeta = true;
break;
case "shift":
this._completionKeyIsShift = true;
break;
case "mod":
if (Util.PLATFORM_IS_MAC_OS) {
this._completionKeyIsMeta = true;
} else {
this._completionKeyIsCtrl = true;
}
break;
}
}
}
}
}
if (this._completionKey === null) {
this.removeAttribute("completionkey");
} else {
this.setAttribute("completionkey", spec);
}
this._attrBeingChanged = null;
}
get singleClickToAccept() {
return this.hasAttribute("singleclicktoaccept");
}
set singleClickToAccept(single) {
this._attrBeingChanged = "singleclicktoaccept";
if (single) {
this.setAttribute("singleclicktoaccept", "singleclicktoaccept");
} else {
this.removeAttribute("singleclicktoaccept");
}
this._attrBeingChanged = null;
if (this._list !== null) {
this._list.singleClickToAccept = single;
}
}
get lenientTextChecker() {
return this._lenientTextChecker;
}
set lenientTextChecker(checker) {
this._lenientTextChecker = checker;
}
get text() {
return this._field.value;
}
set text(value) {
if (value === null) {
value = "";
}
this._field.value = value;
this.onInput(/*event*/ null);
}
get autofocus() {
return this._field.autofocus;
}
set autofocus(focus) {
this._field.autofocus = focus;
}
}
window.customElements.define("xui-autocomplete-field", AutocompleteField);