Conflicting global keyboard event listener, how to resolve it in an elegant way?

There’s a keyboard shortcut for a global search bar, which will open up a command palette in the future.
+k

Then there’s a page-specific input field, which gains focus by pressing /.

Here’s the hook for the global search field.

const CommandPallete = {
  mounted() {
    document.addEventListener("keydown", (e) => {
      if (e.key === "k" && e.metaKey) {
        this.el.focus();
      }
    });
  },
};

export default CommandPallete;

And here’s the JS dispatch for local input field:

phx-window-keyup={JS.dispatch("phx:focus", to: "#url")}
phx-key="/"
window.addEventListener("phx:focus", (event) => {
  event.target.focus();
});

Here’s the problem:

Command pallet Keyboard Shortcut

  1. Upon arrival, the focus goes to the URL input field.
  2. On click +k, focus goes to global search.
  3. While typing in the global search, if someone enters / by chance, the focus goes to the local input field instead of staying global.

I tried preventDefault, stopEventPropagation, and currently thinking of the best way forward.

Perhaps a common and global Hook, that can handle any shortcut combo, and prevents focus from darting around if there’s already an element in focus. (I will try this approach next)

Or something else, that I am unable to think of.


P.S. Some events are okay though, for instance, dismissing the flash message with an Esc key, or closing the command palette with Esc key, while still focused on input field.

phx-window-keyup={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
phx-key="Escape"
2 Likes

Hmm, you could use phx-focus and phx-blur to set an attribute flag on the local input field to filter out "/" key events fired when the command pallette is focused.

<input phx-hook="CommandPallete" phx-focus={JS.set_attribute({"data-ignoreKey", "true"}, to: "#url")} phx-blur={JS.remove_attribute("data-ignoreKey", to: "#url")} />
<inpux id="url" phx-window-keyup={JS.dispatch("phx:focus", to: "#url")}
phx-key="/">
window.addEventListener("phx:focus", (event) => {
  unless event.target.hasAttribute("data-ignoreKey") { event.target.focus() }
});
2 Likes

You need to ditch the phx-window binding, which is rather limited, and use a hook. The hook can register its own keup listener for “/”, and only do it’s focus if the currently focused element (document.activeElement probably) is not itself a focusable input.

4 Likes

Thank you guys.

I will try both and report which one is simpler.

The simplest one is to go with 2 Hooks:

One global, one local, one keydown, one keyup!

keyboard_shortcuts.js

export const CommandPalette = {
  mounted() {
    document.addEventListener("keydown", (e) => {
      if (e.key === "k" && e.metaKey) {
        this.el.focus();
      }
    });
  },
};

export const PrimaryInput = {
  mounted() {
    document.addEventListener("keyup", (e) => {
      if (e.key === "/" && document.activeElement.id !== "search-box") {
        this.el.focus();
      }
    });
  },
};

app.js

import { CommandPalette, PrimaryInput } from "./keyboard_shortcuts";

let liveSocket = new LiveSocket("/live", Socket, {
  params: { _csrf_token: csrfToken },
  metadata: {
    keydown: (e, el) => {
      return {
        key: e.key,
        metaKey: e.metaKey,
      };
    },
  },
  hooks: {
    CommandPalette,
    PrimaryInput,
  },
});

Usage

  1. Command palette or the global search field:
    header_live.html.heex
<input
    id="search-box"
    placeholder="Search here..."
    class="..."
    type="text"
    phx-hook="CommandPalette"
/>
  1. Primary Input for a page, which needs to be focused.
    some_page_live.html.heex
<input
    id="url"
    field={@form[:url]}
    phx-debounce="1000"
    autofocus
    label="URL:"
    class="relative"
    phx-hook="PrimaryInput"
/>
1 Like