# Keyboard shortcuts (React + core)

Impact UI ships a small keyboard shortcut system: a **framework-agnostic** engine under `src/utils/shortcuts/` and **React** helpers in this folder. One global `keydown` listener dispatches to registered handlers by **priority** and **scope**.

---

## Table of contents

1. [Installation / imports](#installation--imports)
2. [`useShortcut`](#useshortcut)
3. [`ShortcutScope` & `useShortcutScope`](#shortcutscope--useshortcutscope)
4. [`ShortcutHint`](#shortcuthint)
5. [`ShortcutManager`](#shortcutmanager)
6. [Shortcut string syntax](#shortcut-string-syntax)
7. [macOS Option (Alt) keys](#macos-option-alt-keys)
8. [Core utilities (`utils/shortcuts`)](#core-utilities-utilsshortcuts)
9. [Browser conflicts](#browser-conflicts)

---

## Installation / imports

From the library barrel (published package):

```js
import {
  useShortcut,
  ShortcutScope,
  ShortcutHint,
  ShortcutManager,
  useShortcutScope,
  formatShortcutDisplay,
  parseShortcutString,
  normalizeKey,
  isMac,
} from 'impact-ui';
```

From source paths inside this repo:

```js
import {
  useShortcut,
  ShortcutScope,
  ShortcutHint,
  ShortcutManager,
} from './components/Shortcuts';
```

---

## `useShortcut`

Registers a shortcut when the component mounts and removes it on unmount. Handlers stay fresh via a ref (you do not need to wrap the callback in `useCallback` for correctness).

### Signature

```ts
useShortcut(keys, handler, options?)
```

| Argument | Type | Description |
|----------|------|-------------|
| `keys` | `string` | Shortcut definition (see [Shortcut string syntax](#shortcut-string-syntax)). |
| `handler` | `(event: KeyboardEvent) => void` | Called when the chord matches. |
| `options` | `object` | Optional; see table below. |

### Options

| Option | Default | Description |
|--------|---------|-------------|
| `id` | `` `${scope}::${keys}` `` | Unique registration id. Use a **stable** string if `keys` is dynamic. |
| `scope` | `'global'` or value from [`ShortcutScope`](#shortcutscope--useshortcutscope) context | Namespace; only shortcuts whose scope is **active** are considered. |
| `enabled` | `true` | When `false`, nothing is registered. |
| `allowInInput` | `false` | When `false`, shortcut does **not** fire if focus is in `input`, `textarea`, `select`, or `contenteditable`. |
| `priority` | `0` | Higher numbers are evaluated **first**; first match wins. |
| `description` | `''` | Shown in help UIs via `getAllShortcuts()`. |
| `preventDefault` | `true` | Whether to call `event.preventDefault()` when the shortcut fires. |

### Example

```jsx
import { useShortcut } from './components/Shortcuts';

function MyApp() {
  const [open, setOpen] = useState(false);

  useShortcut('mod+k', () => setOpen(true), {
    id: 'app-open-command-palette',
    description: 'Open command palette',
  });

  return <CommandPalette open={open} onClose={() => setOpen(false)} />;
}
```

---

## `ShortcutScope` & `useShortcutScope`

**Scopes** let you enable/disable groups of shortcuts (e.g. only while a modal is open). The `global` scope is always active; other scopes must be enabled.

### `ShortcutScope` (component)

Wraps children, enables the named scope when `active` is true, and provides the scope name through React context so nested `useShortcut` calls can omit `scope`.

**Props**

| Prop | Type | Default | Description |
|------|------|---------|---------------|
| `name` | `string` | (required) | Scope identifier. |
| `active` | `boolean` | `true` | Whether this scope is on. |
| `children` | `node` | (required) | Tree that inherits context. |

**Example**

```jsx
import { ShortcutScope, useShortcut } from './components/Shortcuts';

function SearchModal({ open, onClose }) {
  if (!open) return null;

  return (
    <ShortcutScope name="modal-search" active>
      <ModalBody onClose={onClose} />
    </ShortcutScope>
  );
}

function ModalBody({ onClose }) {
  useShortcut('escape', onClose, {
    id: 'search-esc',
    scope: 'modal-search',
    priority: 10,
    description: 'Close search',
  });
  return <input autoFocus />;
}
```

### `useShortcutScope` (hook)

Same enable/disable behavior **without** wrapping children in a component. Returns `enable` / `disable` for imperative toggling.

```js
const { enable, disable } = useShortcutScope('my-panel', isPanelOpen);
```

---

## `ShortcutHint`

Renders a small `<kbd>` badge with a **platform-aware** label (e.g. ⌘K on macOS, Ctrl+K on Windows), using the same `keys` string as `useShortcut`.

**Props**

| Prop | Type | Description |
|------|------|-------------|
| `keys` | `string` | Shortcut string, e.g. `'mod+k'`, `'alt+f'`. |
| `style` | `object` | Optional inline styles (merged with defaults). |
| `className` | `string` | Optional CSS class. |

**Example**

```jsx
<button type="button">
  Search <ShortcutHint keys="mod+k" />
</button>
```

CSS variables (optional overrides):

- `--shortcut-hint-color`
- `--shortcut-hint-bg`
- `--shortcut-hint-border`

---

## `ShortcutManager`

Singleton registry used under the hood. In React apps you usually use `useShortcut`; use the manager directly for **help screens**, **debug**, or **non-React** code.

### Getting the instance

```js
const manager = ShortcutManager.getInstance();
// First call only: ShortcutManager.getInstance({ debug: false, sequenceTimeout: 1000 })
```

### Registration API

| Method | Description |
|--------|-------------|
| `register(registration)` | Same shape as `useShortcut` options plus `id`, `keys`, `handler`. Replacing the same `id` is idempotent (safe with Strict Mode). |
| `unregister(id)` | Remove by id. |
| `isRegistered(id)` | Returns boolean. |

### Scopes

| Method | Description |
|--------|-------------|
| `enableScope(name)` | Activate a scope. |
| `disableScope(name)` | Deactivate; **`global` cannot be disabled**. |
| `isScopeActive(name)` | Returns boolean. |

### Queries & debug

| Method | Description |
|--------|-------------|
| `getAllShortcuts()` | `{ id, keys, scope, description, priority }[]` |
| `getShortcutsByScope(scope)` | Same fields except `scope` (filtered). |
| `setDebug(boolean)` | Log matched shortcuts to the console. |
| `destroy()` | Remove listener and clear state (e.g. tests). |

### Static helpers

| Method | Description |
|--------|-------------|
| `ShortcutManager.resetInstance()` | `destroy()` + clear singleton (mainly tests). |

---

## Shortcut string syntax

| Token | Meaning |
|-------|---------|
| `mod` | **⌘ Command** on macOS, **Ctrl** on Windows/Linux. |
| `ctrl`, `meta`, `alt`, `shift` | Literal modifiers. |
| `+` | Joins modifiers and key: `mod+shift+p`. |
| Space | **Sequence**: `g i` means press `g`, then `i` within the sequence timeout. |

**Aliases** (examples): `esc` → Escape, `space` → Space, `up` → ArrowUp (see `KEY_ALIASES` in `utils/shortcuts/constants.js`).

**Examples**

| String | Behavior |
|--------|----------|
| `mod+k` | Cmd+K (Mac) or Ctrl+K (Windows). |
| `alt+f` | Option+F (Mac) or Alt+F (Windows). |
| `ctrl+shift+d` | Control+Shift+D (explicit; does not use `mod`). |
| `escape` | Escape with no modifiers. |
| `g i` | `g` then `i` (sequence). |

---

## macOS Option (Alt) keys

Holding **Option** while typing a letter often changes `event.key` to a special character (e.g. Option+F → `ƒ`). The matcher falls back to **`event.code`** (physical key) so shortcuts like `alt+f` still match. You can keep using `alt+` in your shortcut strings.

---

## Core utilities (`utils/shortcuts`)

Imported from `components/Shortcuts` re-exports or directly:

```js
import {
  ShortcutManager,
  formatShortcutDisplay,
  parseShortcutString,
  normalizeKey,
  normalizeShortcutString,
  normalizedPhysicalKey,
  matchShortcut,
  isMac,
} from '../../utils/shortcuts';
```

| Export | Role |
|--------|------|
| `formatShortcutDisplay(keys)` | Human-readable label for UI. |
| `parseShortcutString(str)` | Parse string → internal combo/sequence structure. |
| `normalizeKey(key)` | Normalize `KeyboardEvent.key`. |
| `normalizedPhysicalKey(code)` | Map `KeyboardEvent.code` → key (Option-key fix). |
| `matchShortcut(event, combo)` | Low-level match test. |
| `isMac` | Platform flag for display / `mod` resolution. |

---

## Browser conflicts

Some chords are **not registered** because they overlap critical browser shortcuts (e.g. refresh, close tab). The engine logs a warning and skips registration. See `BROWSER_SHORTCUT_BLACKLIST` in `src/utils/shortcuts/constants.js`.

Even when a chord is allowed, the browser may still handle **Cmd/Ctrl+S** (save) or similar before your app; `preventDefault` in capture phase helps but is not guaranteed for every browser/OS shortcut.

---

## Related

- Storybook: **Components → Shortcuts** (`src/stories/Shortcuts.stories.js`)
- Demo shell: `src/App.js` (optional local playground)
- Types (JSDoc): `src/utils/shortcuts/types.js`
