---
title: "Keyboard shortcuts"
description: "Page-wide keyboard shortcuts in Firelight: a reactive controller that listens on the document, ignores keys typed into fields, including fields inside shadow roots, and removes its listener when the component leaves."
---

# Keyboard shortcuts

Build a keyboard shortcut controller: one line in a component adds a key that works anywhere on the page, except while the user is typing.

- [MDN: aria-keyshortcuts](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-keyshortcuts)

Click anywhere outside the box on this page, then press <kbd>/</kbd> to jump to the search box,
or <kbd>?</kbd> to show the shortcuts. Type `?` into the search box: it's typed, not taken as a
shortcut. Untick Single-key shortcuts to turn them off.

```fsharp
module Snippets.Shortcuts

open Fable.Core
open Browser
open Browser.Types
open Firelight
open type Firelight.Lit

/// Calls `run` when `key` is pressed anywhere on the page, unless the user is typing in a field.
type KeyboardShortcut(host: ReactiveControllerHost, key: string, run: unit -> unit) as this =
    inherit ReactiveControllerBase()

    let mutable stopListening = ignore

    do host.addController this

    /// Single-key shortcuts need an off switch (WCAG 2.1.4): bind this to a setting.
    member val Enabled = true with get, set

    override _.hostConnected() =
        let onKey (e: KeyboardEvent) =
            // Where the key was pressed. Inside a shadow root, e.target is only the outermost host.
            let origin = e.composedPath().[0] :?> HTMLElement

            let typing =
                origin.isContentEditable
                || List.contains origin.tagName [ "INPUT"; "TEXTAREA"; "SELECT" ]

            if
                this.Enabled
                && e.key = key
                && not e.repeat
                && not typing
                && not (e.ctrlKey || e.metaKey || e.altKey)
            then
                e.preventDefault ()
                run ()

        stopListening <- Ev.listen document "keydown" onKey

    override _.hostDisconnected() = stopListening ()

[<AttachMembers>]
type ShortcutDemo() as this =
    inherit LitElement()

    let search = createRef<HTMLInputElement> ()

    let shortcuts =
        [
            KeyboardShortcut(this, "/", (fun () -> search.value |> Option.iter (fun s -> s.focus ())))
            KeyboardShortcut(this, "?", (fun () -> this.showHelp <- not this.showHelp))
        ]

    static member properties =
        PropertyDeclarations.create [
            "showHelp", PropertyDeclaration<bool>(state = true)
            "enabled", PropertyDeclaration<bool>(state = true)
        ]

    static member styles =
        css
            $$"""
        :host { display: grid; gap: 0.5rem; justify-items: start; }
        input[type="search"] { font: inherit; padding: 0.3rem 0.5rem; width: 16rem; max-width: 100%; }
        kbd { font: inherit; padding: 0 0.3rem; border: 1px solid var(--border); border-radius: 0.25rem; }
        dl { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 0.75rem; margin: 0; }
        dd { margin: 0; }
        """

    member val showHelp = false with get, set
    member val enabled = true with get, set

    member this.SetEnabled(on: bool) =
        this.enabled <- on

        for shortcut in shortcuts do
            shortcut.Enabled <- on

    override this.render() =
        let help =
            if this.showHelp then
                html
                    $"""
                <dl aria-label="Keyboard shortcuts">
                    <dt><kbd>/</kbd></dt><dd>Search</dd>
                    <dt><kbd>?</kbd></dt><dd>Show or hide these shortcuts</dd>
                </dl>"""
            else
                nothing

        html
            $"""
        <input type="search" aria-label="Search" aria-keyshortcuts={ifDefined (if this.enabled then Some "/" else None)}
            placeholder={if this.enabled then "Press / to search" else "Search"} {ref search}>
        <p>Press <kbd>?</kbd> for the shortcuts.</p>
        {help}
        <label>
            <input type="checkbox" .checked={this.enabled} @change={Ev.checked' this.SetEnabled}>
            Single-key shortcuts
        </label>"""

defineElement<ShortcutDemo> "my-shortcut-demo"
```

```html
<my-shortcut-demo></my-shortcut-demo>
```

## How it works

`KeyboardShortcut` is a [controller](https://roboz0r.github.io/Firelight/guides/controllers/). A component creates one per key in its
constructor, with the function to run, and the controller does the rest: it adds a `keydown`
listener to `document` when the component joins the page, and removes it when the component leaves.
`Ev.listen` adds the listener and returns the function that removes it, which
`hostDisconnected` calls.

- **Skip typing.** A shortcut mustn't fire while the user types into a field. The field may be
  inside a shadow root, and a listener on `document` sees `e.target` retargeted to the outermost
  host, such as `<my-shortcut-demo>`, not the `<input>`. `e.composedPath ()` starts with the
  element where the key was actually pressed, so the controller checks that one.
- **Plain keys only.** It ignores keys pressed with Ctrl, ⌘ or Alt, which belong to the browser
  and the operating system, and the repeats a held key sends, so holding <kbd>?</kbd> doesn't
  flicker the help.
- **Say so.** `aria-keyshortcuts="/"` tells screen readers the search box has a shortcut, and the
  page shows the keys too. Shortcuts nobody can discover don't help anyone.

Single-character shortcuts get in the way of speech input and of keyboard users who press keys by
accident, so WCAG asks for a way to turn them off or remap them, unless they only work while their
component has the focus. The controller's `Enabled` is that switch, and the demo's checkbox sets
it on both shortcuts. In an app, keep the setting with the user's other preferences.

## Related

- [Events: Listen outside the component](https://roboz0r.github.io/Firelight/guides/events/#listen-outside-the-component), for
  listeners on `window` and `document` and their cleanup.
- [Events: How far an event goes](https://roboz0r.github.io/Firelight/guides/events/#how-far-an-event-goes), for retargeting and
  `composedPath`.
