JS Input Mapping

Our goal is to create a simple yet flexible input mapping system that can handle both straightforward and complex scenarios. It should require minimal maintenance and be easy to extend.

Input is often managed through condition checking, like “if this key is pressed, do this function; otherwise, do something else.” We want to move away from this approach. Instead, we aim to use convention over code and leverage lookup tables to handle complexity. This approach simplifies maintenance, improves clarity, and enhances performance.

We should begin by defining an Enum that represents the intent and the corresponding function convention. The key represents the intent, while the value specifies the method to be called to execute that intent.

export const InputActions = Object.freeze({
    "SELECT"       : "select",
    "SELECT_LEFT"  : "selectLeft",
    "SELECT_RIGHT" : "selectRight"
})

This means that the component class needs to have the above mentioned methods.

class Component extends HTMLElement {
    ...
    
    select(event) {
      ...
    }
    
    selectLeft(event) {
      ...
    }
    
    selectRight(event) {
      ...
    }
}

Now we have the intents:

  1. SELECT
  2. SELECT_LEFT
  3. SELECT_RIGHT

We also have their corresponding methods. Now, we need to map the input to the appropriate intent. For example, pressing the left arrow key should trigger the intent “SELECT_LEFT,” and pressing the right arrow key should trigger the intent “SELECT_RIGHT.” To achieve this, we can create a lookup table where the key represents the input and the value represents the corresponding intent.

To handle this, we’ll create an InputManager class. The data for the InputManager will be our lookup table, but we also need a method that takes an event object as input and returns the name of the method to be called.

class InputManager {
    #data = {
        "Space"      : InputActions.SELECT,
        "LeftArrow"  : InputActions.SELEFT_LEFT,
        "RightArrow" : InputActions.SELECT_RIGHT
    }
      
    getInputAction(event) {
        let key = event.code || event.type;
      
        if (event.ctrlKey) {
            key = `Ctrl+${key}`;
        }
      
        if (event.shiftKey) {
            key = `Shift+${key}`;
        }
      
        return this.#data[key];
      }
  }

The getInputAction method looks up the method name in the table based on the value of the intent Enum we defined. While this is straightforward when using just the key code, complications arise when we need to account for modifier keys like Ctrl or Shift. In such cases, we want to allow the lookup table to support combinations like “Shift+Home.” To handle this, we first check the ctrlKey and shiftKey values on the event. If they are pressed, we construct a key string based on a defined convention. This approach works not only for key codes but also for mouse inputs such as “click” or “dblclick.” We detect mouse events by checking event.type—if event.code is undefined, we’re dealing with a mouse event, so we can check whether it’s a click or double-click. With this setup, the lookup table can be easily extended to support additional actions that trigger the corresponding intent.

#data = {
    "Space"      : InputActions.SELECT,
    "LeftArrow"  : InputActions.SELEFT_LEFT,
    "RightArrow" : InputActions.SELECT_RIGHT,
    "click"      : InputActions.SELECT,
    "Tab"        : InputActions.SELEFT_LEFT,
    "Shift+Tab"  : InputActions.SELECT_RIGHT
}

In the above example we wanted to support more input and all we needed to do was to add the supported input to the lookup table, it does not get easier than this.The final piece is registering the actual input events using addEventListener. The logic for this is as follows:

  1. Retrieve the method to call from the InputManager.
  2. If the method exists, invoke it to execute the corresponding intent.
class Component extends HTMLElement {
    ...
    
    #onMouseClick(event) {
        const method = this.getInputAction(event);
        this[method]?.(event);
    }
    
    #onKeyDown(event) {
        const method = this.getInputAction(event);
        this[method]?.(event);
    }

    select(event) {
      ...
    }
    
    selectLeft(event) {
      ...
    }
    
    selectRight(event) {
      ...
    }    
}

The great thing here is that no modifications are needed to the mouse or keyboard events to support new input scenarios. All that’s required is adding the scenario to the lookup table, and you’re set. Want to support a new intent? Simply add it to the Enum, implement the intent execution method in the class, and map the key in the InputManager.

Conclusion

In this article, we’ve explored a flexible and maintainable approach to handling user input in software applications. By moving away from traditional condition-based input handling, we’ve introduced a system that leverages lookup tables and convention over code. This approach offers several key advantages:

  1. Simplicity: The use of an Enum to define input actions and corresponding method names provides a clear and straightforward structure.
  2. Flexibility: Our InputManager class, with its lookup table, can easily handle a wide range of inputs, including both keyboard and mouse events, as well as modifier keys.
  3. Extensibility: Adding new input scenarios or actions is as simple as updating the lookup table, requiring no changes to the core event handling code.
  4. Maintainability: The separation of input mapping from action implementation makes the code easier to understand and maintain.
  5. Performance: By using lookup tables instead of conditional statements, we potentially improve the system’s performance, especially for complex input scenarios.

This input mapping system demonstrates how thoughtful design can significantly simplify what is often a complex aspect of software development. By reducing code complexity and improving clarity, we’ve created a foundation that can easily adapt to changing requirements and grow with your application.

By embracing this approach, you’re not just solving today’s input handling challenges, you’re setting up your project for easier maintenance and expansion in the future.