JS30. Задание 6 Type Ahead


Вот это невразумительное желтое нечто - функция поиска, реализованная через js. В верхнее поле нужно ввести текст, а внизу покажутся все совпадения. Причём, чем больше текста вводишь, тем меньше остаётся совпадений, то есть поиск происходит по мере набора текста. Ну и источник данных для поиска, по-видимому, понадобится откуда-то подключить. Посмотрим.

Ага, оказывается, список городов уже дан изначально в виде ссылки, которая ведёт на страницу с объектом с перечнем  городов США и некоторых их характеристик вроде населения, штата и т.д.

{ "city": "New York", 
  "growth_from_2000_to_2013": "4.8%", 
  "latitude": 40.7127837, 
  "longitude": -74.0059413, 
  "population": "8405837", 
  "rank": "1", 
  "state": "New York" }, 

Где взять такую ссылку история умалчивает.
Создаём переменную endpoint и помещаем в неё ссылку на наш объект

Первая строка кода

var endpoint = "https://gist.githubusercontent.com/Miserlou/c5cd8364bf9b2420bb29/raw/2bf258763cdddd704f8ffd3ea9a3e81d25e2c6f6/cities.json"

Затем создаём пустой массив cities, и туда, очевидно, будем помещать города из нашего объекта

var cities = [];

Так как массив находится где-то в интернете, получить из него данные так просто не получится. Нужен асинхронный запрос, который будет выполняться параллельно с выполнением кода и не будет тормозить его.
Хорошая статья про асинхронные запросы Разбираемся с промисами: основы

Отправляем запрос на сервер

fetch(endpoint)

Метод fetch при вызове возвращает промис подробнее
Проверим это

var prom = fetch(endpoint);
console.log(prom);



Промис - это обещание
Мы надеемся, что запрос вернёт нам то, что мы запрашиваем.
И действительно, при вызове метода fetch к нам придёт какой-то результат. Или положительный - файл, который мы запрашивали, или отрицательный - уведомление об ошибке. В этом задании ошибки мы не обрабатываем (вообще, нужно, конечно)

.then(blob => blob.json())
.then(data => cities.push(...data));

Функция в первом then, когда этот промис выполнится (если вообще успешно выполнится), в качестве аргумента получит его результат в виде объекта Response (https://developer.mozilla.org/ru/docs/Web/API/Response).

fetch(endpoint).then(blob => console.log(blob));


Этот объект, помимо самого ответа на запрос, содержит дополнительные свойства и методы, в том числе json(), который аналогичен JSON.parse — преобразует json в js объект, но отличается тем, что возвращает не сам объект, а промис.
blob знает выполнился ли запрос, но не знает, что этот запрос вернул. Музыку? Картинку? Текст?

На этот промис ставится следующий обработчик (вторая строка), получающий в аргумент результат предыдущего промиса, то есть готовый js объект (в нашем случае — массив), и который автор просто копирует в свой массив cities.

Другими словами, если мы знаем, что данные, которые мы откуда–то запрашиваем с помощью fetch, должны придти в формате json, то в обработчике результата фетча мы вызываем метод json() (и возвращаем его результат), а в следующем обработчике получаем уже преобразованный из формата json объект и что–то с ним делаем.

Вот эта чудная конструкция ...data называется оператор расширения или spread. Появился он в ЕS6 и означает все аргументы. Хорошее и понятное описание действия этого оператора есть здесь.
P.S. кстати, прекрасный блог по ссылке https://abraxabra.ru/blog/

Ок. Самое сложное позади. Мы получили массив cities со списком городов.
В массиве 1000 городов и выглядит он так:
  1. 0:{city: "New York", growth_from_2000_to_2013: "4.8%", latitude: 40.7127837, longitude: -74.0059413, population: "8405837", …}
  2. 1:{city: "Los Angeles", growth_from_2000_to_2013: "4.8%", latitude: 34.0522342, longitude: -118.2436849, population: "3884307", …}
  3. 2:{city: "Chicago", growth_from_2000_to_2013: "-6.1%", latitude: 41.8781136, longitude: -87.6297982, population: "2718782", …} ...
Создаём функцию findMatches, которая получит в качестве аргументов наш массив и слово wordToMatch, которое будет вводить пользователь.
Внутри функции добавляем фильтр, который позволит определить есть ли в массиве слово, которое вводит пользователь.
Для поиска используем регулярное выражение.
Так как в регулярное выражение нам нужно передать переменную, создаём его через  RegExp, и добавляем флаги gi: global + ignore case - искать все совпадения, искать в любом регистре.
В массиве cities для каждого элемента массива place вызываем функцию
place.city.match(regex) или place.state.match(regex), которая возвращает массив из всех совпадений слова, которое вводит пользователь и названий городов/штатов в исходном массиве.

function findMatches(wordToMatch, cities) {
    return cities.filter(place => {
        var regex = new RegExp(wordToMatch, 'gi');
        return place.city.match(regex) || place.state.match(regex)
    });
}


Создадим функцию displayMatches, которая позволит изменять список городов, по мере ввода слова для поиска.

Создаём две переменные

var searchInput = document.querySelector(".search");
var suggestions = document.querySelector(".suggestions");


searchInput - это окно для ввода поискового запроса, suggestions - список городов.
searchInput  добавляем два события: "change" и "keyup".

Событие change происходит по окончании изменении значения элемента формы.

Для текстовых элементов это означает, что событие произойдёт не при каждом вводе, а при потере фокуса. Например, пока вы набираете что-то в текстовом поле – события нет. Но как только вы уведёте фокус на другой элемент, например, нажмёте кнопку – произойдет событие change

Но мы ведь хотим, чтобы поиск осуществлялся по мере набора текста. Для этого и понадобилось ещё одно событие:  "keyup", которое происходит когда мы отпустили клавишу на клавиатуре (а перед этим нажали её, т.е ввели какую-то букву в поле).

searchInput.addEventListener("change", displayMatches);
searchInput.addEventListener("keyup", displayMatches); 


Оба эти события вызывают функцию displayMatches.


function displayMatches() {
    var matchArray = findMatches(this.value, cities);
    var html = matchArray.map(place => {
        const regex = new RegExp(this.value, "gi");
        const cityName = place.city.replace(regex, `<span class="hl">${this.value}</span>`);
        const stateName = place.state.replace(regex, `<span class="hl">${this.value}</span>`);
        return `
<li>
<span class="name">${cityName}, ${stateName}</span>
<span class="population">${numberWithCommas(place.population)}</span>
</li>
`;
    }).join("");
    suggestions.innerHTML = html;
}


Внутри функции создаём переменную matchArray, которой присваиваем функцию findMatches передавая в неё два параметра: this.value и cities.

this.value - это значение инпута для ввода текста, так как функция вызывается при слушателем searchInput. 
cities - массив с городами.

выполним команду

console.log(matchArray);

По мере ввода текста видим как уменьшается размер массива


Это именно то, что нам нужно.

Внутри функции создаём переменную html, присваиваем ей значение нашей функции matchArray, результатом выполнения которой является массив, применяем к нему метод map и для каждого элемента массива возвращаем html-код - пункт списка, в котором указаны название города, название штата, население.

var html = matchArray.map(place => {
                return `
<li>
<span class="name">${cityName}, ${stateName}</span>
<span class="population">${numberWithCommas(place.population)}</span>
</li>
`;


suggestions.innerHTML = html;
}

Строка suggestions.innerHTML = html; добавляет этот html-код к списку городов.

function displayMatches() {
    var matchArray = findMatches(this.value, cities);
    var html = matchArray.map(place => {
       
        return `
<li>
<span class="name">${place.city}, ${place.state}</span>
<span class="population">${numberWithCommas(place.population)}</span>
</li>
`    });
    suggestions.innerHTML = html; 
                             
}


Код работает идеально, по мере набора текста список уменьшается, оставляя только нужные города в которыъ есть набранные буквы.

Добавив к массиву .join(""); мы превратим его в одну большую строку.

осталось последнее - научить скрипт выделять цветом буквы, которые вводятся в поле поиска.

Ещё раз создаём регулярное выражение

const regex = new RegExp(this.value, "gi");

Создаём переменные cityName и stateName, которым присваиваем place.city и place.state с методом .replace, заменяющим найденные совпадения на них же, но в обрамлении span с классом class="hl" для которого в css указано свойство

.hl {
      background:#ffc600;
    }


var cityName = place.city.replace(regex, `<span class="hl">${this.value}</span>`);
var stateName = place.state.replace(regex, `<span class="hl">${this.value}</span>`);


Эти переменные передаём в var html вместо place.city и place.state.

Последний штрих - функция, добавляющая запятые после каждых трёх цифр в количестве жителей города или штата

function numberWithCommas(x) {
    return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}


Код полностью:

var endpoint = "https://gist.githubusercontent.com/Miserlou/c5cd8364bf9b2420bb29/raw/2bf258763cdddd704f8ffd3ea9a3e81d25e2c6f6/cities.json";

var cities = [];
fetch(endpoint)
    .then(blob => blob.json())
    .then(data => cities.push(...data));


function findMatches(wordToMatch, cities) {
    return cities.filter(place => {
        var regex = new RegExp(wordToMatch, "gi");
        return place.city.match(regex) || place.state.match(regex)
    });
}

function numberWithCommas(x) {
    return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}

function displayMatches() {
    var matchArray = findMatches(this.value, cities);
    var html = matchArray.map(place => {
        const regex = new RegExp(this.value, "gi");
        var cityName = place.city.replace(regex, `<span class="hl">${this.value}</span>`);
        var stateName = place.state.replace(regex, `<span class="hl">${this.value}</span>`);
        return `
<li>
<span class="name">${cityName}, ${stateName}</span>
<span class="population">${numberWithCommas(place.population)}</span>
</li>
`;
    }).join("");
    suggestions.innerHTML = html;
}

var searchInput = document.querySelector(".search");
var suggestions = document.querySelector(".suggestions");

searchInput.addEventListener("change", displayMatches);
searchInput.addEventListener("keyup", displayMatches);