HextaUI

Hotkey

キーボードショートカットの解析、ラベル付け、読み上げ、一致判定を行います。Appleプラットフォームでは⌘、それ以外ではCtrlを使います。

Apple
⇧⌘K
Windows and Linux
Shift+Ctrl+K
Screen readers
Shift Command K
parseHotkey
[["shift","mod","k"]]

Click outside the field and press Shift Command Kmatched 0×

"use client"

import * as React from "react"

import { Input } from "@/components/ui/input"
import { Kbd } from "@/components/ui/kbd"
import {
  formatHotkey,
  matchesHotkey,
  parseHotkey,
  spokenKey,
  useIsApple,
} from "@/lib/hotkey"

export function HotkeyDemo() {
  const [hotkey, setHotkey] = React.useState("mod+shift+k")
  const [pressed, setPressed] = React.useState(0)
  const apple = useIsApple()
  const chord = parseHotkey(hotkey)[0] ?? []

  React.useEffect(() => {
    const onKeyDown = (event: KeyboardEvent) => {
      if (
        event.target instanceof HTMLInputElement ||
        !matchesHotkey(event, hotkey)
      ) {
        return
      }
      event.preventDefault()
      setPressed((count) => count + 1)
    }
    window.addEventListener("keydown", onKeyDown)
    return () => window.removeEventListener("keydown", onKeyDown)
  }, [hotkey])

  const rows = [
    ["Apple", formatHotkey(hotkey, true)],
    ["Windows and Linux", formatHotkey(hotkey, false)],
    ["Screen readers", chord.map((key) => spokenKey(key, apple)).join(" ")],
    ["parseHotkey", JSON.stringify(parseHotkey(hotkey))],
  ]

  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      <Input
        aria-label="Hotkey"
        value={hotkey}
        onChange={(event) => setHotkey(event.target.value)}
        spellCheck={false}
        autoCapitalize="off"
      />
      <dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-6 gap-y-2 text-sm">
        {rows.map(([label, value]) => (
          <React.Fragment key={label}>
            <dt className="text-muted-foreground">{label}</dt>
            <dd className="truncate font-mono">{value}</dd>
          </React.Fragment>
        ))}
      </dl>
      <p className="flex items-center gap-2 text-sm text-muted-foreground">
        Click outside the field and press <Kbd keys={hotkey} />
        <span className="ms-auto font-mono tabular-nums">
          matched {pressed}×
        </span>
      </p>
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/hotkey.json

ユーティリティと、それが依存するすべてをプロジェクトに追加します。

import {
  formatHotkey,
  matchesHotkey,
  parseHotkey,
  useIsApple,
} from "@/lib/hotkey"
function SaveShortcut({ onSave }: { onSave: () => void }) {
  const apple = useIsApple()

  React.useEffect(() => {
    const onKeyDown = (event: KeyboardEvent) => {
      if (matchesHotkey(event, "mod+s")) {
        event.preventDefault()
        onSave()
      }
    }
    window.addEventListener("keydown", onKeyDown)
    return () => window.removeEventListener("keydown", onKeyDown)
  }, [onSave])

  return <span>Save {formatHotkey("mod+s", apple)}</span>
}

各ショートカットを文字列として一度だけ書き、その同じ文字列を使って表示、読み上げ、照合を行います。mod は、Apple のプラットフォームでは ⌘、それ以外では Ctrl を意味します。Mac で Ctrl+K、Windows で ⌘K は違和感があるため、ほとんどの場合、これが望ましい動作です。

"mod+k"            ⌘K on Apple platforms, Ctrl+K elsewhere
"shift+mod+p"      modifiers in any order
"cmd+option+esc"   aliases: cmd, command, option, opt, control, esc, return
"alt+up"           up, down, left and right for the arrow keys
"mod++"            a literal plus key
"g g"              a sequence of two chords, separated by a space

キーは + で区切り、大文字小文字は区別されません。名前付きキーは、enter、tab、pageup、f5 のように、小文字の KeyboardEvent.key の値を使います。スペースバーには space を使います。

parseHotkey("mod+shift+k")  // [["shift", "mod", "k"]]
parseHotkey("cmd+opt+esc")  // [["alt", "meta", "escape"]]
parseHotkey("g i")          // [["g"], ["i"]]

parseHotkey は、エイリアスを解決し、修飾キーを Apple の順序(Control、Option、Shift、Command)に並べ替えた、コードごとの配列を返します。そこから作られるラベルはすべて、ユーザーが見慣れた順序で表示されます。

formatHotkey("mod+shift+k", true)   // "⇧⌘K"
formatHotkey("mod+shift+k", false)  // "Shift+Ctrl+K"
keyLabel("enter", true)             // "↵"
spokenKey("mod", true)              // "Command"
spokenKey("mod", false)             // "Control"

Apple のプラットフォームでは、メニューの表示と同様に、区切りなしの記号を使います。Windows と Linux では、+ でつないだ単語を使います。記号は読み上げが難しいため、spokenKey でスクリーンリーダーが読み上げるべき名前を指定します。<Kbd keys> は記号を表示し、読み上げ用の名前を視覚的に非表示のテキストに入れます。

useIsApple() はプラットフォームを判定します。サーバー上とハイドレーション中は true を返し、その後に実際の結果を返すため、Windows のユーザーはハイドレーションエラーではなく、Ctrl の前に一瞬 ⌘ を目にします。

ショートカットのリスナー

matchesHotkey は、keydown イベントをホットキーと照合します。修飾キーは厳密に一致する必要があるため、mod+b は mod+shift+b では発火しません。

"use client"

import * as React from "react"

import { Kbd } from "@/components/ui/kbd"
import { matchesHotkey } from "@/lib/hotkey"

const shortcuts = [
  { hotkey: "mod+b", label: "Bold" },
  { hotkey: "mod+i", label: "Italic" },
  { hotkey: "mod+shift+x", label: "Strikethrough" },
]

export function HotkeyListener() {
  const [last, setLast] = React.useState<string>()

  React.useEffect(() => {
    const onKeyDown = (event: KeyboardEvent) => {
      const match = shortcuts.find((shortcut) =>
        matchesHotkey(event, shortcut.hotkey)
      )
      if (match) {
        event.preventDefault()
        setLast(match.label)
      }
    }
    window.addEventListener("keydown", onKeyDown)
    return () => window.removeEventListener("keydown", onKeyDown)
  }, [])

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <ul className="flex flex-col gap-2 text-sm">
        {shortcuts.map((shortcut) => (
          <li
            key={shortcut.hotkey}
            data-active={last === shortcut.label ? "" : undefined}
            className="flex items-center justify-between rounded-md px-2 py-1 transition-colors duration-150 data-active:bg-muted"
          >
            {shortcut.label}
            <Kbd keys={shortcut.hotkey} />
          </li>
        ))}
      </ul>
      <p role="status" className="text-sm text-muted-foreground">
        {last ? `Matched ${last}` : "Press a shortcut"}
      </p>
    </div>
  )
}
  • 文字と数字は物理キーでも一致するため、Option+K で ˚ が入力される Mac でも alt+k が動作します。
  • Shift を押した文字にも一致します: shift+k は、Shift で生成される K に一致します。
  • 1 つのコードに一致します。g i のようなシーケンスでは、直前のコードを自分で追跡してください。
  • フォーカスがテキスト入力欄にある間は、修飾キーのないショートカットをスキップします。これにより、文字を入力してもコマンドが発火することはありません。
エクスポート説明
parseHotkey(hotkey)string[][]: エイリアスが解決され、修飾キーが並べ替えられた、コードごとの配列。
formatHotkey(hotkey, apple)⇧⌘K や Shift+Ctrl+K など、1 つのコードのラベル。
keyLabel(key, apple)1 つのキー名の表示用ラベル。
spokenKey(key, apple)1 つのキーについて、スクリーンリーダーが読み上げるべき名前。
matchesHotkey(event, hotkey)KeyboardEvent が、修飾キーを厳密に一致させて 1 つのコードに一致するかどうか。
isApplePlatform()navigator.platform を読み取ります。サーバーでは true です。
useIsApple()ハイドレーションに安全なフックとしての isApplePlatform。

Kbd と Command。