# Keyboard Manager

A keyboard manager compatible with JavaScript, TypeScript and Nuxt.

[![validate](https://github.com/Waradu/keyboard/actions/workflows/validate.yml/badge.svg)](https://github.com/Waradu/keyboard/actions/workflows/validate.yml)

- [Install](#install)
- [Get Started](#get-started)
- [Nuxt](#nuxt)
- [Usage](#usage)
- [Key Strings](#key-strings)
- [Handler](#handler)
- [Layers](#layers)
- [Config](#config)
- [Directives](#directives)
- [Changes](#changes)
- [Development](#development)
- [Examples](#examples)

## Install

```bash
bun install @waradu/keyboard
```

## Get Started

Create a keyboard instance and bind a shortcut:

```ts
import { Keyboard } from "@waradu/keyboard";

const keyboard = new Keyboard();

const off = keyboard.bind({
  keys: "ctrl+k",
  run({ event }) {
    console.log("pressed", event.key);
  },
});

off();
```

`Keyboard` initializes itself when `window` is available. If you create an instance before `window` exists, call `keyboard.init()` later.

## Nuxt

Add the Nuxt module to `nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  modules: ["@waradu/keyboard/nuxt"],
});
```

The module provides `$keyboard`, auto-imports composables, registers directives and initializes the keyboard on `app:mounted`.

```ts
const off = useKeybind({
  keys: "a",
  run() {
    console.log("A key pressed");
  },
});
```

`useKeybind` mirrors `keyboard.bind` and automatically removes the binding when the component unmounts.

```ts
useKeybind([
  {
    keys: "ctrl+z",
    run() {
      console.log("undo");
    },
  },
  {
    keys: "ctrl+shift+z",
    run() {
      console.log("redo");
    },
  },
]);
```

Create a layer in Nuxt:

```ts
const editor = useKeybindLayer("editor");

editor.bind({
  keys: "escape",
  run() {
    editor.disable();
  },
});
```

Inspect active handlers:

```ts
const { handlers, unsubscribe } = useKeyboardInspector();

console.log(handlers.value);
unsubscribe();
```

Record keybinds:

```ts
const stop = useKeybindRecorder((keybind) => {
  console.log(keybind.toString());
});

stop();
```

Access the provided instance directly:

```ts
const { $keyboard } = useNuxtApp();

$keyboard.destroy();
```

The module accepts a `debug` option:

```ts
export default defineNuxtConfig({
  modules: ["@waradu/keyboard/nuxt"],
  keyboard: {
    debug: true,
  },
});
```

## Usage

### Bind One Handler

```ts
const off = keyboard.bind({
  keys: ["ctrl+y", "ctrl+shift+z"],
  run() {
    console.log("redo");
  },
  config: {
    prevent: true,
  },
});

off();
```

`keys` can be a key string, a `Keybind`, a plain keybind shape, or an array of them.

```ts
keyboard.bind({
  keys: {
    platform: "macos",
    key: "a",
    modifiers: {
      alt: true,
    },
  },
  run() {},
});
```

### Bind Multiple Handlers

```ts
keyboard.bind([
  {
    keys: "ctrl+z",
    run() {
      console.log("undo");
    },
  },
  {
    keys: "ctrl+shift+z",
    run() {
      console.log("redo");
    },
  },
]);
```

You can pass shared config as the second argument:

```ts
keyboard.bind(
  [
    {
      keys: "ctrl+s",
      run() {
        console.log("save");
      },
    },
  ],
  {
    prevent: true,
  },
);
```

### Inspect Handlers

```ts
const unsubscribe = keyboard.subscribe((handlers) => {
  console.log("active handlers", handlers);
});

unsubscribe();
```

### Record Keybinds

```ts
const stop = keyboard.record((keybind) => {
  console.log(keybind.toString());
});

stop();
```

### Keybinds

Use `Keybind` when you need to parse, compare, format, or display shortcuts.

```ts
import { Keybind } from "@waradu/keyboard";

const keybind = Keybind.fromString("ctrl-cmd+shift+k");

keybind?.toString(); // "ctrl-cmd+shift+k"
keybind?.toShape(); // { key, modifiers, platform? }
keybind?.equals("ctrl-cmd+shift+k"); // true
Keybind.equals("ctrl+k", "ctrl+k"); // true
```

Readable methods return parts so you can join them into text or render them as separate keycaps.

```ts
keybind?.toReadable(); // ["Ctrl-Cmd", "Shift", "K"]
keybind?.toLocalReadable({ platform: "macos" }); // ["Cmd", "Shift", "K"]
keybind?.toLocalReadable({ platform: "windows" }); // ["Ctrl", "Shift", "K"]

Keybind.fromString("no-macos:alt+enter")?.toReadablePlatform(); // "Linux & Windows"
```

## Key Strings

Key strings describe the key and modifiers that must be active for a handler to run.

The structure is:

```txt
(platform:)?(meta+)?(ctrl+)?(ctrl-cmd+)?(alt+)?(shift+)?key
```

Special keys:

- `$any`: match any key and ignores modifiers
- `$num`: match any number key and expose it as `context.template`

Platform prefixes:

- `macos`
- `win`
- `linux`
- `no-macos`
- `no-win`
- `no-linux`

The modifier order is fixed: `meta`, `ctrl`, `ctrl-cmd`, `alt`, `shift`, then the key.

`ctrl-cmd` maps to Cmd (`meta`) on macOS and Ctrl (`ctrl`) on other platforms.
It can be combined with `alt` and `shift`, but not with `meta` or `ctrl`.
In `KeybindShape` / `CreateKeybindShape` objects, the same modifier field is named `ctrlCmd`.

Examples:

- `"ctrl+x"`: valid
- `"ctrl-cmd+k"`: valid
- `"ctrl-cmd+shift+k"`: valid
- `"meta+ctrl+alt+shift+arrow-up"`: valid
- `"c"`: valid
- `"macos:x"`: valid
- `"alt+$num"`: valid
- `"$any"`: valid
- `""`: invalid
- `"shift+alt+y"`: invalid, because `shift` comes after `alt`
- `"meta+ctrl"`: invalid, because the key is missing
- `"meta+ctrl-cmd+k"`: invalid, because `ctrl-cmd` cannot be mixed with `meta` or `ctrl`
- `"lunix:x"`: invalid platform
- `"xy"`: invalid, because only one key can be used

## Handler

The handler receives a context object:

```ts
keyboard.bind({
  keys: "enter",
  run(context) {
    context.event;
    context.handler;
    context.template;
  },
});
```

Context fields:

- `context.event`: the original `KeyboardEvent`
- `context.handler`: the registered handler
- `context.template`: the matched template value, currently used by `$num`

Handler return values are ignored.

## Layers

Layers group handlers so they can be enabled or disabled together.

```ts
const editor = keyboard.layers.create("editor");

editor.bind({
  keys: "escape",
  run() {
    console.log("escape in editor layer");
  },
});

editor.disable();
editor.enable();
editor.toggle();
editor.off();
```

Create a disabled layer:

```ts
const modal = keyboard.layers.create("modal", true);
```

Manage layers globally:

```ts
keyboard.layers.enable("editor");
keyboard.layers.disable(["modal", "editor"]);
keyboard.layers.set("modal");
keyboard.layers.all();
keyboard.layers.none();
```

You can also assign layers directly through config:

```ts
keyboard.bind({
  keys: "escape",
  run() {},
  config: {
    layers: ["modal"],
  },
});
```

## Config

Keyboard-level config:

```ts
const keyboard = new Keyboard({
  debug: true,
  platform: "macos",
  signal: abortController.signal,
});
```

Handler config:

```ts
keyboard.bind({
  keys: "enter",
  run() {},
  config: {
    once: true,
    ignoreIfEditable: true,
    runIfFocused: [document.getElementById("email")],
    prevent: true,
    stop: true,
    when: true,
    signal: abortController.signal,
  },
});
```

Available handler config:

- `once`: remove the handler after the first run
- `ignoreIfEditable`: skip while an editable element is focused
- `runIfFocused`: only run if one of the provided elements is focused
- `prevent`: call `event.preventDefault()`
- `stop`: call `stopPropagation`, `stopImmediatePropagation`, or both
- `when`: boolean or predicate that controls whether the handler runs
- `layers`: layer names assigned to the handler
- `signal`: abort signal that removes the handler

`stop` accepts:

- `true`: call `event.stopPropagation()`
- `"immediate"`: call `event.stopImmediatePropagation()`
- `"both"`: call both propagation methods

## Directives

Directives are Nuxt-only. Pass the handler to `v-keybind` and use the directive arg as the key sequence.

```html
<input
  type="text"
  v-keybind:enter="
    () => {
      console.log('Hello, directive!');
    }
  "
/>
```

Put keyboard modifiers in the key sequence. `prevent` and `stop` are directive modifiers:

```html
<input type="text" v-keybind:ctrl+shift+enter.prevent.stop="onEnter" />
```

Use `ctrl-cmd` in the key sequence for the cross-platform Cmd/Ctrl key:

```html
<input type="text" v-keybind:ctrl-cmd+k="openCommandPalette" />
```

For platform-aware or multi-key bindings, use `useKeybind`.

The directive automatically limits the handler to that focused element.

## Changes

### v8 -> v9

- Replaced `useKeyboard()` with the `Keyboard` class
- Renamed `keyboard.listen` to `keyboard.bind`
- Renamed the `control` modifier to `ctrl`
- Changed key-string separators from `_` to `+`
- Changed catch-all key from `any` to `$any`
- Added the `Keybind` class for parsing, formatting, comparing, and converting keybinds
- Added readable `Keybind` labels for rendering shortcuts in UI
- Added automatic initialization when `window` is available
- Added `ctrl-cmd` as a cross-platform Cmd/Ctrl modifier
- Added `pause()`, `resume()`, and `toggle()` for temporarily pausing the keyboard
- Redesigned the Nuxt directive API to use `v-keybind:sequence` with `.prevent` and `.stop`
- Fixed run return type by ignoring it
- Better and faster OS detection
- Removed stats

### v7.4 -> v8

- Added `parseKeyData` to parse key data into a key string
- Renamed `FormattedKeySequence` to `KeyData`
- `KeyData` can now also be used to define keys
- Changed `KeyData` format

### v7.3 -> v7.4

- Added `keyboard.exists` to check if a key-string handler already exists
- Added `config.when` to control whether a handler runs

### v7.2 -> v7.3

- Added `keyboard.layers` to create and manage layers
- Added Nuxt-only `useKeybindLayer` composable

### v7.1 -> v7.2

- Added `keyboard.subscribe` for inspecting active handlers
- Added `keyboard.record` to record a keybind
- Added `parseKeyString` to parse a key string into key data
- Added Nuxt-only `useKeyboardInspector` and `useKeybindRecorder`

### v7 -> v7.1

- Added `v-keybind` and `v-run` directives
- Allowed passing a single sequence as the `keys` argument instead of requiring an array

## Development

You need [Bun](https://bun.sh).

```bash
bun install
```

Commands:

- `bun test`: run tests
- `bunx tsc --noEmit`: run type checking
- `bun run lint`: run linting
- `bun run format`: format files
- `bun run format:check`: check formatting
- `bun playground:prepare`: install playground dependencies
- `bun playground`: start the playground

## Examples

Catch any key press:

```ts
keyboard.bind({
  keys: "$any",
  run(ctx) {
    console.log("Key pressed:", ctx.event.key);
  },
});
```

Run only when an input is focused:

```ts
const input = document.getElementById("myInput");

keyboard.bind({
  keys: "enter",
  run() {
    console.log("Enter pressed while input is focused");
  },
  config: {
    runIfFocused: [input],
  },
});
```

Prevent default behavior:

```ts
keyboard.bind({
  keys: "ctrl+r",
  run() {
    console.log("Refresh prevented");
  },
  config: {
    prevent: true,
  },
});
```

Run a handler only once:

```ts
keyboard.bind({
  keys: "escape",
  run() {
    console.log("Escape pressed once");
  },
  config: {
    once: true,
  },
});
```

Platform-aware undo and redo:

```ts
keyboard.bind([
  {
    // With ctrl-cmd
    keys: "ctrl-cmd+z",
    run() {
      console.log("undo");
    },
  },
  {
    // Manually
    keys: ["no-macos:ctrl+shift+z", "macos:meta+shift+z"],
    run() {
      console.log("redo");
    },
  },
]);
```

Catch Alt plus any number:

```ts
keyboard.bind({
  keys: "alt+$num",
  run(ctx) {
    console.log("Number pressed:", ctx.template);
  },
});
```
