Keyboard Shortcuts
Martis ships a tiny in-house registry for keyboard shortcuts. Every combo the admin shell, custom Tools, or consumer plugins want to bind goes through one API, so the help overlay (Shift+?) always lists the live set and conflict detection has a single point of truth.
No 3rd-party dependency: implementation lives in resources/js/lib/keyboardShortcuts.ts (~200 lines on top of the browser KeyboardEvent API).
Public API
An extension imports the three functions from @martis/runtime (v1.38.0+), with the ShortcutOptions and Shortcut types:
import { addShortcut, disableShortcut, listShortcuts } from '@martis/runtime' const dispose = addShortcut('mod+s', handler) // returns a disposerdisableShortcut('mod+s') // removes every handler under the combolistShortcuts() // the live registrationsThey are the functions the shell registers its own combos with, so a combo your extension adds shows in the help overlay and takes part in the same conflict order. The SPA also sets them on window.Martis.shortcuts (as add, remove and list) before it loads your extension bundle, for code that is not part of the bundle.
Inside the package the same three functions are module exports:
// Package-internal: resources/js/lib/keyboardShortcuts.ts, which @martis/runtime re-exports.import { addShortcut, disableShortcut, listShortcuts } from '@/lib/keyboardShortcuts'addShortcut(combo, handler, options?)
Registers a new shortcut. Returns a disposer for symmetric cleanup (call it from useEffect cleanup, Tool teardown, etc.).
import { addShortcut } from '@martis/runtime' const dispose = addShortcut('mod+k', () => openPalette(), { description: 'Open command palette', group: 'Navigation', allowInInput: true,}) // laterdispose()The options object is a ShortcutOptions:
| Option | Type | Default | Description |
|---|---|---|---|
description | string | combo | Human-readable label rendered in the help overlay. |
group | string | 'Custom' | Group heading the help overlay clusters under. |
allowInInput | boolean | false | Fire even when an <input>/<textarea>/<select>/contentEditable element is focused. Use sparingly — typically only for global commands like mod+k. |
preventDefault | boolean | true | Call event.preventDefault() before invoking the handler. |
disableShortcut(combo)
Removes every handler registered under combo.
import { disableShortcut } from '@martis/runtime' disableShortcut('mod+k') // removes every handler registered under mod+k so farThe shell registers its own combos when it mounts, after your extension entry has run: to replace one of them, see Take over a bundled shortcut.
listShortcuts(): readonly Shortcut[]
Returns the live registration set (combo, handler, group, description), each entry a Shortcut. Used internally by the help overlay; consumer code can read it for diagnostics.
Combo grammar
'k' # single key'cmd+k' # explicit macOS modifier'ctrl+k' # explicit Windows/Linux modifier'mod+k' # platform-natural primary modifier (cmd on macOS, ctrl elsewhere)'shift+?' # combined modifiers'g r' # two-key sequence (Vim/Gmail-style)'/' # punctuation'escape' # named keysModifier keywords (case-insensitive): cmd, command, meta, ctrl, control, shift, alt, option. The literal mod is rewritten at parse time:
| Platform | mod |
|---|---|
| macOS | meta (the ⌘ key) |
| Windows / Linux | ctrl |
Use mod whenever you mean "the platform-natural primary modifier" — saves you a navigator.platform check.
Sequences
A sequence is two whitespace-separated tokens (e.g. 'g r'). The handler fires when:
- The first token matches a key event (must NOT happen while a form element is focused);
- The second token arrives within 1500 ms;
- Modifiers in either token match the event exactly.
Sequences cannot have modifiers — they are intended as quick mnemonic chords. For modifier combos use the + form.
Built-in shortcuts
These ship with every Martis install:
| Combo | Action |
|---|---|
mod+k | Open the command palette (toggle on Topbar; open on TopnavLayout) |
/ | Open the command palette when no input is focused |
shift+? | Open the keyboard-shortcuts help overlay |
Escape is not a registered shortcut: each modal surface handles it, the top layer first (see Escape closes the top layer only). It closes a modal, a popup, a drawer, the help overlay and, since v2.7.0, the mobile menu drawer, which also keeps Tab inside it while it is open and gives focus back to the menu button when it closes.
Help overlay
<KeyboardShortcutsHelp> is mounted once at the layout root (resources/js/components/Layout.tsx). It listens for Shift+? from anywhere in the shell and surfaces every registered combo grouped by group. The overlay reads the registry on open, so shortcuts registered mid-session (by a Tool that mounted late, for instance) appear without a refresh.
The visual surface follows the design-system "Command Palette / Shortcuts" pattern: var(--martis-surface) card on a var(--martis-overlay) backdrop, a hairline var(--martis-border) rule between rows, and <kbd> chips in var(--martis-font-mono) on var(--martis-hover). Esc and a click on the backdrop both close it.
Disabling the subsystem
Two independent toggles in config/martis.php, both default to true:
'keyboard_shortcuts' => [ 'enabled' => true, // master switch — addShortcut() becomes a no-op when false 'helpOverlay' => true, // skip the bundled Shift+? overlay registration only],| Toggle | Effect when false |
|---|---|
enabled | Every addShortcut() call returns a no-op disposer — no event listener is bound, none of the bundled combos (mod+k, /, shift+?) fire, and listShortcuts() returns an empty array. Use it when the host app ships a custom keyboard layer or wants to forbid global hotkeys. |
helpOverlay | addShortcut() still works for every other combo; only the bundled Shift+? registration is skipped. Use it when the host app wants to surface its own help UI instead of the dialog above. |
The flags are read live (every registration consults the current value), so toggling them at runtime works for late-mounting Tools. The defaults match historic behaviour, so existing installs see no change.
Env vars: MARTIS_KEYBOARD_SHORTCUTS_ENABLED and MARTIS_KEYBOARD_SHORTCUTS_HELP_OVERLAY — both bool.
Behaviour around form focus
By default a shortcut is suppressed when the user is typing in an <input>, <textarea>, <select>, or contentEditable element — typing "k" in a search box should not toggle a palette.
Override with { allowInInput: true } for combos that should always fire (typically mod+k-style global commands).
Recipes
Custom Tool registers an open-shortcut
// In your Tool's React entry component.import { useEffect } from 'react'import { useNavigate } from 'react-router-dom'import { addShortcut } from '@martis/runtime' export function MyDeploymentsTool() { const navigate = useNavigate() useEffect(() => { return addShortcut('g d', () => navigate('/tools/deployments'), { description: 'Go to Deployments tool', group: 'Tools', }) }, [navigate]) return <div>...</div>}Take over a bundled shortcut
The topbar registers mod+k and / when the shell mounts, and the SPA mounts only after your extension bundle has run, so disableShortcut('mod+k') in the extension entry finds nothing to remove yet. Take the combo over instead: when several handlers share a combo, the one registered first runs and the others do not, so a handler your extension entry registers wins over the bundled one:
// resources/js/martis-extensions/index.ts (runs before the shell mounts)import { addShortcut } from '@martis/runtime' addShortcut('mod+k', () => { // your command}, { description: 'Open my launcher', group: 'Navigation', allowInInput: true })Pass allowInInput: true when the bundled handler has it (mod+k does): a handler without it is skipped while an input has focus, and the bundled one runs instead.
Form-save shortcut on a custom page
import { useEffect } from 'react'import { addShortcut } from '@martis/runtime' // Inside your page component, with `formRef` on its <form>.useEffect(() => { return addShortcut('mod+s', (e) => { e.preventDefault() formRef.current?.submit() }, { description: 'Save the current form', group: 'Editing', allowInInput: true, })}, [])Testing
The registry is written to be testable without jsdom shenanigans — call addShortcut, dispatch a KeyboardEvent against document.body, assert. This is the package's own suite:
// Package-internal: the package's Vitest suite imports the module directly.import { keyboardShortcuts, addShortcut } from '@/lib/keyboardShortcuts' beforeEach(() => keyboardShortcuts.reset()) // clears everything between tests it('fires on cmd+k', () => { const handler = vi.fn() addShortcut('cmd+k', handler) document.body.dispatchEvent( new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true }), ) expect(handler).toHaveBeenCalled()})See resources/js/keyboardShortcuts.test.ts for the full suite (16 specs covering single keys, modifiers, sequences, input-focus suppression, listing, dispose semantics, and the master-switch behaviour).