使用Headless UI Combobox组件和Tailwind CSS构建一个功能齐全的命令调色板的教程

370 阅读6分钟

作为开发者,我们经常努力尽可能地优化我们的工作流程,通过利用终端等工具来节省时间。命令调色板就是这样一种工具,它可以显示网络或桌面应用程序中最近的活动,实现快速导航、轻松访问命令和快捷方式,以及其他事项。

为了提高你的生产力水平,命令调色板本质上是一个UI组件,它采用了模式的形式。命令调色板在有许多移动部件的大型复杂应用程序中特别有用,例如,你可能需要多次点击或略过多个下拉菜单来访问一个资源。

在本教程中,我们将探讨如何使用Headless UI Combobox组件和Tailwind CSS从头开始构建一个功能齐全的命令调板。

命令调色板的真实用例

作为一个开发者,你很有可能曾经使用过一个命令调色板。最流行的是VSCode 命令调色板,但也有很多其他的例子,包括 GitHub 命令调色板、Linear、Figma、Slack、monkeytype 等等。

GitHub的应用程序

GitHub最近发布了一个命令调色板功能,在撰写本文时仍处于公开测试阶段。它可以让你快速跳转到不同的页面,搜索命令,并根据你当前的环境获得建议。你还可以通过标签进入其中一个选项或使用特殊字符来缩小你要找的资源的范围。

Github Command Palette

线性应用

如果你不熟悉Linear,它是一个类似于Jira和Asana的项目管理工具,提供了一个非常好的用户体验。Linear有一个非常直观的命令调色板,让你通过其键盘优先的设计来访问整个应用程序的功能。在本教程中,我们将建立一个类似于Linear的命令调板。

Linear App Command Palette

命令调板的基本特征

一些现代的应用程序正在实现命令调色板的功能,但怎样才能成为一个好的命令调色板组件?这里有一个简明的清单,列出了需要注意的事项:

  • 打开调色板的简单快捷方式,即:ctrl + k
  • 它可以从应用程序的任何地方访问
  • 它有广泛的搜索功能,如模糊搜索
  • 命令能传达意图,并易于理解
  • 它可以从一个地方访问应用程序的每个部分

在下一节中,我们将建立我们自己的组件,其中包括上述所有的功能。让我们开始吧!

构建该组件

命令调色板实际上并不像它看起来那么复杂,任何人都可以快速建立一个。我为这个教程准备了一个启动项目,这样你就可以轻松地跟着做。这个启动项目是一个React和Vite SPA,它复制了Linear issues页面。

设置该项目

要想开始,请将资源库克隆到你的本地目录,安装必要的依赖项,并启动开发服务器。该项目使用Yarn,但如果你对npm或pnPm更熟悉,你可以在运行npm installpnpm install 之前删除yarn.lock 文件。

// clone repository
$ git clone https://github.com/Mayowa-Ojo/command-palette
// switch to the 'starter-project' branch
$ git checkout starter-project
// install dependencies
$ yarn
// start dev server
$ yarn dev

如果你访问localhost:3000 ,你会看到以下页面:

Github Repository Clone

CommandPalette 组件

接下来,我们将构建该组件。我们将使用Headless UIcomboboxdialog 组件。combobox 将是我们的命令调色板的基础组件。它有内置的功能,如焦点管理和键盘互动。我们将使用dialog 组件在模式中渲染我们的命令调色板。

为了使组件具有风格,我们将使用Tailwind CSS。Tailwind是一个CSS工具库,可以让你轻松地在你的HTML或JSX文件中添加内联样式。启动项目已经包括了Tailwind的配置。

请按如下步骤安装必要的依赖项:

$ yarn add @headlessui/react @heroicons/react

components 文件夹中,创建一个CommandPalette.jsx 文件并添加以下代码块。

import { Dialog, Combobox } from "@headlessui/react";

export const CommandPalette = ({ commands }) => {
  const [isOpen, setIsOpen] = useState(true);

  return (
    <Dialog
      open={isOpen}
      onClose={setIsOpen}
      className="fixed inset-0 p-4 pt-[15vh] overflow-y-auto"
    >
      <Dialog.Overlay className="fixed inset-0 backdrop-blur-[1px]" />
      <Combobox
         as="div"
         className="bg-accent-dark max-w-2xl mx-auto rounded-lg shadow-2xl relative flex flex-col"
         onChange={(command) => {
            // we have access to the selected command
            // a redirect can happen here or any action can be executed
            setIsOpen(false);
         }}
      >
         <div className="mx-4 mt-4 px-2 h-[25px] text-xs text-slate-100 bg-primary/30 rounded self-start flex items-center flex-shrink-0">
            Issue
         </div>
         <div className="flex items-center text-lg font-medium border-b border-slate-500">
            <Combobox.Input
               className="p-5 text-white placeholder-gray-200 w-full bg-transparent border-0 outline-none"
               placeholder="Type a command or search..."
            />
         </div>
         <Combobox.Options
            className="max-h-72 overflow-y-auto flex flex-col"
            static
         ></Combobox.Options>
      </Combobox>
   </Dialog>
  );
};

这里发生了几件事。首先,我们导入DialogCombobox 组件。Dialog 被渲染成一个围绕Combobox 的包装器,我们初始化一个叫做isOpen 的本地状态,以控制模态。

我们在Dialog 组件内渲染一个Dialog.Overlay ,作为模态的叠加部分。你可以随心所欲地设计它,但在这里,我们只使用backdrop-blur 。然后,我们渲染Combobox 组件,并传递一个处理函数给onChange 道具。每当在Combobox 中选择一个项目时,这个处理函数就会被调用。你通常想在这里导航到一个页面或执行一个动作,但现在,我们只是关闭Dialog

Combobox.Input Combobox.Options 渲染一个 元素,包裹我们要渲染的结果列表。我们传入一个 的道具,表示我们要忽略组件的内部管理状态。ul static

接下来,我们在App.jsx 文件中渲染我们的CommandPalette

const App = () => {
   return (
      <div className="flex w-full bg-primary h-screen max-h-screen min-h-screen overflow-hidden">
         <Drawer teams={teams} />
         <AllIssues issues={issues} />
         <CommandPalette commands={commands}/>
      </div>
   );
};

让我们来谈谈我们的命令调色板将如何运作。在data/seed.json 文件中,我们有一个预定义命令的列表。当调色板被打开时,这些命令将显示在调色板中,并可以根据搜索查询进行过滤。很简单,对吗?

CommandGroup 组件

CommandPalette 接收一个commands 道具,这是我们从seed.json 导入的命令列表。 现在,在 文件夹中创建一个 CommandGroup.jsx 文件,并添加以下代码。

// CommandGroup.jsx
import React from "react";
import clsx from "clsx";
import { Combobox } from "@headlessui/react";
import { PlusIcon, ArrowSmRightIcon } from "@heroicons/react/solid";
import {
   CogIcon,
   UserCircleIcon,
   FastForwardIcon,
} from "@heroicons/react/outline";
import { ProjectIcon } from "../icons/ProjectIcon";
import { ViewsIcon } from "../icons/ViewsIcon";
import { TemplatesIcon } from "../icons/TemplatesIcon";
import { TeamIcon } from "../icons/TeamIcon";

export const CommandGroup = ({ commands, group }) => {
   return (
      <React.Fragment>
         {/* only show the header when there are commands belonging to this group */}
         {commands.filter((command) => command.group === group).length >= 1 && (
            <div className="flex items-center h-6 flex-shrink-0 bg-accent/50">
               <span className="text-xs text-slate-100 px-3.5">{group}</span>
            </div>
         )}
         {commands
            .filter((command) => command.group === group)
            .map((command, idx) => (
               <Combobox.Option key={idx} value={command}>
                  {({ active }) => (
                     <div
                        className={clsx(
                           "w-full h-[46px] text-white flex items-center hover:bg-primary/40 cursor-default transition-colors duration-100 ease-in",
                           active ? "bg-primary/40" : ""
                        )}
                     >
                        <div className="px-3.5 flex items-center w-full">
                           <div className="mr-3 flex items-center justify-center w-4">
                              {mapCommandGroupToIcon(
                                 command.group.toLowerCase()
                              )}
                           </div>
                           <span className="text-sm text-left flex flex-auto">
                              {command.name}
                           </span>
                           <span className="text-[10px]">{command.shortcut}</span>
                        </div>
                     </div>
                  )}
               </Combobox.Option>
            ))}
      </React.Fragment>
   );
};

我们只是使用CommandGroup 组件来避免一些重复的代码。如果你看一下线性命令调色板,你会发现这些命令是根据上下文分组的。为了实现这一点,我们需要过滤掉属于同一组的命令,并为每一组重复这一逻辑。

CommandGroup 组件接收两个道具,commandsgroup 。我们将根据当前的组来过滤命令,并使用Combobox.Option 组件来渲染它们。使用渲染道具,我们可以得到active 项目,并对其进行相应的样式设计,使我们可以在CommandPalette 中为每个组渲染CommandGroup ,同时保持代码的简洁。

请注意,我们在上面的代码块中的某个地方有一个mapCommandGroupToIcon 函数。这是因为每个组都有一个不同的图标,而这个函数只是一个辅助工具,为当前组渲染正确的图标。现在,在同一文件中的CommandGroup 组件下面添加这个函数:

const mapCommandGroupToIcon = (group) => {
   switch (group) {
      case "issue":
         return <PlusIcon className="w-4 h-4 text-white"/>;
      case "project":

现在,我们需要在CommandPalette 中渲染CommandGroup 组件。
按以下方式导入该组件:

import { CommandGroup } from "./CommandGroup";

在每个组的Combobox.Options 内渲染它:

<Combobox.Options
   className="max-h-72 overflow-y-auto flex flex-col"
   static
>
   <CommandGroup commands={commands} group="Issue"/>
   <CommandGroup commands={commands} group="Project"/>
   <CommandGroup commands={commands} group="Views"/>
   <CommandGroup commands={commands} group="Team"/>
   <CommandGroup commands={commands} group="Templates"/>
   <CommandGroup commands={commands} group="Navigation"/>
   <CommandGroup commands={commands} group="Settings"/>
   <CommandGroup commands={commands} group="Account"/>
</Combobox.Options>

你应该看到现在正在渲染的命令列表。下一步是连接搜索功能。

实现搜索功能

CommandPalette.jsx 中创建一个本地状态变量。

// CommandPalette.jsx
const [query, setQuery] = useState("");

将状态更新处理程序传递给Combobox.Input 中的onChange 道具。query 将随着你在输入框中输入的每个字符而被更新。

<Combobox.Input
  className="p-5 text-white placeholder-gray-200 w-full bg-transparent border-0 outline-none"
  placeholder="Type a command or search..."
  onChange={(e) => setQuery(e.target.value)}
/>

一个好的命令调色板的关键属性之一是广泛的搜索功能。我们可以只对搜索查询和命令进行简单的字符串比较,然而这并不考虑错别字和上下文。一个更好的、不会引入太多复杂性的解决方案是模糊搜索。

我们将使用Fuse.js库来实现这一目标。Fuse.js是一个强大的、轻量级的、零依赖性的模糊搜索库。如果你不熟悉模糊搜索,它是一种字符串匹配技术,偏向于近似匹配而不是精确匹配,这意味着即使查询有错别字或拼写错误,你也能得到正确的建议。

首先,安装Fuse.js库:

$ yarn add fuse.js

CommandPalette.jsx ,用一个命令列表实例化Fuse 类:

// CommandPalette.jsx
const fuse = new Fuse(commands, { includeScore: true, keys: ["name"] });

Fuse 类接受一个命令和配置选项的数组。keys 字段是我们注册命令列表中哪些字段可以被Fuse.js索引的地方。现在,创建一个函数来处理搜索并返回过滤后的结果。

// CommandPalette.jsx
const filteredCommands =
  query === ""
     ? commands
     : fuse.search(query).map((res) => ({ ...res.item }));

我们检查query 是否为空,返回所有的命令,如果不是,就用查询的方法运行fuse.search 。另外,我们要将结果映射到创建一个新的对象。这是为了保持一致性,因为Fuse.js返回的结果有一些新的字段,不会与我们已有的结构匹配。

现在,将filteredCommands 传递给每个CommandGroup 组件中的commands 道具。它应该看起来像下面的代码:

// CommandPalette.jsx
<CommandGroup commands={filteredCommands} group="Issue"/>
<CommandGroup commands={filteredCommands} group="Project"/>
<CommandGroup commands={filteredCommands} group="Views"/>
<CommandGroup commands={filteredCommands} group="Team"/>
<CommandGroup commands={filteredCommands} group="Templates"/>
<CommandGroup commands={filteredCommands} group="Navigation"/>
<CommandGroup commands={filteredCommands} group="Settings"/>
<CommandGroup commands={filteredCommands} group="Account"/>

试着在命令调色板中搜索,看看结果是否被过滤了:

Command Palette Search Filter

我们有一个功能齐全的命令调色板,但你可能注意到它总是打开的。我们需要能够控制它的打开状态。让我们定义一个键盘事件,它将监听一个组合键并更新打开状态。将以下代码添加到CommandPalette.jsx

// CommandPalette.jsx
useEffect(() => {
  const onKeydown = (e) => {
     if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setIsOpen(true);
     }
  };
  window.addEventListener("keydown", onKeydown);
  return () => {
     window.removeEventListener("keydown", onKeydown);
  };
}, []);

我们使用一个useEffect Hook来注册一个keydown 键盘事件,当组件被安装时,我们使用一个清理函数来删除监听器。

在Hook中,我们检查按键组合是否与ctrl + k 。如果符合,那么打开状态就被设置为true 。你也可以使用不同的组合键,但重要的是不要使用与本地浏览器快捷键相冲突的组合键。

就这样吧!你可以在finished-project分支上找到这个项目的完成版本。

react-command-palette:预先构建的组件

我们已经探讨了如何从头开始构建一个命令调色板组件。然而,你可能不想在每次需要命令调色板时都建立自己的。这就是预置组件的用处。大多数组件库都不提供命令调色板,但react-command-palette是一个写得很好的组件,可以访问并兼容浏览器。

要使用这个组件,把它作为依赖项安装在你的项目中。

$ yarn add react-command-palette

导入该组件并将你的命令列表传递给它,如下所示。

import React from "react";
import CommandPalette from 'react-command-palette';

const commands = [{
  name: "Foo",
  command() {}
},{
  name: "Bar",
  command() {}
}]

export default function App() {
  return (
    <div>
      <CommandPalette commands={commands} />
    </div>
  );
}

有很多配置选项,你可以用它们来定制外观和行为,以满足你的要求。例如,theme 配置让你从一些内置的主题中选择,或者创建你自己的自定义主题。

接下来的步骤

在这篇文章中,你已经了解了命令调色板,它们的理想用例,以及哪些功能构成了一个好的命令调色板。你还详细探讨了如何使用Headless UI combobox组件和Tailwind CSS构建一个调色板。

如果你只是想在你的应用程序中快速实现这一功能,那么像react-command-palette这样的预置组件就是最好的选择。谢谢你的阅读,如果你有任何问题,请务必留下评论。