Bläddra i källkod

Add an autocomplete component

JC Brand 5 år sedan
förälder
incheckning
60b3f7ae25
3 ändrade filer med 426 tillägg och 345 borttagningar
  1. 7 6
      spec/autocomplete.js
  2. 73 0
      src/components/autocomplete.js
  3. 346 339
      src/converse-autocomplete.js

+ 7 - 6
spec/autocomplete.js

@@ -58,7 +58,7 @@
             textarea.value = '@';
             view.onKeyUp(at_event);
 
-            expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(4);
+            await u.waitUntil(() => view.el.querySelectorAll('.suggestion-box__results li').length === 4);
             expect(view.el.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
             expect(view.el.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('harry');
             expect(view.el.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('jane');
@@ -100,7 +100,7 @@
             }
             view.onKeyDown(tab_event);
             view.onKeyUp(tab_event);
-            expect(view.el.querySelector('.suggestion-box__results').hidden).toBeFalsy();
+            await u.waitUntil(() => view.el.querySelector('.suggestion-box__results').hidden === false);
             expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(1);
             expect(view.el.querySelector('.suggestion-box__results li').textContent).toBe('some1');
 
@@ -115,7 +115,7 @@
                 textarea.value = textarea.value.slice(0, textarea.value.length-1)
                 view.onKeyUp(backspace_event);
             }
-            expect(view.el.querySelector('.suggestion-box__results').hidden).toBeTruthy();
+            await u.waitUntil(() => view.el.querySelector('.suggestion-box__results').hidden === true);
 
             presence = $pres({
                     'to': 'romeo@montague.lit/orchard',
@@ -132,7 +132,7 @@
             textarea.value = "hello s s";
             view.onKeyDown(tab_event);
             view.onKeyUp(tab_event);
-            expect(view.el.querySelector('.suggestion-box__results').hidden).toBeFalsy();
+            await u.waitUntil(() => view.el.querySelector('.suggestion-box__results').hidden === false);
             expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(2);
 
             const up_arrow_event = {
@@ -170,10 +170,11 @@
             textarea.value = "hello z";
             view.onKeyDown(tab_event);
             view.onKeyUp(tab_event);
+            await u.waitUntil(() => view.el.querySelector('.suggestion-box__results').hidden === false);
 
             view.onKeyDown(tab_event);
             view.onKeyUp(tab_event);
-            expect(textarea.value).toBe('hello @z3r0 ');
+            await u.waitUntil(() => textarea.value === 'hello @z3r0 ');
             done();
         }));
 
@@ -212,7 +213,7 @@
             view.onKeyDown(backspace_event);
             textarea.value = "hello @some1"; // Mimic backspace
             view.onKeyUp(backspace_event);
-            expect(view.el.querySelector('.suggestion-box__results').hidden).toBeFalsy();
+            await u.waitUntil(() => view.el.querySelector('.suggestion-box__results').hidden === false);
             expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(1);
             expect(view.el.querySelector('.suggestion-box__results li').textContent).toBe('some1');
             done();

+ 73 - 0
src/components/autocomplete.js

@@ -0,0 +1,73 @@
+import { AutoComplete, FILTER_CONTAINS, FILTER_STARTSWITH } from "../converse-autocomplete.js";
+import { CustomElement } from './element.js';
+import { html } from 'lit-element';
+
+
+export class AutoCompleteComponent extends CustomElement {
+
+    static get properties () {
+        return {
+            'getAutoCompleteList': { type: Function },
+            'auto_evaluate': { type: Boolean },
+            'auto_first': { type: Boolean }, // Should the first element be automatically selected?
+            'filter': { type: String },
+            'include_triggers': { type: String },
+            'min_chars': { type: Number },
+            'name': { type: String },
+            'placeholder': { type: String },
+            'triggers': { type: String },
+        }
+    }
+
+    constructor () {
+        super();
+        this.auto_evaluate = true; // Should evaluation happen automatically without any particular key as trigger?
+        this.auto_first = false; // Should the first element be automatically selected?
+        this.filter = 'contains';
+        this.include_triggers = ''; // Space separated chars which should be included in the returned value
+        this.match_current_word = false; // Match only the current word, otherwise all input is matched
+        this.max_items = 10;
+        this.min_chars = 1;
+        this.triggers = ''; // String of space separated chars
+    }
+
+    render () {
+        return html`
+            <div class="suggestion-box suggestion-box__name">
+                <ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul>
+                <input type="text" name="${this.name}"
+                       autocomplete="off"
+                       @keydown=${this.onKeyDown}
+                       @keyup=${this.onKeyUp}
+                       class="form-control suggestion-box__input"
+                       placeholder="${this.placeholder}"/>
+                <span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span>
+            </div>
+        `;
+    }
+
+    firstUpdated () {
+        this.auto_complete = new AutoComplete(this.firstElementChild, {
+            'ac_triggers': this.triggers.split(' '),
+            'auto_evaluate': this.auto_evaluate,
+            'auto_first': this.auto_first,
+            'filter': this.filter == 'contains' ? FILTER_CONTAINS : FILTER_STARTSWITH,
+            'include_triggers': [],
+            'list': () => this.getAutoCompleteList(),
+            'match_current_word': true,
+            'max_items': this.max_items,
+            'min_chars': this.min_chars,
+        });
+        this.auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false));
+    }
+
+    onKeyDown (ev) {
+        this.auto_complete.onKeyDown(ev);
+    }
+
+    onKeyUp (ev) {
+        this.auto_complete.evaluate(ev);
+    }
+}
+
+window.customElements.define('converse-autocomplete', AutoCompleteComponent);

+ 346 - 339
src/converse-autocomplete.js

@@ -12,404 +12,411 @@ import converse from "@converse/headless/converse-core";
 const u = converse.env.utils;
 
 
-converse.plugins.add("converse-autocomplete", {
+export const FILTER_CONTAINS = function (text, input) {
+    return RegExp(helpers.regExpEscape(input.trim()), "i").test(text);
+};
 
-    initialize () {
-        const { _converse } = this;
 
-        _converse.FILTER_CONTAINS = function (text, input) {
-            return RegExp(helpers.regExpEscape(input.trim()), "i").test(text);
-        };
+export const FILTER_STARTSWITH = function (text, input) {
+    return RegExp("^" + helpers.regExpEscape(input.trim()), "i").test(text);
+};
 
-        _converse.FILTER_STARTSWITH = function (text, input) {
-            return RegExp("^" + helpers.regExpEscape(input.trim()), "i").test(text);
-        };
 
-        const SORT_BYLENGTH = function (a, b) {
-            if (a.length !== b.length) {
-                return a.length - b.length;
-            }
-            return a < b? -1 : 1;
-        };
-
-        const ITEM = (text, input) => {
-            input = input.trim();
-            const element = document.createElement("li");
-            element.setAttribute("aria-selected", "false");
-
-            const regex = new RegExp("("+input+")", "ig");
-            const parts = input ? text.split(regex) : [text];
-            parts.forEach((txt) => {
-                if (input && txt.match(regex)) {
-                    const match = document.createElement("mark");
-                    match.textContent = txt;
-                    element.appendChild(match);
-                } else {
-                    element.appendChild(document.createTextNode(txt));
-                }
-            });
-            return element;
-        };
-
-
-        class Suggestion extends String {
+const SORT_BYLENGTH = function (a, b) {
+    if (a.length !== b.length) {
+        return a.length - b.length;
+    }
+    return a < b? -1 : 1;
+};
+
+
+const ITEM = (text, input) => {
+    input = input.trim();
+    const element = document.createElement("li");
+    element.setAttribute("aria-selected", "false");
+
+    const regex = new RegExp("("+input+")", "ig");
+    const parts = input ? text.split(regex) : [text];
+    parts.forEach((txt) => {
+        if (input && txt.match(regex)) {
+            const match = document.createElement("mark");
+            match.textContent = txt;
+            element.appendChild(match);
+        } else {
+            element.appendChild(document.createTextNode(txt));
+        }
+    });
+    return element;
+};
 
-            constructor (data) {
-                super();
-                const o = Array.isArray(data)
-                    ? { label: data[0], value: data[1] }
-                    : typeof data === "object" && "label" in data && "value" in data ? data : { label: data, value: data };
 
-                this.label = o.label || o.value;
-                this.value = o.value;
-            }
+const helpers = {
 
-            get lenth () {
-                return this.label.length;
-            }
+    getElement (expr, el) {
+        return typeof expr === "string"? (el || document).querySelector(expr) : expr || null;
+    },
 
-            toString () {
-                return "" + this.label;
+    bind (element, o) {
+        if (element) {
+            for (var event in o) {
+                if (!Object.prototype.hasOwnProperty.call(o, event)) {
+                    continue;
+                }
+                const callback = o[event];
+                event.split(/\s+/).forEach(event => element.addEventListener(event, callback));
             }
+        }
+    },
 
-            valueOf () {
-                return this.toString();
+    unbind (element, o) {
+        if (element) {
+            for (var event in o) {
+                if (!Object.prototype.hasOwnProperty.call(o, event)) {
+                    continue;
+                }
+                const callback = o[event];
+                event.split(/\s+/).forEach(event => element.removeEventListener(event, callback));
             }
         }
+    },
 
+    regExpEscape (s) {
+        return s.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&");
+    }
+}
 
-        class AutoComplete {
 
-            constructor (el, config={}) {
-                this.is_opened = false;
+class Suggestion extends String {
 
-                if (u.hasClass('.suggestion-box', el)) {
-                    this.container = el;
-                } else {
-                    this.container = el.querySelector('.suggestion-box');
-                }
-                this.input = this.container.querySelector('.suggestion-box__input');
-                this.input.setAttribute("aria-autocomplete", "list");
-
-                this.ul = this.container.querySelector('.suggestion-box__results');
-                this.status = this.container.querySelector('.suggestion-box__additions');
-
-                Object.assign(this, {
-                    'match_current_word': false, // Match only the current word, otherwise all input is matched
-                    'ac_triggers': [], // Array of keys (`ev.key`) values that will trigger auto-complete
-                    'include_triggers': [], // Array of trigger keys which should be included in the returned value
-                    'min_chars': 2,
-                    'max_items': 10,
-                    'auto_evaluate': true, // Should evaluation happen automatically without any particular key as trigger?
-                    'auto_first': false, // Should the first element be automatically selected?
-                    'data': a => a,
-                    'filter': _converse.FILTER_CONTAINS,
-                    'sort': config.sort === false ? false : SORT_BYLENGTH,
-                    'item': ITEM
-                }, config);
-
-                this.index = -1;
-
-                this.bindEvents()
-
-                if (this.input.hasAttribute("list")) {
-                    this.list = "#" + this.input.getAttribute("list");
-                    this.input.removeAttribute("list");
-                } else {
-                    this.list = this.input.getAttribute("data-list") || config.list || [];
-                }
-            }
+    constructor (data) {
+        super();
+        const o = Array.isArray(data)
+            ? { label: data[0], value: data[1] }
+            : typeof data === "object" && "label" in data && "value" in data ? data : { label: data, value: data };
 
-            bindEvents () {
-                // Bind events
-                const input = {
-                    "blur": () => this.close({'reason': 'blur'})
-                }
-                if (this.auto_evaluate) {
-                    input["input"] = () => this.evaluate();
-                }
+        this.label = o.label || o.value;
+        this.value = o.value;
+    }
 
-                this._events = {
-                    'input': input,
-                    'form': {
-                        "submit": () => this.close({'reason': 'submit'})
-                    },
-                    'ul': {
-                        "mousedown": (ev) => this.onMouseDown(ev),
-                        "mouseover": (ev) => this.onMouseOver(ev)
-                    }
-                };
-                helpers.bind(this.input, this._events.input);
-                helpers.bind(this.input.form, this._events.form);
-                helpers.bind(this.ul, this._events.ul);
-            }
+    get lenth () {
+        return this.label.length;
+    }
 
-            set list (list) {
-                if (Array.isArray(list) || typeof list === "function") {
-                    this._list = list;
-                } else if (typeof list === "string" && list.includes(",")) {
-                    this._list = list.split(/\s*,\s*/);
-                } else { // Element or CSS selector
-                    list = helpers.getElement(list);
-                    if (list && list.children) {
-                        const items = [];
-                        Array.prototype.slice.apply(list.children).forEach(function (el) {
-                            if (!el.disabled) {
-                                const text = el.textContent.trim(),
-                                    value = el.value || text,
-                                    label = el.label || text;
-                                if (value !== "") {
-                                    items.push({ label: label, value: value });
-                                }
-                            }
-                        });
-                        this._list = items;
-                    }
-                }
+    toString () {
+        return "" + this.label;
+    }
 
-                if (document.activeElement === this.input) {
-                    this.evaluate();
-                }
-            }
+    valueOf () {
+        return this.toString();
+    }
+}
 
-            get selected () {
-                return this.index > -1;
-            }
 
-            get opened () {
-                return this.is_opened;
-            }
+export class AutoComplete {
 
-            close (o) {
-                if (!this.opened) {
-                    return;
-                }
-                this.ul.setAttribute("hidden", "");
-                this.is_opened = false;
-                this.index = -1;
-                this.trigger("suggestion-box-close", o || {});
-            }
+    constructor (el, config={}) {
+        this.is_opened = false;
 
-            insertValue (suggestion) {
-                if (this.match_current_word) {
-                    u.replaceCurrentWord(this.input, suggestion.value);
-                } else {
-                    this.input.value = suggestion.value;
-                }
-            }
+        if (u.hasClass('suggestion-box', el)) {
+            this.container = el;
+        } else {
+            this.container = el.querySelector('.suggestion-box');
+        }
+        this.input = this.container.querySelector('.suggestion-box__input');
+        this.input.setAttribute("aria-autocomplete", "list");
+
+        this.ul = this.container.querySelector('.suggestion-box__results');
+        this.status = this.container.querySelector('.suggestion-box__additions');
+
+        Object.assign(this, {
+            'match_current_word': false, // Match only the current word, otherwise all input is matched
+            'ac_triggers': [], // Array of keys (`ev.key`) values that will trigger auto-complete
+            'include_triggers': [], // Array of trigger keys which should be included in the returned value
+            'min_chars': 2,
+            'max_items': 10,
+            'auto_evaluate': true, // Should evaluation happen automatically without any particular key as trigger?
+            'auto_first': false, // Should the first element be automatically selected?
+            'data': a => a,
+            'filter': FILTER_CONTAINS,
+            'sort': config.sort === false ? false : SORT_BYLENGTH,
+            'item': ITEM
+        }, config);
+
+        this.index = -1;
+
+        this.bindEvents()
+
+        if (this.input.hasAttribute("list")) {
+            this.list = "#" + this.input.getAttribute("list");
+            this.input.removeAttribute("list");
+        } else {
+            this.list = this.input.getAttribute("data-list") || config.list || [];
+        }
+    }
 
-            open () {
-                this.ul.removeAttribute("hidden");
-                this.is_opened = true;
+    bindEvents () {
+        // Bind events
+        const input = {
+            "blur": () => this.close({'reason': 'blur'})
+        }
+        if (this.auto_evaluate) {
+            input["input"] = () => this.evaluate();
+        }
 
-                if (this.auto_first && this.index === -1) {
-                    this.goto(0);
-                }
-                this.trigger("suggestion-box-open");
+        this._events = {
+            'input': input,
+            'form': {
+                "submit": () => this.close({'reason': 'submit'})
+            },
+            'ul': {
+                "mousedown": (ev) => this.onMouseDown(ev),
+                "mouseover": (ev) => this.onMouseOver(ev)
             }
+        };
+        helpers.bind(this.input, this._events.input);
+        helpers.bind(this.input.form, this._events.form);
+        helpers.bind(this.ul, this._events.ul);
+    }
 
-            destroy () {
-                //remove events from the input and its form
-                helpers.unbind(this.input, this._events.input);
-                helpers.unbind(this.input.form, this._events.form);
-                this.input.removeAttribute("aria-autocomplete");
+    set list (list) {
+        if (Array.isArray(list) || typeof list === "function") {
+            this._list = list;
+        } else if (typeof list === "string" && list.includes(",")) {
+            this._list = list.split(/\s*,\s*/);
+        } else { // Element or CSS selector
+            list = helpers.getElement(list);
+            if (list && list.children) {
+                const items = [];
+                Array.prototype.slice.apply(list.children).forEach(function (el) {
+                    if (!el.disabled) {
+                        const text = el.textContent.trim(),
+                            value = el.value || text,
+                            label = el.label || text;
+                        if (value !== "") {
+                            items.push({ label: label, value: value });
+                        }
+                    }
+                });
+                this._list = items;
             }
+        }
 
-            next () {
-                const count = this.ul.children.length;
-                this.goto(this.index < count - 1 ? this.index + 1 : (count ? 0 : -1) );
-            }
+        if (document.activeElement === this.input) {
+            this.evaluate();
+        }
+    }
 
-            previous () {
-                const count = this.ul.children.length,
-                      pos = this.index - 1;
-                this.goto(this.selected && pos !== -1 ? pos : count - 1);
-            }
+    get selected () {
+        return this.index > -1;
+    }
 
-            goto (i) {
-                // Should not be used directly, highlights specific item without any checks!
-                const list = this.ul.children;
-                if (this.selected) {
-                    list[this.index].setAttribute("aria-selected", "false");
-                }
-                this.index = i;
-
-                if (i > -1 && list.length > 0) {
-                    list[i].setAttribute("aria-selected", "true");
-                    list[i].focus();
-                    this.status.textContent = list[i].textContent;
-                    // scroll to highlighted element in case parent's height is fixed
-                    this.ul.scrollTop = list[i].offsetTop - this.ul.clientHeight + list[i].clientHeight;
-                    this.trigger("suggestion-box-highlight", {'text': this.suggestions[this.index]});
-                }
-            }
+    get opened () {
+        return this.is_opened;
+    }
 
-            select (selected) {
-                if (selected) {
-                    this.index = u.siblingIndex(selected);
-                } else {
-                    selected = this.ul.children[this.index];
-                }
-                if (selected) {
-                    const suggestion = this.suggestions[this.index];
-                    this.insertValue(suggestion);
-                    this.close({'reason': 'select'});
-                    this.auto_completing = false;
-                    this.trigger("suggestion-box-selectcomplete", {'text': suggestion});
-                }
-            }
+    close (o) {
+        if (!this.opened) {
+            return;
+        }
+        this.ul.setAttribute("hidden", "");
+        this.is_opened = false;
+        this.index = -1;
+        this.trigger("suggestion-box-close", o || {});
+    }
 
-            onMouseOver (ev) {
-                const li = u.ancestor(ev.target, 'li');
-                if (li) {
-                    this.goto(Array.prototype.slice.call(this.ul.children).indexOf(li))
-                }
-            }
+    insertValue (suggestion) {
+        if (this.match_current_word) {
+            u.replaceCurrentWord(this.input, suggestion.value);
+        } else {
+            this.input.value = suggestion.value;
+        }
+    }
 
-            onMouseDown (ev) {
-                if (ev.button !== 0) {
-                    return; // Only select on left click
-                }
-                const li = u.ancestor(ev.target, 'li');
-                if (li) {
-                    ev.preventDefault();
-                    this.select(li, ev.target);
-                }
-            }
+    open () {
+        this.ul.removeAttribute("hidden");
+        this.is_opened = true;
 
-            onKeyDown (ev) {
-                if (this.opened) {
-                    if ([converse.keycodes.ENTER, converse.keycodes.TAB].includes(ev.keyCode) && this.selected) {
-                        ev.preventDefault();
-                        ev.stopPropagation();
-                        this.select();
-                        return true;
-                    } else if (ev.keyCode === converse.keycodes.ESCAPE) {
-                        this.close({'reason': 'esc'});
-                        return true;
-                    } else if ([converse.keycodes.UP_ARROW, converse.keycodes.DOWN_ARROW].includes(ev.keyCode)) {
-                        ev.preventDefault();
-                        ev.stopPropagation();
-                        this[ev.keyCode === converse.keycodes.UP_ARROW ? "previous" : "next"]();
-                        return true;
-                    }
-                }
+        if (this.auto_first && this.index === -1) {
+            this.goto(0);
+        }
+        this.trigger("suggestion-box-open");
+    }
 
-                if ([converse.keycodes.SHIFT,
-                     converse.keycodes.META,
-                     converse.keycodes.META_RIGHT,
-                     converse.keycodes.ESCAPE,
-                     converse.keycodes.ALT
-                    ].includes(ev.keyCode)) {
+    destroy () {
+        //remove events from the input and its form
+        helpers.unbind(this.input, this._events.input);
+        helpers.unbind(this.input.form, this._events.form);
+        this.input.removeAttribute("aria-autocomplete");
+    }
 
-                    return;
-                }
+    next () {
+        const count = this.ul.children.length;
+        this.goto(this.index < count - 1 ? this.index + 1 : (count ? 0 : -1) );
+    }
 
-                if (this.ac_triggers.includes(ev.key)) {
-                    if (ev.key === "Tab") {
-                        ev.preventDefault();
-                    }
-                    this.auto_completing = true;
-                } else if (ev.key === "Backspace") {
-                    const word = u.getCurrentWord(ev.target, ev.target.selectionEnd-1);
-                    if (this.ac_triggers.includes(word[0])) {
-                        this.auto_completing = true;
-                    }
-                }
-            }
+    previous () {
+        const count = this.ul.children.length,
+                pos = this.index - 1;
+        this.goto(this.selected && pos !== -1 ? pos : count - 1);
+    }
 
-            evaluate (ev) {
-                const selecting = this.selected && ev && (
-                    ev.keyCode === converse.keycodes.UP_ARROW ||
-                    ev.keyCode === converse.keycodes.DOWN_ARROW
-                );
+    goto (i) {
+        // Should not be used directly, highlights specific item without any checks!
+        const list = this.ul.children;
+        if (this.selected) {
+            list[this.index].setAttribute("aria-selected", "false");
+        }
+        this.index = i;
+
+        if (i > -1 && list.length > 0) {
+            list[i].setAttribute("aria-selected", "true");
+            list[i].focus();
+            this.status.textContent = list[i].textContent;
+            // scroll to highlighted element in case parent's height is fixed
+            this.ul.scrollTop = list[i].offsetTop - this.ul.clientHeight + list[i].clientHeight;
+            this.trigger("suggestion-box-highlight", {'text': this.suggestions[this.index]});
+        }
+    }
 
-                if (!this.auto_evaluate && !this.auto_completing || selecting) {
-                    return;
-                }
+    select (selected) {
+        if (selected) {
+            this.index = u.siblingIndex(selected);
+        } else {
+            selected = this.ul.children[this.index];
+        }
+        if (selected) {
+            const suggestion = this.suggestions[this.index];
+            this.insertValue(suggestion);
+            this.close({'reason': 'select'});
+            this.auto_completing = false;
+            this.trigger("suggestion-box-selectcomplete", {'text': suggestion});
+        }
+    }
 
-                const list = typeof this._list === "function" ? this._list() : this._list;
-                if (list.length === 0) {
-                    return;
-                }
+    onMouseOver (ev) {
+        const li = u.ancestor(ev.target, 'li');
+        if (li) {
+            this.goto(Array.prototype.slice.call(this.ul.children).indexOf(li))
+        }
+    }
 
-                let value = this.match_current_word ? u.getCurrentWord(this.input) : this.input.value;
-                const contains_trigger = this.ac_triggers.includes(value[0]);
-                if (contains_trigger) {
-                    this.auto_completing = true;
-                    if (!this.include_triggers.includes(ev.key)) {
-                        value = value.slice('1');
-                    }
-                }
+    onMouseDown (ev) {
+        if (ev.button !== 0) {
+            return; // Only select on left click
+        }
+        const li = u.ancestor(ev.target, 'li');
+        if (li) {
+            ev.preventDefault();
+            this.select(li, ev.target);
+        }
+    }
 
-                if ((contains_trigger || value.length) && value.length >= this.min_chars) {
-                    this.index = -1;
-                    // Populate list with options that match
-                    this.ul.innerHTML = "";
+    onKeyDown (ev) {
+        if (this.opened) {
+            if ([converse.keycodes.ENTER, converse.keycodes.TAB].includes(ev.keyCode) && this.selected) {
+                ev.preventDefault();
+                ev.stopPropagation();
+                this.select();
+                return true;
+            } else if (ev.keyCode === converse.keycodes.ESCAPE) {
+                this.close({'reason': 'esc'});
+                return true;
+            } else if ([converse.keycodes.UP_ARROW, converse.keycodes.DOWN_ARROW].includes(ev.keyCode)) {
+                ev.preventDefault();
+                ev.stopPropagation();
+                this[ev.keyCode === converse.keycodes.UP_ARROW ? "previous" : "next"]();
+                return true;
+            }
+        }
 
-                    this.suggestions = list
-                        .map(item => new Suggestion(this.data(item, value)))
-                        .filter(item => this.filter(item, value));
+        if ([converse.keycodes.SHIFT,
+                converse.keycodes.META,
+                converse.keycodes.META_RIGHT,
+                converse.keycodes.ESCAPE,
+                converse.keycodes.ALT
+            ].includes(ev.keyCode)) {
 
-                    if (this.sort !== false) {
-                        this.suggestions = this.suggestions.sort(this.sort);
-                    }
-                    this.suggestions = this.suggestions.slice(0, this.max_items);
-                    this.suggestions.forEach(text => this.ul.appendChild(this.item(text, value)));
+            return;
+        }
 
-                    if (this.ul.children.length === 0) {
-                        this.close({'reason': 'nomatches'});
-                    } else {
-                        this.open();
-                    }
-                } else {
-                    this.close({'reason': 'nomatches'});
-                    if (!contains_trigger) {
-                        this.auto_completing = false;
-                    }
-                }
+        if (this.ac_triggers.includes(ev.key)) {
+            if (ev.key === "Tab") {
+                ev.preventDefault();
+            }
+            this.auto_completing = true;
+        } else if (ev.key === "Backspace") {
+            const word = u.getCurrentWord(ev.target, ev.target.selectionEnd-1);
+            if (this.ac_triggers.includes(word[0])) {
+                this.auto_completing = true;
             }
         }
+    }
 
-        // Make it an event emitter
-        Object.assign(AutoComplete.prototype, Events);
+    async evaluate (ev) {
+        const selecting = this.selected && ev && (
+            ev.keyCode === converse.keycodes.UP_ARROW ||
+            ev.keyCode === converse.keycodes.DOWN_ARROW
+        );
 
+        if (!this.auto_evaluate && !this.auto_completing || selecting) {
+            return;
+        }
 
-        const helpers = {
+        const list = typeof this._list === "function" ? await this._list() : this._list;
+        if (list.length === 0) {
+            return;
+        }
 
-            getElement (expr, el) {
-                return typeof expr === "string"? (el || document).querySelector(expr) : expr || null;
-            },
+        let value = this.match_current_word ? u.getCurrentWord(this.input) : this.input.value;
+        const contains_trigger = this.ac_triggers.includes(value[0]);
+        if (contains_trigger) {
+            this.auto_completing = true;
+            if (!this.include_triggers.includes(ev.key)) {
+                value = value.slice('1');
+            }
+        }
 
-            bind (element, o) {
-                if (element) {
-                    for (var event in o) {
-                        if (!Object.prototype.hasOwnProperty.call(o, event)) {
-                            continue;
-                        }
-                        const callback = o[event];
-                        event.split(/\s+/).forEach(event => element.addEventListener(event, callback));
-                    }
-                }
-            },
+        if ((contains_trigger || value.length) && value.length >= this.min_chars) {
+            this.index = -1;
+            // Populate list with options that match
+            this.ul.innerHTML = "";
 
-            unbind (element, o) {
-                if (element) {
-                    for (var event in o) {
-                        if (!Object.prototype.hasOwnProperty.call(o, event)) {
-                            continue;
-                        }
-                        const callback = o[event];
-                        event.split(/\s+/).forEach(event => element.removeEventListener(event, callback));
-                    }
-                }
-            },
+            this.suggestions = list
+                .map(item => new Suggestion(this.data(item, value)))
+                .filter(item => this.filter(item, value));
+
+            if (this.sort !== false) {
+                this.suggestions = this.suggestions.sort(this.sort);
+            }
+            this.suggestions = this.suggestions.slice(0, this.max_items);
+            this.suggestions.forEach(text => this.ul.appendChild(this.item(text, value)));
 
-            regExpEscape (s) {
-                return s.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&");
+            if (this.ul.children.length === 0) {
+                this.close({'reason': 'nomatches'});
+            } else {
+                this.open();
+            }
+        } else {
+            this.close({'reason': 'nomatches'});
+            if (!contains_trigger) {
+                this.auto_completing = false;
             }
         }
+    }
+}
+
+// Make it an event emitter
+Object.assign(AutoComplete.prototype, Events);
+
+
+converse.plugins.add("converse-autocomplete", {
 
+    initialize () {
+        const _converse = this._converse;
+        _converse.FILTER_CONTAINS = FILTER_CONTAINS;
+        _converse.FILTER_STARTSWITH = FILTER_STARTSWITH;
         _converse.AutoComplete = AutoComplete;
     }
 });
+
+