loading

Register app-wide shortcuts

Add

Add attaches a global keydown shortcut and returns a Guid you can later pass to Remove. By default the browser action is suppressed (preventDefault and stopPropagation are true) and held keys do not auto-repeat - except that a shortcut without Ctrl, Alt or Meta stands aside while its key goes to a control that acts on it, see below. Two shortcuts are live on this page right now.

Razor
@inject Bit.Butil.Keyboard keyboard

@code {
    private Guid ctrlKId;

    protected override async Task OnInitializedAsync()
    {
        ctrlKId = await keyboard.Add(ButilKeyCodes.KeyK, () =>
        {
            // open your command palette...
        }, ButilModifiers.Ctrl);

        await keyboard.Add(ButilKeyCodes.F9, () => { /* ... */ });
    }
}
Live sample
Ctrl + K and F9 are registered - press them and watch the output.
shortcuts output
Results will appear here when you interact with the samples.

Modifier combinations

ButilModifiers

ButilModifiers is a flags enum - combine Ctrl, Alt, Shift and Meta with the bitwise or operator. Build a shortcut below and register it live; press it to see it fire, and register a new one to replace it.

C#
await keyboard.Add(ButilKeyCodes.KeyU,
    () => { /* ... */ },
    ButilModifiers.Ctrl | ButilModifiers.Shift);
Live sample
Key code (KeyboardEvent.code)
Modifiers
modifiers output
Results will appear here when you interact with the samples.

Disposable subscriptions

Subscribe

Subscribe is Add with lifecycle sugar: it returns a ButilSubscription whose DisposeAsync detaches the shortcut, so a component can await-using a shortcut or dispose it in DisposeAsync without tracking Guids.

C#
private ButilSubscription? shortcut;

shortcut = await keyboard.Subscribe(ButilKeyCodes.KeyB, () =>
{
    // toggle the sidebar...
}, ButilModifiers.Ctrl);

// later:
await shortcut.DisposeAsync();
Live sample
subscription output
Results will appear here when you interact with the samples.

Element-scoped shortcuts

SubscribeOn

SubscribeOn binds the shortcut to a specific element: the handler only fires while that element (or one of its descendants) receives the keyboard event. Perfect for editor areas that need their own key handling without hijacking the whole page. The element a shortcut is scoped to never makes it stand aside for IgnoreWhenEditable, only the controls inside it - so a shortcut scoped to a text field fires while the user types there.

Razor
<textarea @ref="editorRef"></textarea>

@code {
    private ElementReference editorRef;
    private ButilSubscription? editorShortcut;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender is false) return;

        editorShortcut = await keyboard.SubscribeOn(editorRef,
            ButilKeyCodes.Enter, () => { /* submit */ }, ButilModifiers.Ctrl);
    }
}
Live sample
Ctrl+Enter fires only while this textarea has focus
element-scoped output
Results will appear here when you interact with the samples.

Stand aside while the user is typing

KeyboardShortcutOptions.IgnoreWhenEditable

preventDefault defaults to true, so a shortcut on a key a control also acts on would take that key away from every control on the page. A shortcut without Ctrl, Alt or Meta therefore stands aside, by default, while its key goes to such a control - nothing fired, nothing suppressed, the key does what it always does there. The test is per key: Delete in a text field deletes text, but Delete on a select, which has no use for it, still fires the shortcut; ArrowRight moves the slider and the caret, but fires on the checkbox. IgnoreWhenEditable sets it explicitly: true stands aside whatever the modifiers, false never does. Register both shortcuts below, then press Delete and ArrowRight on each control - once with the default, once with false.

C#
// Deletes the selected rows - but never the text being typed in a search box.
// Delete has no Ctrl, Alt or Meta, so it stands aside by default.
await keyboard.Add(ButilKeyCodes.Delete, () => { /* delete the selection */ });

// A modified shortcut stays live in text fields unless told otherwise -
// here the field's own undo wins while the user is typing.
await keyboard.Add(ButilKeyCodes.KeyZ, () => { /* undo the last row edit */ },
    new KeyboardShortcutOptions { Modifiers = ButilModifiers.Ctrl, IgnoreWhenEditable = true });

// Take Delete from every control on the page instead.
await keyboard.Add(ButilKeyCodes.Delete, () => { /* ... */ },
    new KeyboardShortcutOptions { IgnoreWhenEditable = false });
Live sample
editable output
Results will appear here when you interact with the samples.

Removing shortcuts

Remove / RemoveAll

Remove(Guid) detaches one shortcut by the id Add returned. Remove(Action) matches by delegate identity and returns the removed ids. RemoveAll wipes every shortcut registered through this Keyboard instance.

C#
var id = await keyboard.Add(ButilKeyCodes.F9, handler);

await keyboard.Remove(id);        // by id (recommended)
await keyboard.Remove(handler);   // by the exact same delegate instance
await keyboard.RemoveAll();       // everything on this Keyboard instance
Live sample
removing output
Results will appear here when you interact with the samples.

Key codes reference

ButilKeyCodes

ButilKeyCodes provides string constants for every physical key: letters (KeyA-KeyZ), digits (Digit0-Digit9), the numpad, function keys (F1-F12), navigation, editing and symbol keys. They are plain KeyboardEvent.code values, so any valid code string works too.

C#
ButilKeyCodes.KeyK        // "KeyK"
ButilKeyCodes.Digit1      // "Digit1"
ButilKeyCodes.F9          // "F9"
ButilKeyCodes.Enter       // "Enter"
ButilKeyCodes.Escape      // "Escape"
ButilKeyCodes.ArrowDown   // "ArrowDown"
ButilKeyCodes.NumpadAdd   // "NumpadAdd"
ButilKeyCodes.Backquote   // "Backquote"
Warning:
RemoveAll clears more than your component The Keyboard service is scoped to the whole app instance, so RemoveAll detaches every shortcut any component registered on it. In a component, prefer removing your own ids - or use Subscribe and dispose the returned handles - as this page does in DisposeAsync.
Note:
Shortcuts stand aside for the keys a control acts onpreventDefault and stopPropagation default to true, which is right for a shortcut that replaces what the browser would do (Ctrl+S, F5). On a key a control also acts on, the same defaults would take that key away from every control on the page - so a shortcut without Ctrl, Alt or Meta stands aside, by default, while its key goes to one: Delete in a text field, an arrow on a slider, Space on a checkbox. Set KeyboardShortcutOptions.IgnoreWhenEditable to true to stand a modified shortcut aside as well (Ctrl+Z over a field's own undo), or to false to take the key from the controls too. A handler subscribed through Document, Window or an element reads the same test from ButilKeyboardEventArgs.TargetIsEditable, next to TargetTagName.
Note:
Physical keys, not characters Matching uses KeyboardEvent.code - the physical key - so ButilKeyCodes.KeyK fires for that key position regardless of the active keyboard layout, and shortcuts keep working while Caps Lock or Shift change the produced character.

API reference

Member
Signature
Description
Add
Task<Guid> Add(string code, Action handler, ButilModifiers modifiers = ButilModifiers.None, bool preventDefault = true, bool stopPropagation = true, bool repeat = false)
Registers a global keydown shortcut and returns its id. Set repeat to true to also fire on key auto-repeat.
Add
Task<Guid> Add(string code, Action handler, KeyboardShortcutOptions options)
Registers a global keydown shortcut with the full option set - IgnoreWhenEditable included - and returns its id.
Subscribe
Task<ButilSubscription> Subscribe(string code, Action handler, ButilModifiers modifiers = ButilModifiers.None, bool preventDefault = true, bool stopPropagation = true, bool repeat = false)
Same as Add but returns a disposable handle that detaches the shortcut.
Subscribe
Task<ButilSubscription> Subscribe(string code, Action handler, KeyboardShortcutOptions options)
Same as Add with options, returning a disposable handle.
SubscribeOn
Task<ButilSubscription> SubscribeOn(ElementReference element, string code, Action handler, ButilModifiers modifiers = ButilModifiers.None, bool preventDefault = true, bool stopPropagation = true, bool repeat = false)
Element-scoped variant: fires only while the element or a descendant receives the keyboard event.
SubscribeOn
Task<ButilSubscription> SubscribeOn(ElementReference element, string code, Action handler, KeyboardShortcutOptions options)
Element-scoped variant with options. The element itself never makes the shortcut stand aside, only the controls inside it.
Remove
ValueTask<Guid[]> Remove(Action handler)
Removes shortcuts matching the exact same delegate instance; returns the removed ids.
Remove
ValueTask Remove(Guid id)
Removes one shortcut by the id returned from Add.
RemoveAll
ValueTask RemoveAll()
Removes every shortcut registered on this Keyboard instance.
DisposeAsync
ValueTask DisposeAsync()
Removes all shortcuts and releases the interop reference. Called by DI when the app scope tears down.
InvokeKeyboard
void InvokeKeyboard(Guid id)
JSInvokable interop entry point invoked from the browser when a shortcut fires - not intended for direct application use.
ButilModifiers
enum ButilModifiers { None, Alt, Ctrl, Meta, Shift }
Flags enum of modifier keys; combine values with the bitwise or operator.
KeyboardShortcutOptions
class KeyboardShortcutOptions { ButilModifiers Modifiers; bool PreventDefault = true; bool StopPropagation = true; bool Repeat; bool? IgnoreWhenEditable }
The full option set of a shortcut. IgnoreWhenEditable stands the shortcut aside while its key goes to a control that acts on it; null (the default) does so only for a shortcut without Ctrl, Alt or Meta, true always, false never.
An unhandled error has occurred. Reload 🗙