AkurAI Build
Menu

Public repository / fee5b6c2cde89fa6bf8dc51068af7336b56a88b8

AkurAI-Framework / site/frontend/hotkeys.js

Latest change 5097caa5d055c8539c451aeda1036baafb1d495f - OLI-245: hotkeys.js helper + SPA router/IdP return_to docs by Ólafur Búi Ólafsson

// hotkeys.js — keyboard-shortcut and focus-scope helper for AkurAI/Framework.
//
// Native ES module, no dependencies. Pure `resolveHotkey` maps a keydown to an
// action name; `createHotkeys` binds one listener and routes actions.
//
// Keymap groups:
//   global:    { key: action }                 always active
//   selection: { key: action }                 active only when ctx.hasSelection
//   <other>:   { scope: "css", keys: {...} }   active only when the event target
//                                              is inside an element matching `scope`
// Plain-modifier chords (Ctrl/Meta/Alt) are never handled. Typing targets
// (input/textarea/select/contenteditable) are ignored; Escape there yields
// "closeTyping" so callers can blur. When disabled, only Escape still resolves.

export function isTypingTarget(t) {
  return !!t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable === true);
}

function lookup(map, key) {
  return map && Object.prototype.hasOwnProperty.call(map, key) ? map[key] : null;
}

export function resolveHotkey(e, keymap, { enabled = true, hasSelection = false } = {}) {
  if (e.metaKey || e.ctrlKey || e.altKey) return null;
  if (!enabled && e.key !== "Escape") return null;
  if (isTypingTarget(e.target)) return e.key === "Escape" ? "closeTyping" : null;
  for (const [name, group] of Object.entries(keymap)) {
    let keys = group;
    if (group.keys) {
      if (!e.target?.closest?.(group.scope)) continue;
      keys = group.keys;
    } else if (name === "selection" && !hasSelection) continue;
    const action = lookup(keys, e.key);
    if (action) return action;
  }
  return null;
}

// Binds one keydown listener; `enabled` is toggleable via setEnabled.
export function createHotkeys({ window: win, keymap, onAction, hasSelection = () => false, preventDefault = [], enabled = true, persist }) {
  let on = enabled;
  const prevent = new Set(preventDefault);
  const onKey = (e) => {
    const action = resolveHotkey(e, keymap, { enabled: on, hasSelection: hasSelection() });
    if (!action) return;
    if (prevent.has(action)) e.preventDefault();
    onAction(action, e);
  };
  win.addEventListener("keydown", onKey);
  return {
    enabled: () => on,
    setEnabled(value) {
      on = !!value;
      if (persist) persist(on);
    },
    destroy: () => win.removeEventListener("keydown", onKey),
  };
}