HextaUI

Hotkey

Analysez, libellez, annoncez et faites correspondre des raccourcis clavier, avec ⌘ sur les plateformes Apple et Ctrl partout ailleurs.

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

Ajoute l’utilitaire et tout ce dont il dépend à votre projet.

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>
}

Écrivez chaque raccourci une seule fois, sous forme de chaîne, et utilisez la même chaîne pour l’afficher, l’annoncer et le faire correspondre. mod signifie ⌘ sur les plateformes Apple et Ctrl partout ailleurs. C’est presque toujours ce que vous voulez, car Ctrl+K sur Mac et ⌘K sur Windows paraissent tous deux incongrus.

"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

Les touches sont séparées par + et insensibles à la casse. Les touches nommées utilisent la valeur KeyboardEvent.key en minuscules, comme enter, tab, pageup ou f5. Utilisez space pour la barre d’espace.

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

parseHotkey retourne un tableau par accord, avec les alias résolus et les modificateurs triés dans l’ordre d’Apple : Control, Option, Shift, Command. Chaque libellé construit à partir de lui se lit dans l’ordre auquel on s’attend.

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"

Les plateformes Apple utilisent des symboles sans séparateur, comme les menus les affichent. Windows et Linux utilisent des mots reliés par +. Les symboles sont difficiles à lire à voix haute : spokenKey donne donc le nom qu’un lecteur d’écran doit annoncer à la place. <Kbd keys> affiche le symbole et place le nom prononcé dans un texte masqué visuellement.

useIsApple() détermine la plateforme. Il retourne true sur le serveur et pendant l’hydratation, puis la vraie réponse : un visiteur sous Windows voit brièvement ⌘ avant Ctrl au lieu de provoquer une erreur d’hydratation.

Écouteur de raccourcis

matchesHotkey compare un événement keydown à un raccourci. Les modificateurs doivent correspondre exactement : mod+b ne se déclenche donc pas pour 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>
  )
}
  • Les lettres et les chiffres correspondent aussi par touche physique : alt+k fonctionne donc sur Mac, où Option+K produit ˚.
  • Les lettres décalées correspondent : shift+k correspond au K que produit Shift.
  • Il fait correspondre un seul accord. Pour des séquences comme g i, suivez vous-même l’accord précédent.
  • Ignore les raccourcis sans modificateur lorsque le focus est dans un champ de texte : taper une lettre ne déclenche jamais une commande.
ExportDescription
parseHotkey(hotkey)string[][] : un tableau par accord, avec les alias résolus et les modificateurs triés.
formatHotkey(hotkey, apple)Le libellé d’un accord, comme ⇧⌘K ou Shift+Ctrl+K.
keyLabel(key, apple)Le libellé visible d’un nom de touche.
spokenKey(key, apple)Le nom qu’un lecteur d’écran doit annoncer pour une touche.
matchesHotkey(event, hotkey)Indique si un KeyboardEvent correspond à un accord, avec des modificateurs exacts.
isApplePlatform()Lit navigator.platform. true sur le serveur.
useIsApple()isApplePlatform sous forme de hook compatible avec l’hydratation.

Kbd et Command.