Introducing Ember Modifiers

As a frontend developer I find myself doing plenty of UX-centric work that involves A11y, clear visual feedback based on user interactions, and at times creating new user interactions altogether. On prior versions of Ember this may have been handled by a Component API such as keypress(event) {... or, was it keyPress(event) {?

All of that is in the past as we introduce Ember Modifiers.

What is an Ember Modifier?

ember-modifier is a library referenced in the Ember Guides while discussing event handling. Modifiers are a new feature introduced in Ember Octane Modifiers are a new feature introduced in Ember Octane.

In a nutshell, the next time you reach for a didInsertElement() with an addEventListener() consider any of the following examples instead.

Using Ember Modifiers

Getting Started

First we install the library

# In Your Terminal
ember install ember-modifier

Handling Dom Events

Below is an example for how to track the focus state of a DOM element.

{{!-- my-component.hbs --}}
<button
  {{on 'focus' this.handleFocus}}
  {{on 'blur' this.handleBlur}}
  role="button">
  Focus Me
</button>
// my-component.js
import Component from '@glimmer/component';
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";

export default class MyComponent extends Component {
  @tracked myButtonHasFocus = false;

  @action
  handleFocus() { this.myButtonHasFocus = true; }

  @action
  handleBlur({ target, relatedTarget }) {
    if (!target.contains(relatedTarget)) this.myButtonHasFocus  = false;
  }
}

Handling Key Presses

We can create a custom modifier like so:

# In Your Terminal
ember g modifier key-down
// modifiers/key-down.js
import { modifier } from 'ember-modifier';

export default modifier(function keyUp(element, [handler], { key: desiredKey }) {
  let keydownListener = (evt) => {
    if (!desiredKey || desiredKey === evt.key) {
      handler(evt);
    }
  }

  element.addEventListener('keydown', keydownListener);

  return () => {
    element.removeEventListener('keydown', keydownListener);
  }
});
// tests/integration/modifiers/key-down-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, triggerKeyEvent } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import { set } from '@ember/object';

module('Integration | Modifier | key-down', function(hooks) {
  setupRenderingTest(hooks);

  test('it fires off a function when a key down, passing the event along with it', async function(assert) {
    set(this, 'keyDown', ({ key }) => {
      assert.step('key down');
      assert.equal(key, 'Enter');
    });

    await render(hbs`
      <div {{key-down this.keyDown}}
        data-test-id='keydown'>
      </div>
    `);
    await triggerKeyEvent('[data-test-id=keydown]', 'keydown', 'Enter');

    assert.verifySteps(['key down']);
  });

  test('it can listen for a specific key', async function(assert) {
    set(this, 'keyDown', ({ key }) => {
      assert.step('enter key down');
      assert.equal(key, 'Enter');
    });

    await render(hbs`
      <div {{key-down this.keyDown key="Enter"}}
        data-test-id='keydown'>
      </div>
    `);
    await triggerKeyEvent('[data-test-id=keydown]', 'keydown', 'Enter');
    await triggerKeyEvent('[data-test-id=keydown]', 'keydown', 'Spacebar');

    assert.verifySteps(['enter key down']);
  });
});

Leveraging a key-down modifier

"Binding" a key to an action

A simple example of a focusable element listening for the Enter key to be pressed.

{{!-- my-component.hbs --}}
  <button
    {{key-down this.handleEnter key='Enter'}}
    My Button
  </button>
// my-component.js
import Component from '@glimmer/component';
import { action } from "@ember/object";

export default class SortableGroupAccessibleComponent extends Component {
  @action
  handleEnter() {
    console.log('enter pressed!');
  }

Note, often times it may be better to listen for keyup rather than keydown for such events.

Preventing a default key behavior

Sometimes you simply want to stop the default behavior of a key, such as scrolling down with an arrow key.

{{!-- my-component.hbs --}}
  <dialog
    tabindex="0"
    role='dialog'
    {{key-down this.preventDefault key='ArrowDown'}}
    {{key-down this.preventDefault key='ArrowUp'}}>
    {{yield}}
  </dialog>
// my-component.js
import Component from '@glimmer/component';

export default class MyComponent extends Component {
  preventDefault(evt) { evt.preventDefault(); } // This can be a plain function, no need for the @action decorator
}

There's plenty of more power than what I've shown here! Be sure to check out ember-render-modifiers and ember-focus-trap as well.