Живой поиск в React js — выбор с помощью мыши или клавиатуры

Отображение результатов поиска в реальном времени для пользователя, пока он набирает текст в строке поиска, является отличным пользовательским опытом. Но создание поля живого поиска со всеми функциями. Например, выделение мышью, перемещение выделения по клавишам вверх-вниз и скрытие строки поиска при размытии.

Это немного сложная задача. Итак, давайте попробуем пройти через все трудности и создать это удивительное поле живого поиска. Где пользователи могут выбирать результат с помощью мыши или клавиатуры, а также результаты будут скрыты, если мы щелкнем за пределами поля.

Что мы рассмотрим

  • Настройка проекта 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.

И я думаю, что этого более чем достаточно для данного проекта. Вы можете увидеть окончательный вид, и если вам есть что сказать, внизу вы найдете мои ссылки на социальные сети.

Оцените статью
devanswers.ru
Добавить комментарий