Отображение результатов поиска в реальном времени для пользователя, пока он набирает текст в строке поиска, является отличным пользовательским опытом. Но создание поля живого поиска со всеми функциями. Например, выделение мышью, перемещение выделения по клавишам вверх-вниз и скрытие строки поиска при размытии.
Это немного сложная задача. Итак, давайте попробуем пройти через все трудности и создать это удивительное поле живого поиска. Где пользователи могут выбирать результат с помощью мыши или клавиатуры, а также результаты будут скрыты, если мы щелкнем за пределами поля.
Что мы рассмотрим
- Настройка проекта React & Tailwind
- Создание панели поиска
- Контейнер для отображения результатов поиска
- Создание полностью настраиваемого компонента
- Условное отображение результатов
- Добавление логики живого поиска
- Выбор результатов по клавише вверх-вниз
- Стилизация сфокусированного элемента
- Выбор результатов
- Скрытие результатов по размытию
- Обновление значения внутри строки поиска
- Настройка проекта React & Tailwind
- Создание строки поиска
- Контейнер для рендеринга результатов поиска
- Создание полностью настраиваемого компонента
- Условное отображение результатов
- Добавление логики живого поиска
- Выбор результатов по клавише вверх-вниз
- Стилизация сфокусированного элемента
- Выбор результатов
- Скрытие результатов при размытии
- Обновление значения внутри строки поиска
Настройка проекта React & Tailwind
Для создания этого демо мы будем использовать, очевидно, React JS и Tailwind CSS для стилизации. Если вы хотите, вы можете использовать любой другой CSS фреймворк или библиотеку. Эта логика будет работать для всех них.
Если вы не знаете, Tailwind CSS — это фреймворк с кучей полезных имен классов для стилизации нашего HTML.
Если у вас уже есть проект, вы можете напрямую добавить эту логику в свой существующий проект. Но если вы начинаете с нуля, как я, выполните эти команды для инициализации нового проекта.
npx create-react-app live-search --template typescript
# or
npx create-react-app live-search // if you are using JavaScript
Как только вы выполните одну из команд, проект будет инициализирован. Теперь вы можете установить Tailwind CSS. Сначала измените каталог с cd live-search
и выполните эти команды.
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Затем вам нужно перейти к вашему файлу tailwind.config.js
и добавить эти вещи внутрь вашего контента.
// tailwind.config.js
module.exports = {
content: ["./src/**/*.{js,jsx,ts,tsx}",],
...
Затем перейдите в файл index.css
и замените весь код на эти три строки.
// index.css
// remove old CSS and add them
@tailwind base;
@tailwind components;
@tailwind utilities;
Создание строки поиска
Строка поиска — это просто элемент input
. Но мы добавим к ней немного стиля, чтобы она выглядела немного симпатично. Для этого создадим файл SearchBar.tsx
(или jsx) в папке src и добавим в него эти коды.
// inside SearchBar.tsx
import React, { FC } from "react";
interface Props<T> {
value?: string;
notFound?: boolean;
onChange?: React.ChangeEventHandler;
results?: T[];
renderItem: (item: T) => JSX.Element;
onSelect?: (item: T) => void;
}
const SearchBar = <T extends object>({ }: Props<T>): JSX.Element => {
return (
{/* Search bar */}
<input
type="text"
className="w-full p-2 text-lg rounded border-2 border-gray-500 outline-none"
placeholder="Search here..."
/>
);
};
Думаю, мне не нужно объяснять названия этих классов. Они сами себя объясняют. Но эти interface Props
могут выглядеть немного пугающе. Это всего лишь особенности Typescript, которые я объясню, когда они нам понадобятся. Итак, давайте отобразим этот компонент внутри нашего App.tsx
.
// inside App.tsx
const App = () => {
return (
<div className="max-w-3xl mx-auto mt-10">
<SearchBar />
</div>
);
};
export default App;
Здесь я использую фиксированную ширину для div
, так что я могу использовать этот mx-auto
для приведения этого div
в центр веб-страницы. Если вы попытаетесь запустить этот код, он выдаст ошибку. Потому что renderItem
является необходимым реквизитом. Закомментируйте его, если хотите увидеть изменения.
Контейнер для рендеринга результатов поиска
Теперь нам нужен контейнер для отображения результатов. Для этого мы обернем наш поисковый ввод внутри div
с классом relative
. А под элементом ввода мы разместим div
с классом absolute. С помощью этих классов мы отобразим контейнер поиска прямо под полем ввода.
// SearchBar.tsx
const SearchBar = <T extends object>({ }: Props<T>): JSX.Element => {
return (
<div className="relative">
<input ... />
{/* Results Wrapper */}
<div className="absolute mt-1 w-full p-2 bg-white shadow-lg rounded-bl rounded-br max-h-36 overflow-y-auto"></div>
</div>
);
};
С помощью других классов, например max-h-36 overflow-y-auto
. Он создаст контейнер с фиксированной высотой и покажет полосу прокрутки, если результаты будут длиннее.
Создание полностью настраиваемого компонента
Теперь давайте воспользуемся первым реквизитом из интерфейса. Как вы видите, мы используем немного странный синтаксис для компонентов interface
и SearchBar
.
interface Props<T> {
results?: T[];
renderItem: (item: T) => JSX.Element;
onSelect?: (item: T) => void;
}
const SearchBar = <T extends object>({}:Props<T>) ...
Это потому, что мы хотим сделать эту панель поиска настолько настраиваемой. Чтобы она могла принимать, отображать и отдавать один и тот же тип данных обратно нашим методам renderItem
и onSelect
. Теперь вам больше не нужно беспокоиться о типах. Typescript возьмет тип из results
и передаст его в renderItem
и onSelect
.
Теперь, наконец, посмотрим, как мы можем отобразить наши результаты.
// inside SearchBar.tsx
const SearchBar = <T extends object>({ results = [], renderItem }: Props<T>): JSX.Element => {
return (
<div className="relative">
{/* Search bar */}
<input ... />
{/* Results Wrapper */}
<div className="...">
{/* Results Lists Goes here */}
<div className="space-y-2">
{results.map((item, index) => {
return (
<div
key={index}
className="cursor-pointer hover:bg-black hover:bg-opacity-10 p-2"
>
{renderItem(item)}
</div>
);
})}
</div>
</div>
</div>
);
};
Прежде всего, не забудьте придать свойству results
значение по умолчанию пустой массив []
. Это позволит избежать нежелательных ошибок. Затем мы используем resutls.map
для рендеринга наших результатов. Здесь у нас есть метод renderItem
для отображения результатов. Мы должны передать этот метод из App.tsx
, что мы увидим позже.
Затем у нас есть обертка div для renderItem
с классами, hover:bg-black hover:bg-opacity-10
. Очень важно дать пользователю обратную связь о результатах наведения. Эти классы добавят background-color
черного цвета с opacity: 0.1
при наведении на результат.
Хорошо, теперь давайте попробуем передать некоторые данные нашему SearchBar
. Поскольку мы использовали общий тип <T>
, мы можем передать любой тип данных, но внутри массива.
const profiles = [
{ id: "1", name: "Allie Grater" },
{ id: "2", name: "Aida Bugg" },
{ id: "3", name: "Gabrielle" },
{ id: "4", name: "Grace" },
{ id: "5", name: "Hannah" },
{ id: "6", name: "Heather" },
{ id: "7", name: "John Doe" },
{ id: "8", name: "Anne Teak" },
{ id: "9", name: "Audie Yose" },
{ id: "10", name: "Addie Minstra" },
{ id: "11", name: "Anne Ortha" },
];
const App = () => {
return (
<div className="max-w-3xl mx-auto mt-10">
<SearchBar results={profiles} renderItem={(item) => <p>{item.name}</p>} />
</div>
);
};
Как я уже говорил, вам не нужно беспокоиться о типах для renderItem
и onSelect
. Вот результат, если вы наведете курсор на элемент, вы увидите его тип. Круто, правда?
Теперь, если вы сохраните файл и запустите проект с помощью npm start
. Вы увидите что-то вроде этого.
Условное отображение результатов
В настоящее время эти результаты видны всегда, поэтому давайте решим эту проблему. Для этого мы можем просто добавить состояние под названием showResults
и отображать результаты в зависимости от значения этого состояния.
// inside SearchBar.tsx
const SearchBar = ... {
const [showResults, setShowResults] = useState(true);
// To toggle the search results as result changes
useEffect(() => {
if (results.length > 0 && !showResults) setShowResults(true);
if (results.length <= 0) setShowResults(false);
}, [results]);
return ...
}
Логика довольно проста, если есть результаты и состояние showResults
равно false
, то сделайте его true
. В противном случае сделайте его ложным. Теперь эта логика будет скрывать и показывать результаты поиска по мере изменения длины результата.
Но для этого нам нужно добавить условие, если showResults
будет true
только тогда отображать результаты.
const SearchBar = ... {
...
return (
<div className="relative">
{/* Search bar */}
<input ... />
{/* Results Wrapper */}
{showResults ? <div className="...">
{/* Results Lists Goes here */}
<div className="space-y-2">...</div>
</div> : null}
</div>
);
};
Добавление логики живого поиска
Теперь давайте напишем небольшой фрагмент кода для добавления логики поиска.
// SearchBar.tsx
const SearchBar = <T extends object>({onChange, ... }: Props<T>): JSX.Element => {
return (...
<input
onChange={onChange}
/>
...
// App.tsx
const profiles = [...]
const App = () => {
const [results, setResults] = useState<{ id: string; name: string }[]>();
type changeHandler = React.ChangeEventHandler<HTMLInputElement>;
const handleChange: changeHandler = (e) => {
const { target } = e;
if (!target.value.trim()) return setResults([]);
const filteredValue = profiles.filter((profile) =>
profile.name.toLowerCase().startsWith(target.value)
);
setResults(filteredValue);
};
return (
<div className="max-w-3xl mx-auto mt-10">
<SearchBar
onChange={handleChange}
results={results}
renderItem={(item) => <p>{item.name}</p>}
/>
</div>
);
};
Несмотря на то, что это слишком много кода, логика проста. Сначала мы деструктурируем onChange
внутри компонента SearchBar
и назначим его элементу ввода.
Затем внутри App.tsx
мы обрабатываем событие изменения для SearchBar
. Поэтому для handleChange
мы просто используем метод array filter
, чтобы отфильтровать подходящие результаты.
Если мы нашли результаты, мы добавим их в новое состояние под названием results, а если у нас пустое поле ввода, мы сбросим эти результаты в пустой массив.
И в конце вместо profiles
мы передадим состояние results
в реквизит results
. Если вы сделаете это, то увидите результаты поиска, только если введете правильные имена профилей.
Внутри handleChange
вы также можете получить данные с сервера. Важно только то, как вы будете добавлять или сбрасывать состояние results
. Потому что если вы забыли сбросить состояние results
, результаты внутри вашего контейнера живого поиска будут видны все время.
Выбор результатов по клавише вверх-вниз
Хорошо, что у нас есть наша панель поиска, где мы можем искать и выводить результаты. Но теперь нам нужно добавить событие клавиатуры для перемещения нашего выбора вверх и вниз.
const SearchBar = ... => {
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
const { key } = e;
// move down
if (key === "ArrowDown"){ }
// move up
if (key === "ArrowUp"){ }
// hide search results
if (key === "Escape"){ }
// select the current item
if (key === "Enter"){ }
};
return (
<div
tabIndex={1}
onKeyDown={handleKeyDown}
className="relative outline-none"
>
<input ... />
...
Теперь мы хотим прослушать событие keyDown
и делаем это для обертки div
внутри компонента SearchBar
. Но мы не можем напрямую прослушивать keyDown
для div’ов. Поэтому нам нужно использовать свойство tabIndex
. Также, чтобы избежать нежелательных контуров при фокусе div (поскольку мы используем tabIndex
, будет контур), я использую класс outline-none
.
Давайте запишем логику работы с клавиатурой по порядку.
Обработка события клавиатуры не так проста. Потому что во время перемещения вверх или вниз мы должны обрабатывать множество вещей. Например, отображение правильного пользовательского интерфейса и прокрутку в правильном положении, если результатов несколько.
Поэтому для начала нам нужно состояние под названием focusedIndex
. Как вы видите, значение по умолчанию для этого состояния равно -1. Потому что позже мы будем использовать это состояние для перемещения фокуса в нужное место. И мы не хотим фокусироваться на чем-либо вначале. Так что -1 будет работать нормально.
// inside SearchBar.tsx
const SearchBar = ... => {
const [focusedIndex, setFocusedIndex] = useState(-1);
...
Теперь с помощью приведенной ниже логики мы обновим индекс фокуса с правильным временем. Также, если вы заметили, я не использую -1 или +1 для обновления нашего значения. Вместо этого мы используем оператор modulo
(%).
Это небольшая формула, которую мы можем использовать для обновления нашего подсчета только в диапазоне, соответствующем длине результатов. Так, если у вас есть результаты длиной 6 и если вы нажмете клавишу вниз, то nextCount
начнется с 0 и перейдет к 5. Если вы нажмете вверх, то все будет наоборот — начнется с 5 и перейдет к 0.
Это точное значение, которое нам нужно, чтобы быть внутри границы массива результатов. Если мы выйдем за пределы длины, то получим исключение о границе индекса.
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
let nextCount = 0;
if (key === "ArrowDown") nextCount = (focusedIndex + 1) % results.length;
if (key === "ArrowUp") nextCount = (focusedIndex + results.length - 1) % results.length;
if (key === "Escape") setShowResults(false);
if (key === "Enter") {
e.preventDefault();
}
setFocusedIndex(nextCount);
};
Кроме того, происходит еще одна вещь. Если мы нажмем клавишу Escape
, мы скроем результаты поиска. Но мы не обрабатываем Enter
, мы сделаем это позже.
Стилизация сфокусированного элемента
Теперь в соответствии с focusedIndex
нам нужно стилизовать результат. Для этого мы должны взять ссылку на конкретный результат и изменить его стиль.
// SearchBar.tsx
const SearchBar = ... => {
const resultContainer = useRef<HTMLDivElement>(null);
return (
...
{results.map((item, index) => {
return (
<div
ref={index === focusedIndex ? resultContainer : null}
style={{ backgroundColor: index === focusedIndex ? "rgba(0,0,0,0.1)" : "" }}
key={index}
className="cursor-pointer hover:bg-black hover:bg-opacity-10 p-2"
>
{renderItem(item)}
</div>
);
})}
Главное для этого div
— это ref
и реквизит style
. Что в основном мы делаем? Если index
равен focusedIndex
, мы присваиваем этот div
к ref
под названием resultContainer
, иначе мы делаем его нулевым. Почему? Вы поймете это позже.
Затем мы меняем цвет фона на черный с непрозрачностью 10%, если index
равен focusedIndex
.
Теперь с такой логикой, если вы попытаетесь переместиться вверх или вниз, вы увидите изменения, но результаты останутся только в одном месте. Они не будут прокручиваться вниз или вверх по мере обновления индекса. Чтобы исправить это, используйте следующую логику.
// SearchBar.tsx
useEffect(() => {
if (!resultContainer.current) return;
resultContainer.current.scrollIntoView({
block: "center",
});
}, [focusedIndex]);
Если есть resultContainer
, мы используем метод scrollIntoView
для прокрутки до точной точки и сделаем сфокусированный элемент в центре с опцией block: center
.
Теперь у вас должно получиться что-то вроде этого.
Выбор результатов
Одним из важных моментов в живом поиске является выбор результата, поэтому давайте посмотрим, как это сделать.
// SearchBar.tsx
...
const handleSelection = (selectedIndex: number) => {
const selectedItem = results[selectedIndex];
if (!selectedItem) resetSearchComplete();
onSelect && onSelect(selectedItem);
resetSearchComplete();
};
const resetSearchComplete = useCallback(() => {
setFocusedIndex(-1);
setShowResults(false);
}, []);
...
Теперь нам нужно обработать две вещи для выбора: сначала выбрать нужную вещь и отправить ее обратно в App.tsx
. Потому что именно этот компонент мы используем сейчас. Затем нам также нужно сбросить результаты поиска.
Для этого внутри handleSelection
мы принимаем selectedIndex
и извлекаем выбранный элемент из массива результатов. Если ничего нет, просто возвращаем, иначе вызываем onSelect
с выбранным результатом. Убедитесь, что вы деструктурируете его из реквизита.
const SearchBar = ({ onSelect, ...}) =>
А затем просто сбросьте состояния на их предыдущие значения. focusedIndex
на -1 и showResults
на false.
Теперь нам нужно добавить этот handleSelection
в двух местах. Внутри нажатия клавиши Enter и при движении мыши вниз для элемента результата.
// SearchBar.tsx
// to select on enter
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
...
if (key === "Enter") {
e.preventDefault();
handleSelection(focusedIndex);
}
setFocusedIndex(nextCount);
};
return (
...
{results.map((item, index) => {
return (
<div
onMouseDown={() => handleSelection(index)}
ref={index === focusedIndex ? resultContainer : null}
style={{ backgroundColor: index === focusedIndex ? "rgba(0,0,0,0.1)" : "" }}
...
>
{renderItem(item)}
</div>
);
Теперь, если вы попытаетесь выбрать мышью или клавиатурой, вы выберете правильный элемент. Если вы хотите увидеть это, добавьте onSelect
внутри вашего SearchBar
и выведите результат в консоль.
// App.tsx
const App = () => {
return (
<div className="max-w-3xl mx-auto mt-10">
<SearchBar
...
onSelect={(item) => console.log(item)}
/>
</div>
);
}
Скрытие результатов при размытии
Теперь, если вы думаете, почему onMouseDown
, почему не onClick
? Вместо того чтобы дать вам ответ, я покажу вам это.
Просто добавьте onBlur
к вашей верхней обертке div
внутри компонента SearchBar
. И передайте ему метод resetSearchComplete
.
// SearchBar.tsx
...
return (
<div
tabIndex={1}
onKeyDown={handleKeyDown}
onBlur={resetSearchComplete}
className="relative outline-none"
>
...
Теперь, если вы щелкните за пределами экрана, результаты поиска исчезнут, что является идеальным результатом, который мы хотим получить. Кроме того, вы увидите выбранные результаты внутри консоли (если вы их выбрали). Но теперь, если изменить onMouseDown
на onClick
, результаты исчезнут с экрана, но вы не получите выбранный элемент в консоли.
Почему так? Потому что перед onClick
сработает onBlur
. Таким образом, handleSelection
никогда не будет вызван.
Обновление значения внутри строки поиска
Здесь все выглядит хорошо. Единственное, что нам теперь нужно, это отобразить выбранное имя профиля в строке поиска. Итак, создайте состояние defaultValue
внутри SearchBar.tsx
. Затем передайте это значение элементу input
.
// SearchBar.tsx
const SearchBar = ... ({ value }) => {
const [defaultValue, setDefaultValue] = useState("");
...
return ...
<input
...
value={defaultValue}
/>
Если вы помните, мы также принимаем свойство value для нашего компонента SearchBar
. Поэтому, если есть реквизит value
, давайте присвоим его defaultValue
.
// SearchBar.tsx
useEffect(() => {
if (value) setDefaultValue(value);
}, [value]);
Теперь, если значение изменится, этот хук useEffect
обновит defaultValue
до value
.
Давайте сделаем еще одну вещь внутри App.tsx
. Добавьте состояние selectedProfile
и обновите значение внутри onSelect
. Затем передайте selectedProfile.name
в значение prop
.
// App.tsx
const App = () => {
const [selectedProfile, setSelectedProfile] = useState<{ id: string; name: string }>();
return (
...
<SearchBar
...
onSelect={(item) => setSelectedProfile(item)}
value={selectedProfile?.name}
/>
Теперь, дамы и господа, вы должны увидеть что-то вроде этого.
Но есть одна большая проблема. После выбора результата вы не можете вносить какие-либо изменения в поле ввода.
Чтобы решить эту проблему, мы должны обрабатывать событие onChange
непосредственно внутри нашего компонента SearchBar
.
// SearchBar.tsx
const SearchBar = ({ onChange }) => {
...
type changeHandler = React.ChangeEventHandler<HTMLInputElement>;
const handleChange: changeHandler = (e) => {
setDefaultValue(e.target.value);
onChange && onChange(e);
};
return (
...
{/* Search bar */}
<input
onChange={handleChange}
...
Здесь вместо передачи onChange
prop непосредственно элементу input
. Теперь мы передадим handlChange
и внутри обновим defaultValue
, а затем вызовем реквизит onChange
. Теперь вы можете обновить значение с помощью onSelect
и эта SearchBar
автоматически сбросит значение, когда произойдет onChange
.
И я думаю, что этого более чем достаточно для данного проекта. Вы можете увидеть окончательный вид, и если вам есть что сказать, внизу вы найдете мои ссылки на социальные сети.