rand[om]

rand[om]

med ∩ ml

Handling keyboard shortcuts in JavaScript

I was recently reading the source code of mizu.js1 and I liked the utility “function”2 they have to handle keyboard shortcuts/combinations. I decided to slightly modify it to have it as part of my JavaScript utility functions.

The function

This function returns another function itself. You pass in a string that represents a combination of key presses like Shift+Alt+C, and it returns a function that will return true if a KeyboardEvent matches that combination. I had to make some modifications to the original code. First, I converted it to JavaScript, because I don’t like having a build step, and I want to be able to copy-paste this code. I also changed the matcher on the event.key to match on event.code because I found event.key was returning a string generated by the keyboard combination, not the actual key pressed.

According to MDN, KeyboardEvent.code ignores the user keyboard layout.

/**
 * Creates a function to check if a keyboard event matches specified key combinations.
 *
 * @param {string} keys - A comma-separated string of key combinations (e.g., "Ctrl+B, Shift+Alt+C").
 * @returns {function} - A function that takes a keyboard event and returns true if it matches any of the specified combinations.
 */
function keyboard(keys) {
  const combinations = keys
    .split(",")
    .map((combination) =>
      combination.split("+").map((key) => key.trim().toLowerCase())
    );
  return function (event) {
    if (!/^key(?:down|press|up)$/.test(event.type)) {
      return false;
    }
    return combinations.some((combination) => {
      for (const key of combination) {
        switch (key) {
          case "alt":
            if (!event.altKey) {
              return false;
            }
            break;
          case "ctrl":
            if (!event.ctrlKey) {
              return false;
            }
            break;
          case "shift":
            if (!event.shiftKey) {
              return false;
            }
            break;
          case "meta":
            if (!event.metaKey) {
              return false;
            }
            break;
          case "space":
            if (event.key !== " ") {
              return false;
            }
            break;
          case "key":
            if (/^(?:alt|ctrl|shift|meta)$/i.test(event.key)) {
              return false;
            }
            break;
          default:
            // Match `code` for physical key instead of `key`
            if (event.code.toLowerCase() !== `key${key}`) {
              return false;
            }
        }
      }
      return true;
    });
  };
}

Using it

You need to created matchers, and actions associated with them. In the examples below, I’m also checking the current active element with document.activeElement. This is because I can imagine situations where I’ll want to apply some shortcuts only if certain elements are focused, or the event handler needs to retrieve content from the focused element.

// Define actions
const actions = [
  {
    matcher: keyboard("a"),
    action: () => {
      console.log("You pressed 'A'!");
    },
  },
  {
    matcher: keyboard("ctrl+b"),
    action: () => {
      console.log("You pressed 'Ctrl+B'!");
    },
  },
  {
    matcher: keyboard("shift+alt+c"),
    action: () => {
      console.log("You pressed 'Shift+Alt+C'!");
    },
  },
];

// Add event listener to the window to catch all keydown events
window.addEventListener("keydown", (event) => {
  const focusedElement = document.activeElement;
  for (const { matcher, action } of actions) {
    if (matcher(event)) {
      action();
      console.log(`Focused element: ${focusedElement.tagName || "NONE"}`);
      event.preventDefault();
      break;
    } else {
      console.log("not matched");
    }
  }
});

Full example

Here’s a self-contained HTML file (partially generated with GPT-4o / Claude Sonnet 3.5) that showcases the functionality:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Keyboard Shortcuts</title>
    <meta charset="UTF-8" />
    <style>
      body {
        font-family: Arial, sans-serif;
        margin: 20px;
      }
      input,
      textarea,
      button {
        display: block;
        margin: 10px 0;
        padding: 10px;
        font-size: 16px;
      }
      .log {
        margin-top: 20px;
        padding: 10px;
        border: 1px solid #ccc;
        background: #f9f9f9;
        max-height: 150px;
        overflow-y: auto;
      }
    </style>
  </head>
  <body>
    <h1>Keyboard Shortcuts Example</h1>
    <p>Try pressing the following combinations:</p>
    <ul>
      <li><strong>A</strong>: Logs a message</li>
      <li><strong>Ctrl + B</strong>: Logs a message</li>
      <li><strong>Shift + Alt + C</strong>: Logs a message</li>
    </ul>
    <p>Type in the input field or textarea to see focused element behavior.</p>
    <input type="text" placeholder="Type something..." />
    <textarea placeholder="Type here..."></textarea>
    <button>Click me!</button>

    <div class="log" id="log"></div>

    <script>
      /**
       * Creates a function to check if a keyboard event matches specified key combinations.
       *
       * @param {string} keys - A comma-separated string of key combinations (e.g., "Ctrl+B, Shift+Alt+C").
       * @returns {function} - A function that takes a keyboard event and returns true if it matches any of the specified combinations.
       */
      function keyboard(keys) {
        const combinations = keys
          .split(",")
          .map((combination) =>
            combination.split("+").map((key) => key.trim().toLowerCase())
          );
        return function (event) {
          if (!/^key(?:down|press|up)$/.test(event.type)) {
            return false;
          }
          return combinations.some((combination) => {
            for (const key of combination) {
              switch (key) {
                case "alt":
                  if (!event.altKey) {
                    return false;
                  }
                  break;
                case "ctrl":
                  if (!event.ctrlKey) {
                    return false;
                  }
                  break;
                case "shift":
                  if (!event.shiftKey) {
                    return false;
                  }
                  break;
                case "meta":
                  if (!event.metaKey) {
                    return false;
                  }
                  break;
                case "space":
                  if (event.key !== " ") {
                    return false;
                  }
                  break;
                case "key":
                  if (/^(?:alt|ctrl|shift|meta)$/i.test(event.key)) {
                    return false;
                  }
                  break;
                default:
                  // Match `code` for physical key instead of `key`
                  if (event.code.toLowerCase() !== `key${key}`) {
                    return false;
                  }
              }
            }
            return true;
          });
        };
      }

      // Define actions
      const actions = [
        {
          matcher: keyboard("a"),
          action: () => {
            log("You pressed 'A'!");
          },
        },
        {
          matcher: keyboard("ctrl+b"),
          action: () => {
            log("You pressed 'Ctrl+B'!");
          },
        },
        {
          matcher: keyboard("shift+alt+c"),
          action: () => {
            log("You pressed 'Shift+Alt+C'!");
            alert("You pressed 'Shift+Alt+C'!");
          },
        },
      ];

      // Log messages to the UI
      function log(message) {
        const logElement = document.getElementById("log");
        const entry = document.createElement("div");
        entry.textContent = message;
        logElement.appendChild(entry);
        logElement.scrollTop = logElement.scrollHeight;
      }

      // Add event listener
      window.addEventListener("keydown", (event) => {
        const focusedElement = document.activeElement;
        for (const { matcher, action } of actions) {
          if (matcher(event)) {
            action();
            log(`Focused element: ${focusedElement.tagName || "NONE"}`);
            event.preventDefault();
            break;
          } else {
            console.log("not matched");
          }
        }
      });
    </script>
  </body>
</html>