iT邦幫忙

2022 iThome 鐵人賽

DAY 26
0
Modern Web

從零開始打造炫砲個人部落格,使用 Next.js、ContentLayer、i18next 等現代技術系列 第 26

使用 kbar 加入 Command Palette 指令面板 - Modern Next.js Blog 系列 #26

  • 分享至 

  • xImage
  •  

這是「Modern Blog 30 天」系列第 26 篇文章。

上一篇加完了留言系統,這篇我們繼續加入另一個酷炫功能:「Command Palette 指令面板」!

最終效果如下:

Command palette toggle button

Command palette light

Command palette dark

這篇修改的程式碼如下:

https://github.com/Kamigami55/nextjs-tailwind-contentlayer-blog-starter/compare/day25-giscus-comment...day26-command-palette

我的個人網站裡也有此系列的好讀版,程式碼更易讀、也支援深色模式和側邊目錄,歡迎前往閱讀!


Command Palette 指令面板

Command Palette 指令面板是一個最近非常流行的 UI 設計元素,在許多 App 或網頁都可以看到它。

例如:

  • Mac 按下 Cmd + 空白鍵 就能叫出的 Spotlight,或取代 Spotlight 的 AlfredRaycast
  • VSCode 按下 Cmd + P 的檔案搜尋框、或按下 Cmd + Shift + P 的指令輸入框
  • Notion 按下 Cmd + /
  • Vercel Dashboard 按下 Cmd + K
  • React Docs (beta) 按下 Cmd + K

按下特定快捷鍵後,就會在畫面中央跳出一個搜尋框,裡面可以輸入文字來搜尋全站內容,或快速執行各種操作。

最近也多出許多開源套件,能讓我們在網站內實作出 Command Palette,這裡我們使用 kbar 來實作。

安裝 @heroicons/react

我們會給 Command Palette 裡各個選項指定 icon,這裡我們統一使用 Tailwind CSS 官方出的 Heroicons(官網Github repo)。

輸入指令安裝:

pnpm add @heroicons/react

安裝 @tailwindcss/line-clamp

後續切版 Command Palette 時,我們也希望當選項文字太長時,能截斷文字只顯示一行,避免跑版。

這種效果可以使用 CSS 的 -webkit-line-clamp 來實現。

Tailwind CSS 官方也有推出 @tailwindcss/line-clamp plugin 來實現斷行效果(官網Github repo

輸入指令安裝:

pnpm add -D @tailwindcss/line-clamp

然後修改 tailwind.config.js 來啟用它:

// ...

/** @type {import('tailwindcss').Config} */
module.exports = {
  // ...
  plugins: [
    require('@tailwindcss/typography'),
    // 加入 @tailwindcss/line-clamp
    require('@tailwindcss/line-clamp'),
  ],
};

安裝 kbar

接著來安裝 Command Palette 主體的 kbar(官網Github repo):

pnpm add kbar

實作 Command Palette

使用 kbar 實作它,切版一樣使用 Tailwind CSS。

這邊切版的程式是修改自這篇文章「How to implement command palette with Kbar and Tailwind CSS | by Oz Hashimoto | Prototypr」的。

新增 src/components/CommandPalette/index.ts

import CommandPalette from './CommandPalette';

export default CommandPalette;

新增 src/components/CommandPalette/CommandPalette.tsx

(如果你的網站有更多頁面、或想要更多可執行操作,可以擴充裡面的 actions array)

// template come from:
// https://blog.prototypr.io/how-to-implement-command-palette-with-kbar-and-tailwind-css-71ea0e3f99c1

import {
  HomeIcon,
  LightBulbIcon,
  MoonIcon,
  SunIcon,
} from '@heroicons/react/24/outline';
import {
  ActionId,
  ActionImpl,
  KBarAnimator,
  KBarPortal,
  KBarPositioner,
  KBarProvider,
  KBarResults,
  Priority,
  useMatches,
} from 'kbar';
import { useRouter } from 'next/router';
import { useTheme } from 'next-themes';
import React, { forwardRef, useMemo } from 'react';

import { KBarSearch } from './KBarSearch';

type Props = {
  children: React.ReactNode;
};

export default function CommandPalette({ children }: Props) {
  const router = useRouter();
  const { setTheme } = useTheme();

  const actions = [
    // Page section
    {
      id: 'home',
      name: '首頁',
      keywords: 'home homepage index 首頁',
      perform: () => router.push('/'),
      icon: <HomeIcon className="h-6 w-6" />,
      section: {
        name: '頁面',
        priority: Priority.HIGH,
      },
    },
    // Operation section
    // - Theme toggle
    {
      id: 'theme',
      name: '切換主題',
      keywords: 'change toggle theme mode color 切換 更換 顏色 主題 模式',
      icon: <LightBulbIcon className="h-6 w-6" />,
      section: '操作',
    },
    {
      id: 'theme-light',
      name: '明亮模式',
      keywords: 'theme light white mode color 顏色 主題 模式 明亮 白色',
      perform: () => setTheme('light'),
      icon: <SunIcon className="h-6 w-6" />,
      parent: 'theme',
      section: '操作',
    },
    {
      id: 'theme-dark',
      name: '暗黑模式',
      keywords: 'theme dark black mode color 顏色 主題 模式 暗黑 黑色 深夜',
      perform: () => setTheme('dark'),
      icon: <MoonIcon className="h-6 w-6" />,
      parent: 'theme',
      section: '操作',
    },
  ];

  return (
    <KBarProvider actions={actions}>
      <CommandBar />
      {children}
    </KBarProvider>
  );
}

function CommandBar() {
  return (
    <KBarPortal>
      <KBarPositioner className="z-20 flex items-center bg-gray-400/70 p-2 backdrop-blur-sm dark:bg-gray-900/80">
        <KBarAnimator className="box-content w-full max-w-[600px] overflow-hidden rounded-xl border border-gray-400 bg-white/80 p-2 dark:border-gray-600 dark:bg-gray-700/80">
          <KBarSearch className="flex h-16 w-full bg-transparent px-4 outline-none" />
          <RenderResults />
        </KBarAnimator>
      </KBarPositioner>
    </KBarPortal>
  );
}

function RenderResults() {
  const { results, rootActionId } = useMatches();

  return (
    <KBarResults
      items={results}
      onRender={({ item, active }) =>
        typeof item === 'string' ? (
          <div className="px-4 pt-4 pb-2 font-medium text-gray-500 dark:text-gray-400">
            {item}
          </div>
        ) : (
          <ResultItem
            action={item}
            active={active}
            currentRootActionId={rootActionId || ''}
          />
        )
      }
    />
  );
}

interface ResultItemProps {
  action: ActionImpl;
  active: boolean;
  currentRootActionId: ActionId;
}
type Ref = HTMLDivElement;

// eslint-disable-next-line react/display-name
const ResultItem = forwardRef<Ref, ResultItemProps>(
  (
    {
      action,
      active,
      currentRootActionId,
    }: {
      action: ActionImpl;
      active: boolean;
      currentRootActionId: ActionId;
    },
    ref: React.Ref<HTMLDivElement>
  ) => {
    const ancestors = useMemo(() => {
      if (!currentRootActionId) return action.ancestors;
      const index = action.ancestors.findIndex(
        (ancestor) => ancestor.id === currentRootActionId
      );
      // +1 removes the currentRootAction; e.g.
      // if we are on the "Set theme" parent action,
      // the UI should not display "Set theme… > Dark"
      // but rather just "Dark"
      return action.ancestors.slice(index + 1);
    }, [action.ancestors, currentRootActionId]);

    return (
      <div
        ref={ref}
        className={`${
          active
            ? 'rounded-lg bg-primary-500 text-gray-100'
            : 'text-gray-600 dark:text-gray-300'
        } flex cursor-pointer items-center justify-between rounded-lg px-4 py-2`}
      >
        <div className="flex items-center gap-2 text-base">
          {action.icon && action.icon}
          <div className="flex flex-col">
            <div className="line-clamp-1">
              {ancestors.length > 0 &&
                ancestors.map((ancestor) => (
                  <React.Fragment key={ancestor.id}>
                    <span className="mr-3 opacity-70">{ancestor.name}</span>
                    <span className="mr-3">›</span>
                  </React.Fragment>
                ))}
              <span>{action.name}</span>
            </div>
            {action.subtitle && (
              <span className="text-sm">{action.subtitle}</span>
            )}
          </div>
        </div>
        {action.shortcut?.length ? (
          <div aria-hidden className="grid grid-flow-col gap-2">
            {action.shortcut.map((sc) => (
              <kbd
                key={sc}
                className={`${
                  active
                    ? 'bg-white text-teal-500 dark:bg-gray-500 dark:text-gray-200'
                    : 'bg-gray-200 text-gray-500 dark:bg-gray-600 dark:text-gray-400'
                } flex cursor-pointer items-center justify-between rounded-md px-3 py-2`}
              >
                {sc}
              </kbd>
            ))}
          </div>
        ) : null}
      </div>
    );
  }
);

接著因為 kbar 目前無法輸入中文字,所以需要客製化自己的 KbarSearch 元件來 workaround。

程式取自 kbar issue 的這篇回覆:can't input chinese · Issue #237 · timc1/kbar

新增 src/components/CommandPalette/KBarSearch.tsx

// Custom KBarSearch component to fix cannot input Chinese issue
// A replacement of KBarSearch component from kbar
// import { KBarSearch } from 'kbar';
// Copied from: https://github.com/timc1/kbar/issues/237#issuecomment-1253691644

import { useKBar, VisualState } from 'kbar';
import React, { useState } from 'react';

export const KBAR_LISTBOX = 'kbar-listbox';
export const getListboxItemId = (id: number) => `kbar-listbox-item-${id}`;

export function KBarSearch(
  props: React.InputHTMLAttributes<HTMLInputElement> & {
    defaultPlaceholder?: string;
  }
) {
  const {
    query,
    searchQuery,
    actions,
    currentRootActionId,
    activeIndex,
    showing,
    options,
  } = useKBar((state) => ({
    searchQuery: state.searchQuery,
    currentRootActionId: state.currentRootActionId,
    actions: state.actions,
    activeIndex: state.activeIndex,
    showing: state.visualState === VisualState.showing,
  }));
  const [search, setSearch] = useState(searchQuery);

  const ownRef = React.useRef<HTMLInputElement>(null);

  const { defaultPlaceholder, ...rest } = props;

  React.useEffect(() => {
    query.setSearch('');
    ownRef.current!.focus();
    return () => query.setSearch('');
  }, [currentRootActionId, query]);

  React.useEffect(() => {
    query.setSearch(search);
  }, [query, search]);

  const placeholder = React.useMemo((): string => {
    const defaultText = defaultPlaceholder ?? 'Type a command or search…';
    return currentRootActionId && actions[currentRootActionId]
      ? actions[currentRootActionId].name
      : defaultText;
  }, [actions, currentRootActionId, defaultPlaceholder]);

  return (
    <input
      {...rest}
      ref={ownRef}
      // eslint-disable-next-line jsx-a11y/no-autofocus
      autoFocus
      autoComplete="off"
      role="combobox"
      spellCheck="false"
      aria-expanded={showing}
      aria-controls={KBAR_LISTBOX}
      aria-activedescendant={getListboxItemId(activeIndex)}
      value={search}
      placeholder={placeholder}
      onChange={(event) => {
        props.onChange?.(event);
        setSearch(event.target.value);
        options?.callbacks?.onQueryChange?.(event.target.value);
      }}
      onKeyDown={(event) => {
        props.onKeyDown?.(event);
        if (currentRootActionId && !search && event.key === 'Backspace') {
          const parent = actions[currentRootActionId].parent;
          query.setCurrentRootAction(parent);
        }
      }}
    />
  );
}

最後修改 src/pages/_app.tsx,用 <CommandPalette> 元件包住整個 App:

// ...
import CommandPalette from '@/components/CommandPalette';
// ...

function MyApp({ Component, pageProps }: AppProps) {
  // ...

  return (
    <ThemeProvider attribute="class">
      // 用 <CommandPalette> 包住整個 App
      <CommandPalette>
        // ...
        <LayoutWrapper>
          <Component {...pageProps} />
        </LayoutWrapper>
      </CommandPalette>
    </ThemeProvider>
  );
}

export default MyApp;

這樣就成功使用 kbar 加入 Command Palette 了,在網頁內按下 Cmd + K 就能開啟了。

在 navigation 加入 Command Palette 按鈕

但正常使用者根本不會發現我們加了 Command Palette,因此我們還需要在 navigation 加入開啟按鈕,讓使用者能手動觸發,進而發現它的存在。

新增 src/components/CommandPaletteToggle.tsx

import { useKBar } from 'kbar';

export default function CommandPaletteToggle() {
  const { query } = useKBar();

  return (
    <button
      aria-label="Toggle Command Palette"
      type="button"
      className="hidden h-12 w-12 rounded py-3 px-4 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800 sm:block"
      onClick={query.toggle}
    >
      <svg
        fill="none"
        className="h-4 w-4 text-gray-900 transition-colors dark:text-gray-100"
        viewBox="0 0 18 18"
      >
        <path
          stroke="currentColor"
          strokeLinecap="round"
          strokeLinejoin="round"
          strokeWidth="1.5"
          d="M14.333 1a2.667 2.667 0 0 0-2.666 2.667v10.666a2.667 2.667 0 1 0 2.666-2.666H3.667a2.667 2.667 0 1 0 2.666 2.666V3.667a2.667 2.667 0 1 0-2.666 2.666h10.666a2.667 2.667 0 0 0 0-5.333Z"
        />
      </svg>
    </button>
  );
}

修改 src/components/Header.tsx,加入 <CommandPaletteToggle />

import CommandPaletteToggle from '@/components/CommandPaletteToggle';
// ...

export default function Header() {
  return (
    <header className="sticky top-0 z-10 border-b border-slate-900/10 bg-white/70 py-3 backdrop-blur transition-colors dark:border-slate-50/[0.06] dark:bg-gray-900/60">
      <SectionContainer>
        <div className="flex items-baseline justify-between">
          // ...

          <div className="flex items-center text-base leading-5 sm:gap-1">
            // ...

            <ThemeSwitch />
            // 加入 <CommandPaletteToggle />
            <CommandPaletteToggle />
            <MobileNav />
          </div>
        </div>
      </SectionContainer>
    </header>
  );
}

成果

這樣就完成了!使用 pnpm dev,進去網站裡按下 Ctrl + K (Windows) 或 Cmd + K (Mac),或是點右上角的 Command icon,就能開啟 Command Palette 了。

裡面目前能執行的操作有三個:瀏覽首頁、切換深色主題、切換明亮主題。

最終效果如下:

Command palette toggle button

Command palette light

Command palette dark

這篇修改的程式碼如下:

https://github.com/Kamigami55/nextjs-tailwind-contentlayer-blog-starter/compare/day25-giscus-comment...day26-command-palette

References

Troubleshooting

在前面的 src/components/CommandPalette/KBarSearch.tsx,裡面有用到 TypeScript 的 Non-null assertion operator

如果你發現那邊有 TypeScript Eslint 的 warning 的話,可以修改 .eslintrc.js,把這條 rule 關掉:

module.exports = {
  // ...
  overrides: [
    {
      files: '**/*.{ts,tsx}',
      // ...
      rules: {
        // 加入下面這行關掉 warning
        '@typescript-eslint/no-non-null-assertion': 'off',
      },
    },
  ],
};

下一篇

恭喜你成功新增了 Command Palette 指令面板,讓網站多了一個炫砲功能,方便讀者快速操作網站。

下一篇我們繼續來擴充它,讓他能搜尋所有文章並跳轉到特定文章內頁!


上一篇
使用 giscus 在 Next.js 加入留言系統 - Modern Next.js Blog 系列 #25
下一篇
在 kbar Command Palette 實作文章搜尋 - Modern Next.js Blog 系列 #27
系列文
從零開始打造炫砲個人部落格,使用 Next.js、ContentLayer、i18next 等現代技術30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言