Как создать повторно используемый компонент прослушивателя сочетаний клавиш в React

Если вы похожи на меня и любите быстрые клавиши, вы знаете, как приятно нажать несколько клавиш и наблюдать, как происходит волшебство. Будь то привычные сочетания клавиш Ctrl+C - Ctrl + V, которые разработчики используют для “заимствования кода" из LLMS и code pages, или персонализированные сочетания клавиш, которые мы настраиваем в наших любимых инструментах, сочетания клавиш экономят время и позволяют нам почувствовать себя компьютерными гениями.

Что ж, не бойтесь! Я взломал код для создания компонентов, которые запускаются и реагируют на сочетания клавиш. В этой статье я научу вас, как создавать их с помощью React, Tailwind CSS и Framer Motion.

Таблица содержания

Вот все, о чем мы расскажем:

    Предпосылки Что такое компонент прослушивателя сочетаний клавиш (KSL)? Как создать компонент KSL Как создать компонент отображения Как запустить компонент с помощью сочетания клавиш Как анимировать видимость компонента Как оптимизировать ваш компонент KSL Вывод

Предпосылки

    Основы HTML, CSS и Tailwind CSS Основы JavaScript, React и перехватчиков React.

Что такое компонент прослушивателя сочетаний клавиш (KSL)?

Компонент прослушивания сочетаний клавиш (KSLC) - это компонент, который прослушивает определенные комбинации клавиш и запускает действия в вашем приложении. Он предназначен для того, чтобы ваше приложение реагировало на сочетания клавиш, обеспечивая более плавный и эффективный пользовательский интерфейс.

Почему это так важно?

    Специальные возможности: Компонент KSL упрощает пользователям, использующим клавиатуру, выполнение действий, делая ваше приложение более доступным и простым в использовании. Удобство использования: Сочетания клавиш работают быстро и эффективно, позволяя пользователям выполнять работу за меньшее время. Больше не нужно возиться с мышью - просто нажмите клавишу (или две), и бум, начинается действие! Возможность повторного использования: Как только вы настроите свой KSL, он сможет обрабатывать различные ярлыки в вашем приложении, что упростит его добавление без необходимости переписывать ту же логику. Более чистый код: Вместо того, чтобы разбрасывать прослушиватели событий клавиатуры по всему миру, компонент KSL поддерживает порядок, централизуя логику. Ваш код остается чистым, организованным и его проще поддерживать.

Как создать компонент KSL

Я подготовил репозиторий на GitHub со стартовыми файлами, чтобы ускорить процесс. Просто клонируйте это репозиторий и устанавливайте зависимости.

Для этого проекта мы используем домашнюю страницу Tailwind в качестве основы и создаем функциональность KSL. После установки и запуска команды build, вот как должна выглядеть ваша страница.:

Как создать компонент Reveal (Раскрытие)

Компонент "Показать" - это компонент, который мы хотим показать при использовании ярлыка.

Для начала создайте файл с именем search-box.tsx и вставьте в него этот код:

export default function SearchBox() {
  return (
    <div className="fixed top-0 left-0 w-full h-full backdrop-blur-sm bg-slate-900/50 ">
      {" "}
      <div className=" p-[15vh] text-[#939AA7] h-full">
        <div className="max-w-xl mx-auto divide-y divide-[#939AA7] bg-[#1e293b] rounded-md">
          <div className="relative flex justify-between px-4 py-2 text-sm ">
            <div className="flex items-center w-full gap-2 text-white">
              <BiSearch size={20} />
              <input
                type="text"
                className="w-full h-full p-2 bg-transparent focus-within:outline-none"
                placeholder="Search Documentation"
              />
            </div>
            <div className="absolute -translate-y-1/2 right-4 top-1/2 ">
              <kbd className="p-1 text-xs rounded-[4px] bg-[#475569] font-sans font-semibold text-slate-400">
                <abbr title="Escape" className="no-underline ">
                  Esc{" "}
                </abbr>{" "}
              </kbd>
            </div>
          </div>
          <div className="flex items-center justify-center p-10 text-center ">
            <h2 className="text-xl">
              How many licks does it take to get to the center of a Tootsie pop?
            </h2>
          </div>
        </div>
      </div>
    </div>
  );
}

Итак, что же происходит в этом коде?

    Основное наложение (<div className="фиксированный верхний-0 левый-0 ...">) Это полноэкранное наложение, при котором фон затемняется. Функция размытия фона (background-blur-sm) добавляет к фону едва заметное размытие, а функция bg-slate-900/50 создает полупрозрачное темное наложение. Оболочка окна поиска (<div className="p-[15vh] ...">) Содержимое размещается по центру с помощью утилит padding и flex. Max-w-xl обеспечивает достаточную ширину поля поиска для удобства чтения.

Затем в вашем App.tsx создайте состояние, которое динамически отображает этот компонент:

const [isOpen, setIsOpen] = useState<boolean>(false);
    useState: Этот хук инициализирует isOpen значением false, что означает, что поле поиска по умолчанию скрыто. Если для параметра isOpen установлено значение true, на экране отобразится компонент SearchBox.

И визуализировать поисковый компонент:

  {isOpen && <SearchBox />}

Чтобы отобразить компонент поиска, добавьте функцию переключения к кнопке ввода:

<button
  type="button"
  className="items-center hidden h-12 px-4 space-x-3 text-left rounded-lg shadow-sm sm:flex w-72 ring-slate-900/10 focus:outline-none hover:ring-2 hover:ring-sky-500 focus:ring-2 focus:ring-sky-500 bg-slate-800 ring-0 text-slate-300 highlight-white/5 hover:bg-slate-700"
  onClick={() => setIsOpen(true)}>
  <BiSearch size={20} />
  <span className="flex-auto">Quick search...</span>
   <kbd className="font-sans font-semibold text-slate-500">
   <abbr title="Control" className="no-underline text-slate-500">
    Ctrl{" "}
    </abbr>{" "}
    K
   </kbd>
</button>

Событие onClick устанавливает для параметра isOpen значение true, отображая поле поиска.

Но, как вы видели, это было вызвано щелчком мыши, а не сочетанием клавиш. Давайте сделаем это дальше.

Как запустить компонент с помощью сочетания клавиш

Чтобы компонент reveal открывался и закрывался с помощью сочетания клавиш, мы будем использовать механизм useEffect для прослушивания определенных комбинаций клавиш и соответствующего обновления состояния компонента.

Шаг 1: Прослушивание событий с клавиатуры

Добавьте перехватчик useEffect в свой файл App.tsx для прослушивания нажатий клавиш:

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.ctrlKey && event.key === Key.K) {
        event.preventDefault(); // Prevent default browser behavior

      }    };

    window.addEventListener("keydown", handleKeyDown);
    return () => {
      window.removeEventListener("keydown", handleKeyDown);
    };
  }, []);

Что происходит в этом коде?

    Настройка эффекта (useEffect) useEffect гарантирует, что прослушиватель событий для нажатий клавиш добавляется при подключении компонента и очищается при отключении компонента, предотвращая утечки памяти. Комбинация клавиш (event.ctrlKey && event.key === "k") Event.ctrlKey проверяет, нажата ли управляющая клавиша. Event.key === "k" гарантирует, что мы прослушиваем именно клавишу "K". В совокупности это проверяет, нажата ли комбинация Ctrl + K. Предотвращение поведения по умолчанию (event.preventDefault()) В некоторых браузерах поведение по умолчанию может быть привязано к сочетаниям клавиш, таким как Ctrl + K (например, для фокусировки адресной строки браузера). Вызов preventDefault останавливает это поведение. Очистка события (return () => ...) Функция очистки удаляет прослушиватель событий, чтобы предотвратить добавление дублирующихся прослушивателей при повторном отображении компонента.

Шаг 2: Переключите видимость компонентов

Затем обновите функцию handleKeyDown, чтобы переключать видимость окна поиска при нажатии сочетания клавиш:

useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      // Listen for Ctrl + K
      if (event.ctrlKey && event.key === Key.K) {
        event.preventDefault(); // Prevent default browser behavior
        setIsOpen((prev) => !prev); // Toggle the search box
      } else if (event.key === Key.Escape) {
        setIsOpen(false); // Close the search box
      }
    };

    window.addEventListener("keydown", handleKeyDown);
    return () => {
      window.removeEventListener("keydown", handleKeyDown);
    };
  }, []);

Что происходит в этом коде?

    Переключение состояния (setIsOpen((prev) => !prev)) При нажатии Ctrl + K средство настройки состояния setIsOpen переключает видимость окна поиска. Аргумент prev представляет предыдущее состояние. Использование параметра !prev изменяет его значение на другое: значение true (открыто) становится значением false (закрыто). значение false (закрыто) становится значением true (открыто). Закрытие с помощью клавиши Escape (event.key === "Escape") Когда нажата клавиша Escape, setIsOpen(false) явно устанавливает состояние в false, закрывая окно поиска.

Это приводит к следующему:

Как анимировать видимость компонента

На данный момент наш компонент работает, но ему не хватает некоторой изюминки, не так ли? Давайте это изменим.

Шаг 1: Создайте оверлейный компонент

Мы начнем с создания оверлейного компонента, который будет выступать в качестве темного, размытого фона для окна поиска. Вот базовая версия:

import { ReactNode } from "react";

export default function OverlayWrapper({ children }: { children: ReactNode }) {
  return (
    <div
      className="fixed top-0 left-0 w-full h-full backdrop-blur-sm bg-slate-900/50 ">
      {children}
    </div>
  );
}

Шаг 2: Добавьте анимацию в оверлей

Теперь давайте сделаем так, чтобы наложение появлялось и исчезало при помощи движения кадра. Обновите компонент OverlayWrapper следующим образом:

import { motion } from "framer-motion";
import { ReactNode } from "react";

export default function OverlayWrapper({ children }: { children: ReactNode }) {
  return (
    <motion.div
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
      className="fixed top-0 left-0 w-full h-full backdrop-blur-sm bg-slate-900/50 ">
      {children}
    </motion.div>
  );
}
Key animation props:
    initial: Задает начальное состояние при монтаже компонента (полностью прозрачное). animate: определяет состояние, к которому следует перейти при анимации (полностью непрозрачное). exit: определяет анимацию при размонтировании компонента (затухание).

Шаг 3: Анимируйте поле поиска

Затем добавьте немного движения в само поле поиска. Мы заставим его скользить и исчезать, когда оно появляется, и исчезать, когда оно исчезает.

import { motion } from "framer-motion";
import { BiSearch } from "react-icons/bi";
import OverlayWrapper from "./overlay";

export default function SearchBox() {
  return (
    <OverlayWrapper>
      <motion.div
        initial={{ y: "-10%", opacity: 0 }}
        animate={{ y: "0%", opacity: 1 }}
        exit={{ y: "-5%", opacity: 0 }}
        className=" p-[15vh] text-[#939AA7] h-full">
        <div
          className="max-w-xl mx-auto divide-y divide-[#939AA7] bg-[#1e293b] rounded-md"
        >
          <div className="relative flex justify-between px-4 py-2 text-sm ">
            <div className="flex items-center w-full gap-2 text-white">
              <BiSearch size={20} />
              <input
                type="text"
                className="w-full h-full p-2 bg-transparent focus-within:outline-none"
                placeholder="Search Documentation"
              />
            </div>
            <div className="absolute -translate-y-1/2 right-4 top-1/2 ">
              <kbd className="p-1 text-xs rounded-[4px] bg-[#475569] font-sans font-semibold text-slate-400">
                <abbr title="Escape" className="no-underline ">
                  Esc{" "}
                </abbr>{" "}
              </kbd>
            </div>
          </div>
          <div className="flex items-center justify-center p-10 text-center ">
            <h2 className="text-xl">
              How many licks does it take to get to the center of a Tootsie pop?
            </h2>
          </div>
        </div>
      </motion.div>
    </OverlayWrapper>
  );
}

Шаг 4: Включите отслеживание анимации с помощью AnimatePresence

Наконец, перенесите логику условного рендеринга в компонент AnimatePresence, предоставляемый Framer Motion. Это гарантирует, что движение фреймера будет отслеживаться при входе элементов в DOM и выходе из него.

<AnimatePresence>{isOpen && <SearchBox />}</AnimatePresence>

Это позволяет отслеживать движение кадра при входе элемента в DOM и выходе из него. В результате мы получаем следующий результат:

Ах, так гораздо лучше!

Как оптимизировать ваш компонент KSL

Если вы думали, что мы закончили, то не так быстро - нам еще нужно кое-что сделать.

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

Чтобы сделать это, начните с создания хука useClickOutside. Этот хук использует ссылочный элемент, чтобы узнать, когда пользователь нажимает за пределами целевого элемента (окна поиска), что является очень популярным поведением для закрывающих модальностей и KSLC.


import { useEffect } from "react";

type ClickOutsideHandler = (event: Event) => void;

export const useClickOutside = (
  ref: React.RefObject<HTMLElement>,
  handler: ClickOutsideHandler
) => {
  useEffect(() => {
    const listener = (event: Event) => {
      // Do nothing if clicking ref's element or descendant elements
      if (!ref.current || ref.current.contains(event.target as Node)) return;

      handler(event);
    };

    document.addEventListener("mousedown", listener);
    document.addEventListener("touchstart", listener);

    return () => {
      document.removeEventListener("mousedown", listener);
      document.removeEventListener("touchstart", listener);
    };
  }, [ref, handler]);
};

Чтобы использовать этот хук, передайте функцию, отвечающую за открытие и закрытие поискового компонента:

<AnimatePresence> {isOpen && <SearchBox close={setIsOpen} />} </AnimatePresence>

Затем получите функцию в поиске с соответствующим типом реквизита:

export default function SearchBox({
  close,
}: {
  close: React.Dispatch<React.SetStateAction<boolean>>;
}) {

После этого создайте ссылку (ref) на элемент, который вы хотите отслеживать, и отметьте этот элемент:

import { motion } from "framer-motion";
import { useRef } from "react";
import { BiSearch } from "react-icons/bi";
import { useClickOutside } from "../hooks/useClickOutside";
import OverlayWrapper from "./overlay";

export default function SearchBox({
  close,
}: {
  close: React.Dispatch<React.SetStateAction<boolean>>;
}) {
  const searchboxRef = useRef<HTMLDivElement>(null);
  return (
    <OverlayWrapper>
      <motion.div
        initial={{ y: "-10%", opacity: 0 }}
        animate={{ y: "0%", opacity: 1 }}
        exit={{ y: "-5%", opacity: 0 }}
        className=" p-[15vh] text-[#939AA7] h-full">
        <div
          className="max-w-xl mx-auto divide-y divide-[#939AA7] bg-[#1e293b] rounded-md"
          ref={searchboxRef}>
          <div className="relative flex justify-between px-4 py-2 text-sm ">
            <div className="flex items-center w-full gap-2 text-white">
              <BiSearch size={20} />
              <input
                type="text"
                className="w-full h-full p-2 bg-transparent focus-within:outline-none"
                placeholder="Search Documentation"
              />
            </div>
            <div className="absolute -translate-y-1/2 right-4 top-1/2 ">
              <kbd className="p-1 text-xs rounded-[4px] bg-[#475569] font-sans font-semibold text-slate-400">
                <abbr title="Escape" className="no-underline ">
                  Esc{" "}
                </abbr>{" "}
              </kbd>
            </div>
          </div>
          <div className="flex items-center justify-center p-10 text-center ">
            <h2 className="text-xl">
              How many licks does it take to get to the center of a Tootsie pop?
            </h2>
          </div>
        </div>
      </motion.div>
    </OverlayWrapper>
  );
}

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

useClickOutside(searchboxRef, () => close(false));

Тестирование этого сейчас дает следующий результат:

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

Во-первых, создайте файл привязки useKeyBindings для обработки комбинаций нажатий клавиш.

Затем определите хук и интерфейс. Хук будет принимать массив привязок, где каждая привязка состоит из:

    Массив клавиш, который определяет комбинацию клавиш (например, ["Control", "k"]) Функция обратного вызова, которая вызывается при нажатии соответствующих клавиш.
import { useEffect } from "react";

// Define the structure of a keybinding
interface KeyBinding {
  keys: string[]; // Array of keys (e.g., ["Control", "k"])
  callback: () => void; // Function to execute when the keys are pressed
}

export const useKeyBindings = (bindings: KeyBinding[]) => {

};

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

Мы переведем клавиши в нижний регистр, чтобы сравнение было нечувствительным к регистру, и отследим, какие клавиши нажаты, проверив наличие ctrlKey, shiftKey, altKey, metaKey и нажатой клавиши (например, "k" для Ctrl + K).

const handleKeyDown = (event: KeyboardEvent) => {
  // Track the keys that are pressed
  const pressedKeys = new Set<string>();

  // Check for modifier keys (Ctrl, Shift, Alt, Meta)
  if (event.ctrlKey) pressedKeys.add("control");
  if (event.shiftKey) pressedKeys.add("shift");
  if (event.altKey) pressedKeys.add("alt");
  if (event.metaKey) pressedKeys.add("meta");

  // Add the key that was pressed (e.g., "k" for Ctrl + K)
  if (event.key) pressedKeys.add(event.key.toLowerCase());
};

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

// Loop through each keybinding
bindings.forEach(({ keys, callback }) => {
  // Normalize the keys to lowercase for comparison
  const normalizedKeys = keys.map((key) => key.toLowerCase());

  // Check if the pressed keys match the keybinding
  const isMatch =
    pressedKeys.size === normalizedKeys.length &&
    normalizedKeys.every((key) => pressedKeys.has(key));

  // If the keys match, call the callback
  if (isMatch) {
    event.preventDefault(); // Prevent default browser behavior
    callback(); // Execute the callback function
  }
});

Наконец, настройте прослушиватели событий в объекте window для прослушивания событий нажатия клавиш. Эти прослушиватели будут запускать функцию handleKeyDown при каждом нажатии клавиши. Не забудьте добавить функцию очистки прослушивателей событий при отключении компонента.

useEffect(() => {
  // Add event listeners for keydown
  window.addEventListener("keydown", handleKeyDown);

  // Cleanup the event listeners when the component unmounts
  return () => {
    window.removeEventListener("keydown", handleKeyDown);
  };
}, [bindings]);

Теперь полный хук useKeyBindings, собранный вместе, выглядит следующим образом:

import { useEffect } from "react";

interface KeyBinding {
  keys: string[]; // A combination of keys to trigger the callback (e.g., ["Control", "k"])
  callback: () => void; // The function to execute when the keys are pressed
}

export function useKeyBindings(bindings: KeyBinding[]) {
  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      bindings.forEach(({ keys, callback }) => {
        const normalizedKeys = keys.map((key) => key.toLowerCase());
        const pressedKeys = new Set<string>();

        // Track modifier keys explicitly
        if (event.ctrlKey) pressedKeys.add("control");
        if (event.shiftKey) pressedKeys.add("shift");
        if (event.altKey) pressedKeys.add("alt");
        if (event.metaKey) pressedKeys.add("meta");

        // Add the actual key pressed
        if (event.key) pressedKeys.add(event.key.toLowerCase());

        // Match exactly: pressed keys must match the defined keys
        const isExactMatch =
          pressedKeys.size === normalizedKeys.length &&
          normalizedKeys.every((key) => pressedKeys.has(key));

        if (isExactMatch) {
          event.preventDefault(); // Prevent default behavior
          callback(); // Execute the callback
        }
      });
    };

    window.addEventListener("keydown", handleKeyDown);
    return () => {
      window.removeEventListener("keydown", handleKeyDown);
    };
  }, [bindings]);
}

Вот как вы можете использовать этот хук в своем приложении:

import { useKeyBindings } from "./hooks/useKeyBindings";

export default function App() {
  const [isOpen, setIsOpen] = useState<boolean>(false);

  useKeyBindings([
    {
      keys: ["Control", "k"], // Listen for "Ctrl + K"
      callback: () => setIsOpen((prev) => !prev), // Toggle the search box
    },
    {
      keys: ["Escape"], // Listen for "Escape"
      callback: () => setIsOpen(false), // Close the search box
    },
  ]);

Что дает следующий результат:

При таком подходе вы даже можете добавить несколько ярлыков для повышения видимости компонента поиска.

useKeyBindings([
    {
      keys: ["Control", "k"], // Listen for "Ctrl + K"
      callback: () => setIsOpen((prev) => !prev), // Toggle the search box
    },
    {
      keys: ["Control", "d"], // Listen for "Ctrl + D"
      callback: () => setIsOpen((prev) => !prev), // Toggle the search box
    },
    {
      keys: ["Escape"], // Listen for "Escape"
      callback: () => setIsOpen(false), // Close the search box
    },
  ]);

Вот ссылки на все ресурсы, которые могут вам понадобиться для этой статьи:

    Исходные файлы Окончательный

Вывод

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

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

Нравятся мои статьи?

Не стесняйтесь угостить меня здесь кофе, чтобы я не скучал и публиковал больше подобных статей.

Контактная информация

Хотите связаться со мной? Не стесняйтесь обращаться ко мне по следующим вопросам:

    Twitter / X: @jajadavid8 В LinkedIn: Дэвид Джаджа Электронная почта: Jajadavidjid@gmail.com