keyboard.mjs

/** @module keyboard */

/** The four modifiers a `KeyboardEvent` tracks, and the whole set exclusivity is checked over. */
const MODIFIERS = ['Meta', 'Control', 'Alt', 'Shift']

/** `event.key` values for the modifiers themselves, which arrive as keydowns of their own. */
const MODIFIER_KEYS = new Set(MODIFIERS)

/**
 * Spellings a shortcut may use for each modifier, `mod` excepted - that one is resolved per
 * platform and so cannot be a constant.
 *
 * `cmd` and `ctrl` stay literal, which is the whole reason `mod` exists. Making them mean
 * "whichever this machine calls primary" reads nicer for the nine bindings out of ten that
 * want it, and takes away the only way to say the tenth: macOS keeps the Emacs bindings
 * (`Ctrl+A`, `Ctrl+E`, `Ctrl+K`) live inside every text field, so an editor binding literal
 * Control on a Mac is an ordinary thing to want and would become unsayable.
 */
const ALIASES = {
  meta: 'Meta', cmd: 'Meta', command: 'Meta', super: 'Meta', win: 'Meta',
  ctrl: 'Control', control: 'Control',
  alt: 'Alt', option: 'Alt', opt: 'Alt',
  shift: 'Shift'
}

/** The konami code as ten shortcut specs, the arcade original minus Start and Select. */
const KONAMI = [
  'ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown',
  'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight',
  'b', 'a'
]

/**
 * Selector for the places a person types, for the `when` and `except` options.
 *
 * `[contenteditable]` is matched with `closest`, so the `<b>` inside an editable `<div>`
 * counts as being in one. `<select>` is in the list because a focused one runs its own
 * type-ahead on plain letters.
 *
 * @type {string}
 * @example
 * bindShortcut('mod+k', focusSearch, { except: EDITABLE }) // not while typing
 * bindShortcut('mod+b', bold, { when: '#editor' })         // only while typing, over there
 */
export const EDITABLE = 'input, textarea, select, [contenteditable]'

/**
 * Whether this machine is one where `mod` means the Command key.
 *
 * Read per call rather than at import: several helpers here run in a page before anything is
 * known about it, and a module that touches `navigator` on the way in cannot be imported
 * under Node at all. `userAgentData` first because `navigator.platform` is deprecated;
 * neither is guessed at from the user-agent string, which reports `AppleWebKit` on machines
 * that are not Apple's.
 */
function isApplePlatform() {
  if (typeof navigator !== 'object' || !navigator) return false
  const platform = (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || ''
  return /mac|iphone|ipad|ipod/i.test(platform)
}

/**
 * A shortcut spec split into the modifiers it demands and the key it ends on.
 *
 * The key is the last `+`-separated part, which leaves one ambiguity worth spelling out:
 * `mod++` is Command and the plus key, `mod+` is a spec that names no key at all. They are
 * told apart by how many empty parts the split leaves behind - two for the first, one for
 * the second - and the second throws. A lookbehind would say this in one regex and is not
 * used: an unsupported regex literal is a `SyntaxError` at parse time, which takes down the
 * whole module rather than the one call, and this library ships to whatever browser a
 * consumer still supports.
 *
 * An unknown modifier throws rather than resolving to nothing. Silently, `mdo+k` is a
 * shortcut that can never fire and gives no reason.
 */
function parseShortcut(spec) {
  if (typeof spec !== 'string' || !spec.trim()) {
    throw new TypeError(`Shortcut must be a non-empty string, got ${JSON.stringify(spec)}`)
  }
  const parts = spec.trim().split('+')
  let key = parts.pop()
  if (key === '') {
    if (parts.length && parts[parts.length - 1] === '') {
      parts.pop()
      key = '+'
    } else {
      throw new TypeError(`Shortcut "${spec}" names no key`)
    }
  }

  const required = new Set()
  for (const part of parts) {
    const name = part.trim().toLowerCase()
    const modifier = name === 'mod' ? (isApplePlatform() ? 'Meta' : 'Control') : ALIASES[name]
    if (!modifier) throw new TypeError(`Shortcut "${spec}" names an unknown modifier: "${part}"`)
    required.add(modifier)
  }
  return { required, key }
}

/** The element a keydown came from, reaching inside a shadow root, or null if it has none. */
function eventOrigin(event) {
  const path = typeof event.composedPath === 'function' ? event.composedPath() : null
  const node = (path && path[0]) || event.target
  return node && typeof node.closest === 'function' ? node : null
}

/** Whether a keydown is allowed to act, given a binding's `when`/`except` selectors. */
function inScope(event, when, except) {
  if (!when && !except) return true
  const origin = eventOrigin(event)
  if (when && !(origin && origin.closest(when))) return false
  if (except && origin && origin.closest(except)) return false
  return true
}

/**
 * Whether a keydown is the shortcut a spec describes.
 *
 * The spec is modifiers and a key joined by `+` - `mod+k`, `shift+alt+ArrowUp`, `Escape`.
 * `mod` is Command on Apple platforms and Control everywhere else; `cmd`, `ctrl`, `alt` and
 * `shift` mean themselves. Case does not matter to the key, so `mod+K` and `mod+k` are the
 * same shortcut and neither demands Shift - to demand it, say `mod+shift+k`.
 *
 * **Modifiers not named must be up.** `mod+k` does not match `Ctrl+Shift+K`, which is a
 * devtools shortcut in two browsers, and the same rule is what keeps `alt+k` off `k`. This
 * is the part hand-written checks get wrong, because it takes a test per modifier rather
 * than per modifier you thought of.
 *
 * The key is matched against `event.key` without case *or* against `event.code` exactly, so
 * a layout where a modifier rewrites the character still has a way to be named: `alt+k` on
 * macOS arrives as `˚`, and `alt+KeyK` matches it.
 *
 * Says nothing about where the keydown came from - a shortcut that must not fire while
 * someone is typing pairs this with {@link EDITABLE}, or uses {@link bindShortcut}, which
 * has the option. A lone modifier (`shift` with no key) is not a shortcut this can express.
 *
 * @param {KeyboardEvent} event
 * @param {string} spec Modifiers and a key, joined by `+`
 * @returns {boolean}
 * @throws {TypeError} If the spec names no key, or a modifier that is not a modifier
 * @example
 * // on Windows, with Ctrl+K pressed
 * matchesShortcut(event, 'mod+k') // => true
 * // with Ctrl+Shift+K pressed
 * matchesShortcut(event, 'mod+k') // => false, Shift was not asked for
 */
export function matchesShortcut(event, spec) {
  const { required, key } = parseShortcut(spec)
  if (!event || typeof event.getModifierState !== 'function') return false
  for (const modifier of MODIFIERS) {
    if (event.getModifierState(modifier) !== required.has(modifier)) return false
  }
  const pressed = typeof event.key === 'string' ? event.key : ''
  return pressed.toLowerCase() === key.toLowerCase() || event.code === key
}

/**
 * Runs a handler when a shortcut is pressed, and returns the function that stops it.
 *
 * Where it may fire is the point of `when` and `except`, and the reason they are selectors
 * rather than a boolean: the same page can want one shortcut everywhere, one only inside an
 * editor, and one everywhere *but* an editor, and all three are then a setting rather than
 * three code paths.
 *
 * Ignores a keydown that is a key repeating unless `allowRepeat` says otherwise, and always
 * ignores one arriving mid-composition - an IME sends keydowns for the keys building a
 * character, and they are not the person pressing a shortcut.
 *
 * The spec is parsed on the way in, so a typo throws where it was written rather than
 * silently never firing.
 *
 * @param {string} spec Modifiers and a key, joined by `+` - see {@link matchesShortcut}
 * @param {Function} handler Called with the event
 * @param {object} [options]
 * @param {EventTarget} [options.target=document] What to listen on
 * @param {string} [options.when] Fire only when the keydown comes from inside a match
 * @param {string} [options.except] Never fire when the keydown comes from inside a match
 * @param {boolean} [options.preventDefault=true] Whether a match takes the key off the browser
 * @param {boolean} [options.allowRepeat=false] Whether a held key fires more than once
 * @returns {Function} Unbind
 * @throws {TypeError} If the spec is malformed
 * @example
 * const unbind = bindShortcut('mod+k', () => input.focus())
 * bindShortcut('mod+b', bold, { when: '#editor' })
 * bindShortcut('/', search, { except: EDITABLE })
 * unbind()
 */
export function bindShortcut(spec, handler, options = {}) {
  const {
    target = typeof document !== 'undefined' ? document : null,
    when = null,
    except = null,
    preventDefault = true,
    allowRepeat = false
  } = options
  parseShortcut(spec)
  if (!target || typeof target.addEventListener !== 'function') return () => {}

  const listener = (event) => {
    if (event.isComposing || (event.repeat && !allowRepeat)) return
    if (!matchesShortcut(event, spec)) return
    if (!inScope(event, when, except)) return
    if (preventDefault) event.preventDefault()
    handler(event)
  }
  target.addEventListener('keydown', listener)
  return () => target.removeEventListener('keydown', listener)
}

/**
 * Runs a handler when a list of shortcuts is pressed in order, and returns the function that
 * stops it. A cheat code, or the `g` `i` pair a keyboard-first app uses to go somewhere.
 *
 * Forgets itself `timeout` milliseconds after each step, so a half-typed sequence does not
 * wait around to be completed by an unrelated keypress an hour later. A wrong key restarts
 * it - and is then tried against the first step, so `↑ ↑ ↑ ↓` still gets to step three
 * rather than throwing away the `↑` that was also a fresh start.
 *
 * Keydowns for the modifiers themselves pass through without disturbing the count: `mod+k`
 * as a step arrives as a keydown for Meta and then one for `k`, and a sequence that reset on
 * the first of those could never contain a shortcut with a modifier in it.
 *
 * `preventDefault` is **off** here, unlike {@link bindShortcut}. A step matches long before
 * the sequence does, and the konami code opens on an arrow key - preventing that default
 * would stop the page scrolling for everyone who never finishes the sequence, which is
 * everyone.
 *
 * @param {string[]} specs The shortcuts, in order - see {@link matchesShortcut}
 * @param {Function} handler Called with the event that completed the sequence
 * @param {object} [options]
 * @param {EventTarget} [options.target=document] What to listen on
 * @param {number} [options.timeout=1000] Milliseconds a partial sequence survives
 * @param {string} [options.when] Fire only when the keydown comes from inside a match
 * @param {string} [options.except] Never fire when the keydown comes from inside a match
 * @param {boolean} [options.preventDefault=false] Whether a matched step is taken off the browser
 * @returns {Function} Unbind
 * @throws {TypeError} If the list is empty, or a spec is malformed
 * @example
 * bindSequence(['g', 'i'], () => go('/inbox'))
 * bindSequence(['ArrowUp', 'ArrowUp', 'b', 'a'], cheat, { timeout: 2000 })
 */
export function bindSequence(specs, handler, options = {}) {
  if (!Array.isArray(specs) || !specs.length) {
    throw new TypeError('A sequence needs at least one shortcut')
  }
  const {
    target = typeof document !== 'undefined' ? document : null,
    timeout = 1000,
    when = null,
    except = null,
    preventDefault = false
  } = options
  specs.forEach((spec) => parseShortcut(spec))
  if (!target || typeof target.addEventListener !== 'function') return () => {}

  let at = 0
  let timer = null
  const reset = () => {
    at = 0
    if (timer) timer = clearTimeout(timer)
  }

  const listener = (event) => {
    if (event.isComposing || event.repeat || MODIFIER_KEYS.has(event.key)) return
    if (!inScope(event, when, except)) return

    if (matchesShortcut(event, specs[at])) {
      at += 1
    } else {
      // The key that broke the run may be the one that starts a new one, and only from a
      // run that had started - otherwise this re-tests the first step against itself.
      const restarts = at > 0 && matchesShortcut(event, specs[0])
      reset()
      if (!restarts) return
      at = 1
    }

    if (preventDefault) event.preventDefault()
    if (at < specs.length) {
      if (timer) clearTimeout(timer)
      timer = setTimeout(reset, timeout)
      return
    }
    reset()
    handler(event)
  }
  target.addEventListener('keydown', listener)
  return () => {
    if (timer) timer = clearTimeout(timer)
    target.removeEventListener('keydown', listener)
  }
}

/**
 * Runs a handler on the konami code - ↑ ↑ ↓ ↓ ← → ← → B A.
 *
 * The arcade original ends on Start, which no keyboard has; the web has settled on the ten
 * keys before it. Takes no sequence of its own: a different sequence is
 * {@link bindSequence}, and an option here would only be that function wearing this name.
 *
 * @param {Function} handler Called with the event that completed the sequence
 * @param {object} [options] As {@link bindSequence}
 * @returns {Function} Unbind
 * @example
 * konamiCode(() => document.body.classList.add('rainbow'))
 */
export function konamiCode(handler, options = {}) {
  return bindSequence(KONAMI, handler, options)
}

/** Whether the last input the page saw was a key rather than a pointer. */
let keyboardIntent = false

/** Documents already watched, so repeat calls from many components cost nothing. */
const intentWatched = new WeakSet()

/**
 * Starts tracking whether the person is driving the page with a keyboard or with a
 * pointer, so `isKeyboardIntent` can be asked later.
 *
 * This is the part `:focus-visible` cannot give you. That pseudo-class matches a text
 * input or a `contenteditable` even when it was clicked into, because a browser assumes
 * anything taking text input wants its focus ring — right for a ring, wrong for deciding
 * whether to show a keyboard hint or move focus somewhere a mouse user did not ask for.
 *
 * Listeners go on in capture, because a `pointerdown` handler somewhere in the page that
 * calls `stopPropagation` (drag implementations do, routinely) would otherwise hide the
 * switch to pointer. `pointerdown` rather than `mousedown` so a pen and a touch count
 * without waiting for emulated mouse events.
 *
 * Call it once, early — before any focus you intend to judge, since the keypress that
 * moves focus lands on whatever had focus *before* the element you are asking about. It
 * is safe to call from every component that needs it: the document is only watched once.
 * There is no unwatch; two capture listeners for the life of the page is the whole cost.
 *
 * @see {@link isKeyboardIntent}
 * @param {Document} [doc=document] The document to watch — pass an iframe's own document to watch inside it
 * @returns {void}
 * @example
 * watchInputIntent()
 *
 * element.addEventListener('focusin', () => {
 *   element.classList.toggle('is-key-focus', isKeyboardIntent())
 * })
 */
export function watchInputIntent(doc = document) {
  if (!doc || intentWatched.has(doc)) return
  intentWatched.add(doc)
  doc.addEventListener('keydown', () => { keyboardIntent = true }, true)
  doc.addEventListener('pointerdown', () => { keyboardIntent = false }, true)
}

/**
 * Whether the last input the page saw was a key rather than a pointer.
 *
 * Pointer until proven otherwise: before anyone has touched anything this is `false`, so
 * focus that arrives on load — an `autofocus`, a restored scroll position — is not
 * mistaken for someone tabbing. Requires {@link watchInputIntent} to have been called;
 * without it this is always `false`.
 *
 * @see {@link watchInputIntent}
 * @returns {boolean} `true` if the last input was a keypress
 * @example
 * watchInputIntent()
 * // after the person presses Tab
 * isKeyboardIntent() // => true
 * // after the person clicks
 * isKeyboardIntent() // => false
 */
export function isKeyboardIntent() {
  return keyboardIntent
}