Source: xui/AutocompleteField.js

/**
 * 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);