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.
Click anywhere outside the box on this page, then press / to jump to the search box,
or ? 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.
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"
Press ? for the shortcuts.
How it works
KeyboardShortcut is a controller. 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
documentseese.targetretargeted 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 ? 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, for
listeners on
windowanddocumentand their cleanup. - Events: How far an event goes, for retargeting and
composedPath.