UNPKG

@tanstack/hotkeys

Version:

Type-safe, framework-agnostic keyboard hotkey management for the browser

273 lines • 11.7 kB
import { CanonicalModifier, Key, LogicalKey } from "./key.types.js"; //#region src/hotkey.types.d.ts /** * Single modifier + key combinations. * Uses canonical modifiers (4) + Mod (1) = 5 modifiers. * * The `Mod` modifier is platform-adaptive: * - **macOS**: Resolves to `Meta` (Command key ⌘) * - **Windows/Linux**: Resolves to `Control` (Ctrl key) * * This enables cross-platform hotkey definitions that automatically adapt to the platform. * For example, `Mod+S` becomes `Command+S` on Mac and `Ctrl+S` on Windows/Linux. */ export type SingleModifierHotkeyChord = `Control+${Key}` | `Alt+${Key}` | `Shift+${Key}` | `Meta+${Key}` | `Mod+${Key}`; /** * Two modifier + key combinations. * * **Platform-adaptive `Mod` combinations:** * - `Mod+Alt` and `Mod+Shift` are included (safe on all platforms) * - `Mod+Control` and `Mod+Meta` are excluded because they create duplicate modifiers: * - `Mod+Control` duplicates `Control` on Windows/Linux (Mod = Control) * - `Mod+Meta` duplicates `Meta` on macOS (Mod = Meta) */ export type TwoModifierHotkeyChord = `Control+Alt+${Key}` | `Control+Shift+${Key}` | `Control+Meta+${Key}` | `Alt+Shift+${Key}` | `Alt+Meta+${Key}` | `Shift+Meta+${Key}` | `Mod+Alt+${Key}` | `Mod+Shift+${Key}`; /** * Three modifier + key combinations. * * **Platform-adaptive `Mod` combinations:** * - `Mod+Alt+Shift` is included (safe on all platforms) * - `Mod+Control+Shift` and `Mod+Shift+Meta` are excluded because they create duplicate modifiers: * - `Mod+Control+Shift` duplicates `Control` on Windows/Linux (Mod = Control) * - `Mod+Shift+Meta` duplicates `Meta` on macOS (Mod = Meta) */ export type ThreeModifierHotkeyChord = `Control+Alt+Shift+${Key}` | `Control+Alt+Meta+${Key}` | `Control+Shift+Meta+${Key}` | `Alt+Shift+Meta+${Key}` | `Mod+Alt+Shift+${Key}`; /** * Four modifier + key combinations. * * Only the canonical `Control+Alt+Shift+Meta` combination is included. * * **Why no `Mod` combinations?** * Since `Mod` resolves to either `Control` (Windows/Linux) or `Meta` (macOS), any * four-modifier combination with `Mod` would create duplicate modifiers on one platform. * For example: * - `Mod+Control+Alt+Shift` → duplicates `Control` on Windows/Linux * - `Mod+Alt+Shift+Meta` → duplicates `Meta` on macOS */ export type FourModifierHotkeyChord = `Control+Alt+Shift+Meta+${Key}`; /** * A type-safe hotkey string. * * Provides autocomplete for: * - All single keys (letters, numbers, function keys, navigation, editing, punctuation) * - Single modifier + common key (Control+S, Mod+A, Mod+/, etc.) * - Two modifiers + common key (Mod+Shift+S, Control+Alt+A, etc.) * - Three modifiers + common key (Control+Alt+Shift+A, Mod+Alt+Shift+S, etc.) * - Four modifiers + common key (Control+Alt+Shift+Meta+A, etc.) * * ## Modifier Names * * Use canonical modifier names: * - `Control` (not Ctrl) - The Control key * - `Alt` (not Option) - The Alt key (Option on macOS) * - `Meta` (not Command/Cmd) - The Meta/Command key (macOS only) * - `Shift` - The Shift key * * ## Platform-Adaptive `Mod` Modifier * * The `Mod` modifier is a special platform-adaptive modifier that automatically resolves * to the "primary modifier" on each platform: * * - **macOS**: `Mod` → `Meta` (Command key ⌘) * - **Windows/Linux**: `Mod` → `Control` (Ctrl key) * * This enables cross-platform hotkey definitions that work correctly on all platforms * without platform-specific code. The `Mod` modifier is resolved at runtime based on * the detected platform. * * **When to use `Mod` vs platform-specific modifiers:** * - Use `Mod` for cross-platform shortcuts (e.g., `Mod+S` for save) * - Use `Meta` or `Control` when you need platform-specific behavior * - Use `Mod` when you want your shortcuts to follow platform conventions automatically * * **Limitations:** * - `Mod+Control` and `Mod+Meta` combinations are not allowed (they create duplicate * modifiers on one platform) * - In four-modifier combinations, only canonical modifiers are allowed (no `Mod`) * * @example * ```ts * // Cross-platform shortcuts (recommended) * const save: HotkeyChord = 'Mod+S' // Command+S on Mac, Ctrl+S on Windows/Linux * const saveAs: HotkeyChord = 'Mod+Shift+S' // Command+Shift+S on Mac, Ctrl+Shift+S elsewhere * const comment: HotkeyChord = 'Mod+/' // Command+/ on Mac, Ctrl+/ elsewhere * * // Platform-specific shortcuts * const macOnly: HotkeyChord = 'Meta+S' // Command+S on Mac only * const windowsOnly: HotkeyChord = 'Control+S' // Ctrl+S on Windows/Linux only * ``` */ export type HotkeyChord = Key | SingleModifierHotkeyChord | TwoModifierHotkeyChord | ThreeModifierHotkeyChord | FourModifierHotkeyChord; /** Resolved modifier flags shared by both parsed binding variants. */ export interface ParsedModifiers { /** Whether Control is required. */ ctrl: boolean; /** Whether Shift is required. */ shift: boolean; /** Whether Alt is required. */ alt: boolean; /** Whether Meta (Command) is required. */ meta: boolean; /** Required modifiers in canonical order. */ modifiers: Array<CanonicalModifier>; } /** * A parsed chord matches either a logical key or a physical code, never both. * Check `code !== undefined` to narrow to a physical binding. Its logical * character is unknown without a keyboard event and is not stored in `key`. * * @example * parseHotkey('Alt+S') // { key: 'S', alt: true, ... } * parseHotkey('Alt+[KeyS]') // { code: 'KeyS', alt: true, ... } with no key property */ export type ParsedHotkeyChord = ParsedModifiers & ({ key: LogicalKey | (string & {}); code?: never; } | { code: string; key?: never; }); /** Modifier flags shared by logical-key and physical-code registrations. */ export interface RawModifiers { /** Platform-adaptive modifier: Command on macOS, Control on Windows/Linux. Defaults to false. */ mod?: boolean; /** Whether the Control key is required. Defaults to false. */ ctrl?: boolean; /** Whether the Shift key is required. Defaults to false. */ shift?: boolean; /** Whether the Alt key is required. Defaults to false. */ alt?: boolean; /** Whether the Meta (Command) key is required. Defaults to false. */ meta?: boolean; } /** * A chord written as an object instead of a string, for building chords from * data. Set `key` for a logical key or `code` for a physical one, and set a * boolean for each modifier. Omitted modifiers default to `false`. * * The `mod` modifier is platform-adaptive: Command on macOS, Control on Windows/Linux. * Sequence steps can be objects too: `[{ key: 'K', mod: true }, 'C']`. * * @example * ```ts * useHotkey({ key: 'S', mod: true }, handler) // Mod+S (cross-platform) * useHotkey({ key: 'S', ctrl: true }, handler) // Control+S * useHotkey({ key: 'Escape' }, handler) // Escape (no modifiers) * useHotkey({ key: 'A', shift: true, meta: true }, handler) // Shift+Meta+A * useHotkey({ key: 'S', mod: true, shift: true }, handler) // Mod+Shift+S * useHotkey({ code: 'KeyW', mod: true }, handler) // Physical Mod+KeyW * useHotkey({ code: 'NumpadAdd', mod: true }, handler) // Physical numpad plus * ``` */ export type RawHotkeyChord = ({ key: LogicalKey | (string & {}); code?: never; } & RawModifiers) | ({ code: string; key?: never; } & RawModifiers); /** * A sequence of chord strings pressed one after another (Vim-style), such as * `['G', 'G']`. Registered sequences are stored in this form. To register * steps written as objects, see {@link RegisterableHotkeySequence}. * * Steps may include modifiers; the same modifier can appear on consecutive * steps (e.g. `['Shift+R', 'Shift+T']`). Modifier-only key events do not * advance or reset matching. Automatic repeats and IME composition are ignored. * A one-step sequence is treated as its single chord. */ export type HotkeySequence = ReadonlyArray<HotkeyChord>; /** * A hotkey: one chord (`'Mod+S'`) or a sequence of chords (`['G', 'G']`). */ export type Hotkey = HotkeyChord | HotkeySequence; /** A single chord as a type-safe string (`HotkeyChord`) or an object (`RawHotkeyChord`). */ export type RegisterableHotkeyChord = HotkeyChord | RawHotkeyChord; /** * A sequence whose steps are chord strings or chord objects, such as * `['G', { key: 'H', shift: true }]`. */ export type RegisterableHotkeySequence = ReadonlyArray<RegisterableHotkeyChord>; /** * Anything that can be registered: a chord or a sequence, with each chord * written as a string or an object. */ export type RegisterableHotkey = RegisterableHotkeyChord | RegisterableHotkeySequence; /** Context passed to a chord's callback. */ export interface HotkeyChordCallbackContext { kind: 'chord'; /** The registered chord */ hotkey: HotkeyChord; /** The chord with `Mod` resolved for the registration's platform */ parsedChord: ParsedHotkeyChord; } /** Context passed to a sequence's callback. */ export interface HotkeySequenceCallbackContext { kind: 'sequence'; /** The registered sequence */ hotkey: HotkeySequence; /** Each step with `Mod` resolved for the registration's platform */ parsedSequence: Array<ParsedHotkeyChord>; } /** * Context passed to hotkey callbacks along with the keyboard event. * Narrow with `kind` to read `parsedChord` or `parsedSequence`. */ export type HotkeyCallbackContext = HotkeyChordCallbackContext | HotkeySequenceCallbackContext; /** * The callback context for a hotkey: chord context for chords (including * one-step tuples), sequence context for sequences, and either when the kind * is not known statically. */ export type HotkeyCallbackContextFor<T extends RegisterableHotkey> = [T] extends [never] ? HotkeyCallbackContext : T extends readonly [RegisterableHotkeyChord] ? HotkeyChordCallbackContext : T extends RegisterableHotkeySequence ? HotkeySequenceCallbackContext : HotkeyChordCallbackContext; /** * Callback function type for hotkey handlers. * * @param event - The keyboard event that triggered the hotkey * @param context - The registered hotkey and its parsed form. APIs that take * one hotkey narrow it to chord or sequence context from the hotkey you pass. * * @example * ```ts * const handler: HotkeyCallback = (event, context) => { * if (context.kind === 'chord') { * console.log(context.hotkey, context.parsedChord.modifiers) * } else { * console.log(context.hotkey.join(' '), context.parsedSequence.length) * } * } * ``` */ export type HotkeyCallback<TContext extends HotkeyCallbackContext = HotkeyCallbackContext> = (event: KeyboardEvent, context: TContext) => void; /** * Metadata for hotkey registrations, chords and sequences alike. * Includes `name`, `description`, and `group` by default. Extend via declaration merging: * * @example * ```ts * declare module '@tanstack/hotkeys' { * interface HotkeyMeta { * category?: string * icon?: string * } * } * ``` */ export interface HotkeyMeta { /** Human-readable name for this hotkey (e.g., "Save Document") */ name?: string; /** Description of what this hotkey does */ description?: string; /** Optional display group. Does not affect matching or scope. */ group?: string; } /** * Behavior when registering a hotkey/sequence that conflicts with an existing registration. * * - `'warn'` - Log a warning to the console but allow both registrations (default) * - `'error'` - Throw an error and prevent the new registration * - `'replace'` - Unregister the existing registration and register the new one * - `'allow'` - Allow multiple registrations without warning */ export type ConflictBehavior = 'warn' | 'error' | 'replace' | 'allow'; //#endregion