Autocomplete

Naszym zadaniem będzie stworzenie prostego pola z listą podpowiedzi.

W czystym HTML taką listę możemy uzyskać za pomocą znacznika datalist:


    <input list="browsers">
    <datalist id="browsers">
          <option value="Internet Explorer">
          <option value="Firefox">
          <option value="Google Chrome">
          <option value="Opera">
          <option value="Safari">
    </datalist>

Zanim zaczniemy, ściągnij paczkę z początkowym projektem, który będzie nam służył za backend. To prosty skrypt korzystający z Express, który wypluwa dane brane z plików json. Dodatkowo przygotowany jest tam prosty html, w którym umieściłem tylko jedno pole tekstowe, które będziemy poddawać obróbce.

Po ściągnięciu zainstaluj wszystko poleceniem npm i, a następnie odpal całość poleceniem npm start. Dostaniesz do użytku dwa adresy na które będziemy się łączyć. Pierwszy to http://localhost:3000/cities a drugi http://localhost:3000/users.

Klasa Autocomplete

Zacznijmy od stworzenia prostej klasy, na bazie której będziemy tworzyć pola autocomplete.


class Autocomplete {
    constructor(input, options) {
        //pobieramy pole
        this.input = input;

        //nasze opcje - ta sama technika jaką używaliśmy przy lightboxie, sliderze i innych zadaniach
        this.options = {...{
            url : ""
        }, ...options};

        this.makeHTML();
    }
}

const auto = new Autocomplete(document.querySelector("#testCities"), {
    url : "http://localhost:3000/cities"
})

Konstruktor ma za zadanie pobrać pole, które będziemy modyfikować, ustawić podstawowe opcje i wywołać funkcję makeHTML(), która utworzy strukturę html.

Funkcja makeHTML

Pierwszą z funkcji będzie makeHTML().

Tworzymy dynamicznie listę oraz podpinamy ją do pola. Do takiego podpięcia służy atrybut list, do którego przekazujemy id listy. Do ustawienia jego użyjemy zewnętrznej zmiennej autoCompleteID.


let autocompleteID = 0;

class Autocomplete {
    ...

    makeHTML() {
        const cnt = document.createElement("div");
        cnt.classList.add("autocomplete");

        this.cnt = cnt;
        this.input.after(cnt);
        cnt.append(this.input);

        const list = document.createElement("datalist");
        this.list = list;
        this.list.id = `autocompleteList-${autocompleteID++}`;
        this.input.after(list);
        this.input.setAttribute("list", this.list.id);
    }
}

Można by się tutaj pokusić o udoskonalenie nadawania takiego id. W powyższym kodzie lista dostaje id np. autocompleteList-0, autocompleteList-1 itd. A jeżeli na naszej stronie istnieje już element o takim id? Moglibyśmy dodatkowo sprawdzać czy taki element istnieje. Jeżeli tak - próbujemy dalej:


const list = document.createElement("datalist");
this.list = list;
let id = `autocompleteList-${autocompleteID++}`;
while (document.getElementById(id) !== null) {
    id = `autocompleteList-${autocompleteID++}`;
};
this.list.id = id;
this.input.after(list);
this.input.setAttribute("list", this.list.id);
}

Ja zrezygnuję z tego podejścia by nie udziwniać kodu.

Funkcja bindEvents

Podczas wpisywania będziemy odpytywać serwer o wyniki. Żeby nie robić tego za często skorzystamy z techniki debounced, którą omawialiśmy tutaj.


class Autocomplete {
    constructor() {
        ...
        this.bindEvents();
    }

    bindEvents() {
        const debounced = function(delay, fn) {
            let timerId;
            return function(...args) {
                if (timerId) {
                    clearTimeout(timerId);
                }
                timerId = setTimeout(() => {
                    fn(...args);
                    timerId = null;
                }, delay);
            }
        }

        const inputHandle = debounced(200, this.checkInputValue.bind(this));
        this.input.addEventListener("input", inputHandle);
    }
}

Do funkcji debounced przekazaliśmy funkcję this.checkInputValue, którą napiszemy za moment. Po odpaleniu zwykłej funkcji (a więc też tej, którą przekazujesz przez referencję) w jej wnętrzu this zostaje zmienione na "coś". W powyższym przypadku przekazaną przez referencję funkcję odpalamy później wewnątrz setTimeout (we wnętrzu którego this i tak już wskazuje na window - czemu?). Funkcja ta nie jest odpalana jako metoda obiektu (przed nazwą funkcji nie ma kropki i obiektu np. ob.fn(...args)), stąd this zostało by zmienione i wskazywało na window. Za pomocą bind() (linia 20) wymuszamy więc by wewnątrz funkcji słowo this wskazywało na właściwy obiekt. Hej - ale my już o tym mówiliśmy - dokładnie tutaj.

Cały powyższy kod moglibyśmy też napisać nieco prościej korzystając z funkcji strzałkowej (zauważ, że metoda checkInputValue() odpalana jest jako metoda naszego obiektu - taka mała rzecz, a zmienia wszystko):


...

bindEvents() {
    let debounce;
    const inputHandle = e => {
        clearTimeout(debounce);
        debounce = setTimeout(() => this.checkInputValue(), 200);
    }
    this.input.addEventListener("input", inputHandle);
}

...

Funkcja checkInputValue

Funkcja checkInputValue() sprawdza tylko długość wartości pola. Gdy jest odpowiednio długa, wywołamy zapytanie do serwera.


class Autocomplete {
    ...

    checkInputValue() {
        if (this.input.value.length >= 3 ) {
            this.makeRequest();
        }
    }

    ...
}

Aż się prosi aby cyfra 3 była pobierana z opcji naszej klasy.

Jeżeli chcesz, doda odpowiednią opcję w konstruktorze, a następnie zmień powyższy kod na:


...

checkInputValue() {
    if (this.input.value.length >= this.options.minValueLength ) {
        this.makeRequest();
    }
}

...

Funkcja makeRequest

Kolejną funkcją będzie ta robiąca już właściwe połączenie.

Za pomocą składni aync/await oraz fetch wykonujemy połączenie, pobiera dane, a następnie przekazuje je do funkcji fillList(), która wypełni listę odpowiednimi opcjami. W przypadku błędu rzucamy błędem.

Dodatkowo żeby zasygnalizować użytkownikowi moment wczytywania pokażemy mu ikonkę wczytywania.


class Autocomplete {
    ...

    async makeRequest() {
        this.showLoading();
        try {
            const request = await fetch(this.options.url + "?q=" + this.input.value);
            const json = await request.json();
            this.fillList(json.suggestions);
        } catch (err) {
            throw Error(err);
        } finally {
            this.hideLoading();
        }
    }

    ...
}

Do pokazania wczytywania posłużą nam dwie funkcje. Pierwsza z nich tworzy element o klasie .autocomplete-loading, z druga po prostu go usuwa. Odpowiednie stylowanie dla tego elementu znajdziesz w pliku css ściągniętej paczki.


class Autocomplete {
    ...

    showLoading() {
        if (!this.cnt.querySelector(".autocomplete-loading")) {
            const loading = document.createElement("div");
            loading.classList.add("autocomplete-loading");
            this.cnt.append(loading);
        }
    }

    hideLoading() {
        if (this.cnt.querySelector(".autocomplete-loading")) {
            this.cnt.querySelector(".autocomplete-loading").remove();
        }
    }
    ...
}

Funkcja fillList

Ostatnia funkcja posłuży do wypełnienia listy odpowiednimi opcjami.


class Autocomplete {
    ...

    fillList(data) {
        this.list.innerHTML = "";

        for (let item of data) {
            const option = document.createElement("option");
            option.value = item;
            this.list.append(option);
        }
    }

    ...
}

I tyle. Nasza klasa jest gotowa.

Cache wyników

Gdy zaczniesz wpisywać nazwę miasta, zostanie wykonane zapytanie do serwera. Jeżeli znowu zaczniesz wpisywać taką samą nazwę, nasz skrypt ponownie wykona odpowiednie zapytanie i dostanie te same wyniki. Spróbujmy to usprawnić, dodając proste zapamiętywanie danych (cache). Pobrane wyniki będziemy odkładać do zmiennej. Jeżeli użytkownik ponownie wpisze takie same dane, my zamiast robić zapytanie do serwera pobierzemy dane z naszego magazynu.


let autocompleteID = 0;

const cache = {};

class Autocomplete {
    constructor(input, options) {
        this.input = input;
        this.options = {...{
            url : "",
            cache : true
        }, ...options};
        this.makeHTML();
        this.bindEvents();
    }

    ...

    setCache(data, key) {
        cache[key] = data;
    }

    async makeRequest() {
        this.showLoading();
        try {
            const request = await fetch(this.options.url + "?q=" + this.input.value);
            const json = await request.json();
            if (this.options.cache) {
                this.setCache(json.suggestions, this.input.value);
            }
            this.fillList(json.suggestions);
        } catch (err) {
            throw Error(err);
        } finally {
            this.hideLoading();
        }
    }

    checkInputValue() {
        if (this.input.value.length >=3 ) {
            if (this.options.cache && this.cache[this.input.value]) {
                this.fillList(cache[this.input.value]);
            } else {
                this.makeRequest();
            }
        }
    }

    ...
}

Zauważ, że zmienna cache jest globalna. Założyłem, że jeżeli będziemy mieli na stronie kilka instancji naszego obiektu, wszystkie one będą korzystać ze wspólnego cache.

Demo

Własna lista rozwijana

Jako bonus zamieszczam kod dla autocomplete z listą opartą o element ul. Dzięki temu nie tylko możemy ją dowolnie stylować, ale też zmieniać zawartość jej elementów.

Ogólne działanie będzie tutaj podobne do tego co powyżej. Główna różnica wynika z tego, że domyślna lista datalist na której bazowaliśmy powyżej ma już zaimplementowaną całą funkcjonalność - np. wybieranie elementu czy poruszanie się za pomocą klawiszy. Jeżeli robimy taką listę sami, wszystko musimy też zakodować. Funkcja bindEvents() stała się więc bardziej rozbudowana. Dodatkowo zauważysz, że w kilku miejscach doszło nam wywoływanie pokazania i ukrywanie listy (funkcje showList() i hideList()) oraz zaznaczanie aktywnego elementu.


class Autocomplete {
    constructor(input, options) {
        this.input = input;
        this.selectIndex = -1; //indeks wskazuje na aktywny, podświetlony element
        this.options = {...{
            url : "",
            cache : true
        }, ...options};
        this.makeHTML();
        this.bindEvents();
    }

    makeHTML() {
        const cnt = document.createElement("div");
        cnt.classList.add("autocomplete");

        this.cnt = cnt;
        this.input.after(cnt);
        cnt.append(this.input);

        const list = document.createElement("ul");
        list.classList.add("autocomplete-list");
        list.hidden = true;
        this.list = list;
        this.input.after(list);
    }

    renderElement(inputValue, data) {
        let val = inputValue.toUpperCase();
        let temp = data.toUpperCase();
        let indexStart = temp.indexOf(val);
        let indexEnd = indexStart + inputValue.length;
        let part1 = data.substring(0, indexStart);
        let part2 = data.substring(indexStart, indexEnd);
        let part3 = data.substring(indexEnd);

        return `${part1}<b>${part2}</b>${part3}`;
    }

    fillList(data) {
        this.list.innerHTML = "";
        for (let jsonItem of data) {
            const li = document.createElement("li");
            li.classList.add("autocomplete-list-el");
            li.innerHTML = this.renderElement(this.input.value, jsonItem);
            this.list.append(li);
        }
    }

    showList() {
        this.list.hidden = false;
    }

    hideList() {
        this.selectIndex = -1;
        this.list.hidden = true;
    }

    showLoading() {
        if (!this.cnt.querySelector(".autocomplete-loading")) {
            const loading = document.createElement("div");
            loading.classList.add("autocomplete-loading");
            this.cnt.append(loading);
        }
    }

    hideLoading() {
        if (this.cnt.querySelector(".autocomplete-loading")) {
            this.cnt.querySelector(".autocomplete-loading").remove();
        }
    }

    async makeRequest() {
        this.showLoading();

        try {
            const request = await fetch(this.options.url + "?q=" + this.input.value);
            const json = await request.json();
            if (this.options.cache) {
                this.setCache(json.suggestions, this.input.value);
            }
            this.fillList(json.suggestions);
            this.showList();
        } catch (err) {
            throw Error(err);
        } finally {
            this.hideLoading();
        }
    }

    setCache(data, key) {
        cache[key] = data;
    }

    checkInputValue() {
        if (this.input.value.length >= 3) {
            if (this.options.cache && cache[this.input.value]) {
                this.fillList(cache[this.input.value]);
                this.showList();
            } else {
                this.makeRequest();
            }
        } else {
            this.hideList();
        }
    }

    markActive() {
        for (let li of this.list.children) {
            li.classList.remove("is-active");
        }
        if (this.selectIndex > -1) {
            this.list.children[this.selectIndex].classList.add("is-active");
        }
    }

    selectActive(input, selectedElement) {
        input.value = this.list.children[this.selectIndex].innerText;
    }

    bindEvents() {
        const debounced = function(delay, fn) {
            let timerId;
            return function(...args) {
                if (timerId) {
                    clearTimeout(timerId);
                }
                timerId = setTimeout(() => {
                    fn(...args);
                    timerId = null;
                }, delay);
            }
        }

        const inputHandle = debounced(200, this.checkInputValue.bind(this));
        this.input.addEventListener("input", inputHandle);

        this.input.addEventListener("keydown", e => {
            if (this.list) {
                const max = this.list.children.length;

                if (e.key === "ArrowDown") {
                    this.showList();
                    this.selectIndex++;
                    if (this.selectIndex > max - 1) {
                        this.selectIndex = 0;
                    }
                }

                if (e.key === "ArrowUp") {
                    this.showList();
                    this.selectIndex--;
                    if (this.selectIndex < 0) {
                        this.selectIndex = max - 1;
                    }
                }

                const current = this.list.children[this.selectIndex];
                if (current) {
                    if (current.offsetTop < this.list.scrollTop) {
                        this.list.scrollTop = current.offsetTop
                    }

                    if (current.offsetTop + current.offsetHeight > this.list.scrollTop + this.list.offsetHeight) {
                        this.list.scrollTop = current.offsetTop + current.offsetHeight - this.list.offsetHeight
                    }
                }

                if (e.key === "Enter") {
                    this.selectActive(this.input, this.list.children[this.selectIndex]);
                    e.preventDefault();
                    this.hideList();
                }

                if (e.key === "Escape") {
                    e.preventDefault();
                    this.hideList();
                }

                this.markActive();
            }
        })

        this.cnt.addEventListener("click", e => {
            const el = e.target.closest(".autocomplete-list-el");
            if (el) {
                const index = [...this.list.children].indexOf(el);
                this.selectIndex = index;
                this.markActive();
                this.selectActive(this.input, this.list.children[this.selectIndex]);
            }
        })

        document.addEventListener("click", e => {
            this.hideList();
        })
    }
}

Kolejnymi rzeczami różniącymi się od poprzedniego rozwiązania jest wydzielenie osobnej funkcji dla tworzenia pojedynczego elementu listy (renderElement()) oraz dla wybrania elementu (selectActive()).


...

renderElement(inputValue, data) {
    let val = inputValue.toUpperCase();
    let temp = data.toUpperCase();
    let indexStart = temp.indexOf(val);
    let indexEnd = indexStart + inputValue.length;
    let part1 = data.substring(0, indexStart);
    let part2 = data.substring(indexStart, indexEnd);
    let part3 = data.substring(indexEnd);

    return `${part1}<b>${part2}</b>${part3}`;
}

selectActive(input, selectedElement) {
    input.value = this.list.children[this.selectIndex].innerText;
}

...

Dzięki temu tworząc pojedynczy obiekt autocomplete mogę mu te funkcje nadpisać zmieniając wyświetlanie elementów na liście, ale też zmieniając zwracane dane przy wyborze elementu.


//wywołanie z domyślną listą
const auto1 = new Autocomplete(document.querySelector("#testCities"), {
    url : "http://localhost:3333/cities"
})

//a tutaj zmieniona lista z avatarem itp
const auto2 = new Autocomplete(document.querySelector("#testUsers"), {
    url : "http://localhost:3333/users"
})
auto2.renderElement = function(inputValue, data) {
    return `
        <div class="autocomplete-list-user-item">
            <div class="autocomplete-list-user-item-avatar"><img src="${data.avatar}" alt="" /></div>
            <div class="autocomplete-list-user-item-name">${data.first_name} ${data.last_name}</div>
            <div class="autocomplete-list-user-item-email">${data.email}</div>
        </div>
    `
}
auto2.selectActive = function(input, selectedElement) {
    const name = selectedElement.querySelector(".autocomplete-list-user-item-name").innerText;
    this.input.value = name;
}
Demo customowej listy

Trening czyni mistrza

Jeżeli chcesz sobie potrenować zdobytą wiedzę, zadania znajdują się w repozytorium pod adresem: https://github.com/kartofelek007/zadania

Wszelkie prawa zastrzeżone. Jeżeli chcesz używać jakiejś części tego kursu, skontaktuj się z autorem.
Aha - i ta strona korzysta z ciasteczek.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.