# KbdButton

> 包住可修改快捷键的按钮，以及它录制中和无效时的样子；不能修改的快捷键仍然用 KbdShortcut。

Source: https://raft-design.botiverse.build/zh/components/kbd-button. Translated from https://raft-design.botiverse.build/components/kbd-button.md; where the two differ, the English page is right.

KbdButton 是快捷键列表里包住可修改快捷键的控件。它包着一个 `md` 的 `KbdShortcut`；点击后，它变成一个输入框，录下接下来按的快捷键。Kbd 本身从不是控件，所以悬停、焦点环和点击都由按钮承担，键帽仍是只读列表里显示的那些键帽。

```tsx
import { Text } from "@botiverse/raft-ui";
import { useCallback, useState } from "react";
import {
  KbdButton,
  KbdButtonCaret,
  KbdButtonPlaceholder,
  KbdShortcut,
} from "@botiverse/raft-ui/wip";
import type { KbdKeyName } from "@botiverse/raft-ui/wip";

const SIDEBAR: KbdKeyName[] = ["mod", "\\"];
const HELD: KbdKeyName[] = ["mod", "shift"];
const NEW_TAB: KbdKeyName[] = ["mod", "t"];
const ASSISTANT: KbdKeyName[] = ["mod", "shift", "."];
const FIND: KbdKeyName[] = ["mod", "f"];

export function KbdButtonStatesContent() {
  // Each row starts in the state it shows; a click toggles it, as Esc would.
  const [sidebar, setSidebar] = useState(false);
  const [assistant, setAssistant] = useState(true);
  const [search, setSearch] = useState(true);
  const [find, setFind] = useState(true);
  const toggleSidebar = useCallback(() => setSidebar((on) => !on), []);
  const toggleAssistant = useCallback(() => setAssistant((on) => !on), []);
  const toggleSearch = useCallback(() => setSearch((on) => !on), []);
  const toggleFind = useCallback(() => setFind((on) => !on), []);
  return (
    <div className="flex w-full max-w-md flex-col gap-4">
      <div className="flex min-h-11 items-center justify-between gap-4">
        <div className="min-w-0">
          <Text.Sans>Collapse or expand the sidebar</Text.Sans>
          <Text.Sans size="small">In a wide window</Text.Sans>
        </div>
        <KbdButton
          recording={sidebar}
          onClick={toggleSidebar}
          aria-label="Change the shortcut for Collapse or expand the sidebar"
          aria-keyshortcuts={"Meta+\\"}
        >
          {sidebar ? (
            <>
              <KbdButtonPlaceholder>Press a shortcut</KbdButtonPlaceholder>
              <KbdButtonCaret />
            </>
          ) : (
            <KbdShortcut keys={SIDEBAR} size="md" platform="mac" />
          )}
        </KbdButton>
      </div>
      <div className="flex min-h-11 items-center justify-between gap-4">
        <div className="min-w-0">
          <Text.Sans>Hide or show your assistant</Text.Sans>
          <Text.Sans size="small" id="assistant-hint">
            {assistant
              ? "Press the new shortcut, Esc to cancel"
              : "Your assistant, when you're not typing"}
          </Text.Sans>
        </div>
        <KbdButton
          recording={assistant}
          onClick={toggleAssistant}
          aria-label="Change the shortcut for Hide or show your assistant"
          aria-keyshortcuts="Meta+Shift+."
          aria-describedby="assistant-hint"
        >
          {assistant ? (
            <>
              <KbdButtonPlaceholder>Press a shortcut</KbdButtonPlaceholder>
              <KbdButtonCaret />
            </>
          ) : (
            <KbdShortcut keys={ASSISTANT} size="md" platform="mac" />
          )}
        </KbdButton>
      </div>
      <div className="flex min-h-11 items-center justify-between gap-4">
        <div className="min-w-0">
          <Text.Sans>Search this channel</Text.Sans>
          <Text.Sans size="small" id="search-hint">
            {search ? "Press the new shortcut, Esc to cancel" : "In a channel"}
          </Text.Sans>
        </div>
        <KbdButton
          recording={search}
          onClick={toggleSearch}
          aria-label="Change the shortcut for Search this channel"
          aria-keyshortcuts="Meta+F"
          aria-describedby="search-hint"
        >
          <KbdShortcut keys={search ? HELD : FIND} size="md" platform="mac" />
          {search ? <KbdButtonCaret /> : null}
        </KbdButton>
      </div>
      <div className="flex min-h-11 items-center justify-between gap-4">
        <div className="min-w-0">
          <Text.Sans>Find in the page</Text.Sans>
          <Text.Sans size="small" variant={find ? "danger" : undefined} id="find-error">
            {find ? "Your browser keeps ⌘T to open a tab" : "In an Artifact"}
          </Text.Sans>
        </div>
        <KbdButton
          recording={find}
          invalid={find}
          onClick={toggleFind}
          aria-label="Change the shortcut for Find in the page"
          aria-keyshortcuts="Meta+F"
          aria-describedby="find-error"
        >
          <KbdShortcut keys={find ? NEW_TAB : FIND} size="md" platform="mac" />
        </KbdButton>
      </div>
    </div>
  );
}
```

## 何时使用
| 场景 | 使用 |
| --- | --- |
| 快捷键面板里人可以修改的快捷键 | 包着 `size="md"` 的 `KbdShortcut` 的 KbdButton |
| 人不能修改的快捷键：只读的、由应用菜单设定的、手机上的列表 | `md` 的 `KbdShortcut`，不加按钮（[Kbd](https://raft-design.botiverse.build/zh/components/kbd.md)）；在暂时还不能修改的地方，在它前面放一个锁形图标说明原因 |
| 一个操作的快捷键，放在它的工具提示或菜单项里 | `sm` 的 `KbdShortcut`（[Tooltip](https://raft-design.botiverse.build/zh/components/tooltip.md)、[Kbd](https://raft-design.botiverse.build/zh/components/kbd.md#menus)） |
| 在几个固定的按键之间选一个，比如用 Enter 还是 ⌘ Enter 发送 | [SegmentedControl](https://raft-design.botiverse.build/zh/components/segmented-control.md) 或 [Select](https://raft-design.botiverse.build/zh/components/select.md)：没有要录制的东西 |

只在绑定可以修改的地方用它。什么也改不了的按钮，是一个启用着却没有作用的控件；只读的绑定仍然用 Kbd，这样列表一眼就能看出哪些快捷键可以修改。

## 结构
- `KbdButton`：原生按钮。必需。在里面放一个 `md` 的 `KbdShortcut`：静止时是当前的按键，录制时是目前已经按住的按键。
- `KbdButtonPlaceholder`：录制中的 KbdButton 在按下任何键之前显示的提示，由使用方提供（"Press a shortcut"）；或者没有快捷键的命令所显示的文字（"No shortcut"）。可选。
- `KbdButtonCaret`：按住的按键后面的插入符，表示输入框还在等更多按键。可选：按键已经落定、被拒绝，或被暂留等待选择时，就去掉它。

表示快捷键被改过的标记（行标签前的黄色圆点，以及行的第二行上它的默认快捷键）和 Reset 按钮属于行，不属于 KbdButton：它们是产品的状态。

## 状态
| 状态 | 样子 |
| --- | --- |
| 静止 | 只有键帽，没有边框：列表读起来和只读列表一样。 |
| 悬停 | 按钮的 `radius-chip` 方框里铺中档叠加层，加一圈 `line-hairline`，方框在键帽四周各留 4px（[悬停与按下](https://raft-design.botiverse.build/zh/foundations/states.md#hover-and-press)）。 |
| 键盘焦点 | 同一个方框外面一圈焦点环。 |
| `recording` | 一个输入框：控件高度（用鼠标时 34px，触屏上 44px），`layer-card`，1.5px 的 `line-field` 边框，控件圆角；从键盘进入时还有焦点环；键帽距输入框起始边 8px。它会设置 `data-recording`。 |
| `invalid` | 键帽用 `danger-soft`，文字和线用 `danger-strong`；录制中的输入框边框用 `danger-strong`。它会设置 `data-invalid`。 |

用点击开始录制时，输入框不显示焦点环，就像用鼠标点击的文本输入框也不显示一样：它的边框、插入符和行的高亮已经标出了它，而且这个人刚刚就指着那里（[焦点](https://raft-design.botiverse.build/zh/foundations/states.md#focus)）。用 Enter 或 Space 开始时，保留焦点环。

插入符不闪烁：它标出下一个按键落在哪里；在供人阅读的列表里，闪烁是不带任何新信息的动效（[动效](https://raft-design.botiverse.build/zh/foundations/motion.md)）。

## 尺寸
- 静止时，按钮的方框在键帽四周各超出 4px，包着 24px 的键帽，高 32px；再用负外边距把这 4px 收回来，所以键帽正好在只读 `KbdShortcut` 所在的位置：在行尾，在行内垂直居中，行高也不变。它的点击区域达到 44px（[无障碍](https://raft-design.botiverse.build/zh/foundations/accessibility.md)）。
- 录制时，输入框是控件高度，和旁边的输入框、按钮一样高；它的外边距收回比键帽多出的部分：用鼠标时是 (34 − 24) / 2 = 5px，触屏上是 10px。键帽和行的边缘之间至少要留这么多：ListRow 的 12px 就够（[ListRow](https://raft-design.botiverse.build/zh/components/list-row.md)）。输入框向起始端扩展，所以它的末端保持在行尾。

## 行为
KbdButton 负责画出各个状态；录下按键由产品来做。

- 点击、按 Enter 或按 Space 开始录制。录制时，每次按键都是快捷键：阻止它的默认行为，实时显示按住的修饰键，在第一个不是修饰键的按键上落定快捷键。
- 按 Esc、点击别处或离开面板都会取消；在有效的快捷键落定之前，什么都不保存。
- 让不带修饰键的 Tab 离开输入框并取消，这样录制永远不会困住键盘。
- 不能用的按键（浏览器或系统保留的按键、单个按键）以 `invalid` 状态留在输入框里，原因写在行的第二行，直到下一次按键；录制保持开启。
- 属于另一个命令的按键先暂留，不保存：保留输入框，去掉插入符，在行下面附一条提示，带 Cancel 和各个选项（[Banner](https://raft-design.botiverse.build/zh/components/banner.md#attached-to-a-list-row)）。

```tsx
import {
  Banner,
  BannerAction,
  BannerDescription,
  BannerTitle,
  Button,
  Text,
} from "@botiverse/raft-ui";
import {
  KbdButton,
  KbdButtonCaret,
  KbdButtonPlaceholder,
  KbdShortcut,
} from "@botiverse/raft-ui/wip";
import type { KbdKeyName } from "@botiverse/raft-ui/wip";
import { TriangleAlert } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import type { KeyboardEvent, MouseEvent, ReactNode } from "react";

type Command = "open" | "search" | "sidebar";

const LABELS: Record<Command, string> = {
  open: "Open keyboard shortcuts",
  search: "Search",
  sidebar: "Collapse or expand the sidebar",
};

const WHERE: Record<Command, string> = {
  open: "Anywhere",
  search: "Anywhere, even while typing",
  sidebar: "In a wide window",
};

const MODIFIERS = new Set(["Meta", "Control", "Alt", "Shift"]);

/** The keys of a key press, by name; ⌘ and Ctrl are both `mod`. */
function pressed(event: KeyboardEvent): KbdKeyName[] {
  const keys: KbdKeyName[] = [];
  if (event.metaKey || event.ctrlKey) keys.push("mod");
  if (event.altKey) keys.push("alt");
  if (event.shiftKey) keys.push("shift");
  if (MODIFIERS.has(event.key)) return keys;
  if (event.code.startsWith("Key")) keys.push(event.code.slice(3).toLowerCase());
  else if (event.code.startsWith("Digit")) keys.push(event.code.slice(5));
  else keys.push(event.key);
  return keys;
}

/** The keys as `aria-keyshortcuts` writes them: Meta+Shift+K. */
function ariaKeys(keys: readonly KbdKeyName[]) {
  const names: Record<string, string> = { mod: "Meta", alt: "Alt", shift: "Shift" };
  return keys.map((key) => names[key] ?? key.toUpperCase()).join("+");
}

const same = (a: readonly KbdKeyName[], b: readonly KbdKeyName[]) => a.join(" ") === b.join(" ");

const commandOf = (event: { currentTarget: HTMLElement }) =>
  event.currentTarget.dataset.command as Command;

type Conflict = { command: Command; keys: KbdKeyName[]; owner: Command };

export function KbdButtonShortcutsPanelContent() {
  const [bindings, setBindings] = useState<Record<Command, KbdKeyName[]>>({
    open: ["mod", "/"],
    search: ["mod", "k"],
    sidebar: ["mod", "\\"],
  });
  const [recording, setRecording] = useState<Command | null>(null);
  const [held, setHeld] = useState<KbdKeyName[]>([]);
  const [refused, setRefused] = useState<string | null>(null);
  const [conflict, setConflict] = useState<Conflict | null>(null);
  const buttons = useRef<Partial<Record<Command, HTMLButtonElement>>>({});

  const register = useCallback((element: HTMLButtonElement | null) => {
    if (element) buttons.current[element.dataset.command as Command] = element;
  }, []);

  const stop = useCallback((focus?: Command) => {
    setRecording(null);
    setHeld([]);
    setRefused(null);
    setConflict(null);
    if (focus) buttons.current[focus]?.focus();
  }, []);

  const start = useCallback((event: MouseEvent<HTMLButtonElement>) => {
    setRecording((current) => current ?? commandOf(event));
  }, []);

  const record = useCallback(
    (event: KeyboardEvent<HTMLButtonElement>) => {
      const command = commandOf(event);
      if (recording !== command) return;
      if (conflict) {
        if (event.key === "Escape") stop(command);
        return;
      }
      // Tab with no modifier leaves the field, so recording never traps the keyboard.
      if (event.key === "Tab" && !event.metaKey && !event.ctrlKey && !event.altKey) return;
      event.preventDefault();
      if (event.key === "Escape") {
        stop(command);
        return;
      }
      const keys = pressed(event);
      setHeld(keys);
      setRefused(null);
      if (MODIFIERS.has(event.key)) return;
      if (!event.metaKey && !event.ctrlKey && !event.altKey) {
        setRefused("Add ⌘, ⌥ or ⌃. Single keys aren't supported.");
        return;
      }
      const owner = (Object.keys(bindings) as Command[]).find(
        (other) => other !== command && same(bindings[other], keys),
      );
      if (owner) {
        setConflict({ command, keys, owner });
        return;
      }
      setBindings({ ...bindings, [command]: keys });
      stop(command);
    },
    [bindings, conflict, recording, stop],
  );

  const leave = useCallback(() => {
    if (!conflict) stop();
  }, [conflict, stop]);

  const resolve = useCallback(
    (swap: boolean) => {
      if (!conflict) return;
      const { command, keys, owner } = conflict;
      setBindings({ ...bindings, [command]: keys, [owner]: swap ? bindings[command] : [] });
      stop(command);
    },
    [bindings, conflict, stop],
  );
  const swap = useCallback(() => resolve(true), [resolve]);
  const replace = useCallback(() => resolve(false), [resolve]);
  const cancel = useCallback(() => stop(conflict?.command), [conflict, stop]);

  const row = (command: Command): ReactNode => {
    const isRecording = recording === command;
    const keys = isRecording ? held : bindings[command];
    const hintId = `kbd-button-panel-${command}`;
    return (
      <>
        <div
          data-recording={isRecording ? "" : undefined}
          className="flex min-h-11 items-center justify-between gap-4 rounded-control px-2 py-1.5 data-recording:bg-fill-cell-selected pointer-coarse:px-3 pointer-coarse:py-2.5"
        >
          <div className="min-w-0">
            <Text.Sans>{LABELS[command]}</Text.Sans>
            <Text.Sans
              size="small"
              variant={isRecording && refused ? "danger" : undefined}
              id={hintId}
            >
              {isRecording ? (refused ?? "Press the new shortcut, Esc to cancel") : WHERE[command]}
            </Text.Sans>
          </div>
          <KbdButton
            ref={register}
            data-command={command}
            recording={isRecording}
            invalid={isRecording && refused !== null}
            aria-label={`Change the shortcut for ${LABELS[command]}`}
            aria-keyshortcuts={
              bindings[command].length > 0 ? ariaKeys(bindings[command]) : undefined
            }
            aria-describedby={hintId}
            onClick={start}
            onKeyDown={record}
            onBlur={isRecording ? leave : undefined}
          >
            {keys.length > 0 ? (
              <KbdShortcut keys={keys} size="md" platform="mac" />
            ) : (
              <KbdButtonPlaceholder>
                {isRecording ? "Press a shortcut" : "No shortcut"}
              </KbdButtonPlaceholder>
            )}
            {isRecording && !refused && !conflict ? <KbdButtonCaret /> : null}
          </KbdButton>
        </div>
        {conflict?.command === command ? (
          <Banner status="warning" size="sm">
            <TriangleAlert />
            <BannerTitle>
              <KbdShortcut keys={conflict.keys} platform="mac" /> is used by{" "}
              {LABELS[conflict.owner]}
            </BannerTitle>
            <BannerDescription>
              Swap gives it this shortcut&rsquo;s old keys. Replace leaves it without a shortcut.
            </BannerDescription>
            <BannerAction>
              <Button variant="ghost" size="md" onClick={cancel}>
                Cancel
              </Button>
              <Button variant="default" size="md" onClick={swap}>
                Swap
              </Button>
              <Button variant="default" size="md" onClick={replace}>
                Replace
              </Button>
            </BannerAction>
          </Banner>
        ) : null}
      </>
    );
  };

  return (
    <div className="flex w-full max-w-100 flex-col gap-1">
      <Text.Sans size="group" render={<h3 />}>
        Global
      </Text.Sans>
      <div className="-mx-2 flex flex-col gap-0.5 pointer-coarse:-mx-3">
        {row("open")}
        {row("search")}
        {row("sidebar")}
      </div>
    </div>
  );
}
```

## 无障碍
- KbdButton 是 Base UI 的 Button，即原生 `button`。它自己没有文字：用 `aria-label` 按它对应的命令给它命名（"Change the shortcut for Search"）。键帽的视觉隐藏文字会被这个名称取代。
- 用 `aria-keyshortcuts` 传入当前的快捷键（`Meta+K`），让读屏软件能读出来；没有快捷键的命令就不要加。
- `data-recording` 和 `data-invalid` 是样式钩子，不会播报。把 `aria-describedby` 指向行的第二行，产品在那里写操作说明（"Press the new shortcut, Esc to cancel"）或拒绝这些按键的原因。
- 附在行上的提示关闭时，把焦点移回 KbdButton，以免焦点落在文档本身上。
- 不要加 `aria-pressed`：录制不是按下后一直保持开启的切换，标成按下的按钮传达的意思是错的。

## 规则
- 只在绑定可以修改的地方用 KbdButton，并且永远不要禁用它：不能修改的快捷键是不带按钮的 `KbdShortcut`。
- 每个 KbdButton 里放一个 `md` 的 `KbdShortcut`。可选的快捷键（"⌘↑ / ⌘↓"）是两个 KbdButton，中间是 "/"，和只读列表一样（[Kbd](https://raft-design.botiverse.build/zh/components/kbd.md#spacing-and-shape)）：每个都是独立的绑定。
- 不要在按钮里放标签、图标或 "+"：行的标签说明快捷键做什么，键帽就是值。
- 用文字说明按键被拒绝的原因，写在行的第二行，不要只靠危险色（[颜色](https://raft-design.botiverse.build/zh/foundations/color.md#meanings-of-color)）。

## API

### KbdButton

A shortcut that can be changed: a native button around a KbdShortcut at `md`. Name it with `aria-label` ("Change the shortcut for Search") and pass the current keys in `aria-keyshortcuts`. A shortcut that can't be changed stays a KbdShortcut, with no button.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `invalid` | `boolean` | `false` | The keys in it can't be used: the caps take the danger tint, the field a danger border, and it sets `data-invalid`. Say why in text the button names with `aria-describedby`. |
| `recording` | `boolean` | `false` | The button is listening for a new shortcut: it draws as a field with the focus ring when it was reached from the keyboard, and sets `data-recording`. Put the held keys in it, then `KbdButtonCaret` while more can come. |
| `focusableWhenDisabled` | `boolean` | `false` | Whether the button should be focusable when disabled. |
| `nativeButton` | `boolean` | `true` | Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`). |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### KbdButtonCaret

The caret after the held keys, while a recording KbdButton waits for more.

Also takes the attributes of its element.

### KbdButtonPlaceholder

What a recording KbdButton shows before any key: the consumer's prompt.

Also takes the attributes of its element.
