Complex Components in Ember - Part 2 - A more reactive component

04 February 2016

This is part 2 of my Complex Component Design series. Here are the posts in the series:


In the previous part of this series, the implementation of the main user flows were explained in detail. I ended the post by saying that I was not content with the implementation for several reasons, the most crucial of which was that parent components needed to be passed down to children, so that children can register themselves with their parent. That, in turn, allowed parents to reach their children and call methods on them directly instead of using events, actions and data bindings for communication. In this post, we'll see how to get rid of these and replace them with more reactive solutions.

Remove the need for direct access to the input

Currently, the autocomplete component (the parent) yields itself to its children. auto-complete-input binds its own autocomplete attribute to it so that it can register itself with its parent when inserted:

 1<!-- tests/dummy/app/templates/index.hbs -->
 2{{#auto-complete
 3      on-select=(action "selectArtist")
 4      on-input=(action "filterArtists")
 5      class="autocomplete-container" as
 6        |autocomplete isDropdownOpen inputValue
 7         toggleDropdown onSelect onInput|}}
 8  <div class="input-group">
 9    {{auto-complete-input
10        autocomplete=autocomplete
11        value=inputValue
12        on-change=onInput
13        type="text"
14        class="combobox input-large form-control"
15        placeholder="Select an artist"}}
16    (...)
17  </div>
18(...)
19{{/auto-complete}}
 1// addon/components/auto-complete-input.js
 2import Ember from 'ember';
 3
 4export default Ember.TextField.extend({
 5  autocomplete: null,
 6
 7  registerWithAutocomplete: Ember.on('didInsertElement', function() {
 8    this.get('autocomplete').registerInput(this);
 9  }),
10  (...)
11});

This is needed when the item is autocompleted and the autocompleted segment is pre-selected so that the user can type over it if it's not the item they had in mind:

 1// addon/components/auto-complete.js
 2export default Ember.Component.extend({
 3  (...)
 4  actions: {
 5    inputDidChange(value) {
 6      (...)
 7      Ember.run.scheduleOnce('afterRender', this, function() {
 8        (...)
 9        const firstOption = this.get('list.firstOption');
10        if (firstOption) {
11          const autocompletedLabel = firstOption.get('label');
12          this.set('focusedOption', firstOption);
13          this.get('on-select')(firstOption.get('item'));
14          this.set('inputValue', autocompletedLabel);
15          Ember.run.next(() => {
16            this.get('input.element').setSelectionRange(value.length, autocompletedLabel.length);
17          });
18        }
19      });
20    }
21  }
22});

On the very last line, the component accesses the input directly, to select (and highlight) the portion of the item that was autocompleted. That's why we need the whole registration process.

Since inputDidChange is triggered from the auto-complete-input component, we could get rid of this direct coupling if there was a way to react to the action's result in the auto-complete-input itself. That way is called closure actions.

Fire, but don't forget

As opposed to the fire-and-forget nature of "ordinary" (aka. element) actions, closure actions provide a way to react to the action's outcome at the source, where the action was fired from.

Since closure actions are functions, they can have return values. If the action triggers an async action, it's best to return a promise from the upstream handler to which the event source can attach its handler to.

Let's see how that works in our case.

 1// addon/components/auto-complete.js
 2export default Ember.Component.extend({
 3  (...)
 4  actions: {
 5    inputDidChange(value) {
 6      this.get('on-input')(value);
 7      this.set('isDropdownOpen', true);
 8      return new Ember.RSVP.Promise((resolve, reject) => {
 9        (...)
10        Ember.run.scheduleOnce('afterRender', this, function() {
11          const firstOption = this.get('list.firstOption');
12          if (firstOption) {
13            const autocompletedLabel = firstOption.get('label');
14            this.set('focusedOption', firstOption);
15            this.get('on-select')(firstOption.get('item'));
16            this.set('inputValue', autocompletedLabel);
17            Ember.run.next(() => {
18              resolve({ start: value.length, end: autocompletedLabel.length });
19            });
20          }
21        });
22      });
23    }
24  }
25});

The code did not change a lot, but now a promise is returned on line 8. It is resolved on 18, where start and end designate the cursor positions of the selection.

The action handler in the auto-complete-input component needs to be modified to set the selection higlight itself:

 1// addon/components/auto-complete-input.js
 2import Ember from 'ember';
 3
 4export default Ember.TextField.extend({
 5  valueDidChange: Ember.on('input', function() {
 6    const value = this.$().val();
 7    this.get('on-change')(value).then(({ start, end }) => {
 8      this.get('element').setSelectionRange(start, end);
 9    });
10  })
11});

Calling on-change will call the above inputDidChange function. Instead of firing the (element) action and forgetting about it, we now call the (closure) action and then "wait" for the resulting promise to be resolved. Once it does, we set the selection range.

We could now remove all the registration code and the passing down of the autocomplete instance to the input component.

Remove the need for direct access to the list options

There is still another instance of the same. It serves to give access to the autocomplete component to the auto-complete-option, through the auto-complete-list.

 1<!-- tests/dummy/app/templates/index.hbs -->
 2{{#auto-complete
 3      on-select=(action "selectArtist")
 4      on-input=(action "filterArtists")
 5      class="autocomplete-container" as |autocomplete isDropdownOpen inputValue
 6                                         toggleDropdown onSelect onInput|}}
 7  <div class="input-group">
 8    {{auto-complete-input
 9        value=inputValue
10        on-change=onInput
11        type="text"
12        class="combobox input-large form-control"
13        placeholder="Select an artist"}}
14    {{#auto-complete-list autocomplete=autocomplete isVisible=isDropdownOpen class="typeahead typeahead-long dropdown-menu" as |list|}}
15      {{#each matchingArtists as |artist|}}
16        {{#auto-complete-option
17            id=artist.id
18            label=artist.name
19            item=artist
20            list=list
21            on-click=onSelect
22            activeId=selectedArtist.id}}
23          <a href="#">{{artist.name}}</a>
24        {{/auto-complete-option}}
25      {{/each}}
26    {{/auto-complete-list}}
27    (...)
28  </div>
29{{/auto-complete}}

I am not copying all the registration code here as it's very boilerplatey. Each option, when inserted into the DOM, registers itself with its list, while the list registers itself with the auto-complete component. The latter has an options property to access the options:

1// addon/components/auto-complete.js
2options: Ember.computed.readOnly('list.options')

This access is needed to be able to cycle through the options by using the cursor keys and then select one of them by using the return key. Here is the code that handles keypresses (more precisely, keydowns):

 1// addon/components/auto-complete.js
 2export default Ember.Component.extend({
 3  (...)
 4  keydownMap: {
 5    8:  'startBackspacing', // backspace
 6    13: 'selectOption',  // return
 7    27: 'closeDropdown', // escape
 8    38: 'focusPrevious', // up key
 9    40: 'focusNext', // down key
10  },
11
12  handleKeydown: Ember.on('keyDown', function(event) {
13    const map = this.get('keydownMap');
14    const code = event.keyCode;
15    const method = map[code];
16    if (method) {
17      return this[method](event);
18    }
19  }),
20  (...)
21});

This is pretty simple so far. If a key we care about was pressed, we call the appropriate method to handle it. Let's see how focusing works:

 1// addon/components/auto-complete.js
 2export default Ember.Component.extend({
 3  (...)
 4  options: Ember.computed.readOnly('list.options'),
 5
 6  focusPrevious: function(event) {
 7    event.preventDefault();
 8    const focused = this.get('focusedOption');
 9    let index = this.get('options').indexOf(focused);
10    if (this.get('isDropdownOpen')) {
11      index = index - 1;
12    }
13    this.focusOptionAtIndex(index);
14  },
15
16  focusNext: function(event) {
17    event.preventDefault();
18    let index = 0;
19    const focused = this.get('focusedOption');
20    if (focused) {
21      index = this.get('options').indexOf(focused);
22      if (this.get('isDropdownOpen')) {
23        index = index + 1;
24      }
25    }
26    this.focusOptionAtIndex(index);
27  },
28
29  focusOptionAtIndex: function(index) {
30    const options = this.get('options');
31    if (index === -1) {
32      index = options.get('length') - 1;
33    } else if (index === options.get('length')) {
34      index = 0;
35    }
36    const option = this.get('options').objectAt(index);
37    if (!option) {
38      return;
39    }
40    this.focusOption(option);
41  },
42
43  focusOption: function(option) {
44    const focused = this.get('focusedOption');
45    if (focused) {
46      focused.blur();
47    }
48    this.set('focusedOption', option);
49    option.focus();
50  },
51  (...)
52});

focusPrevious and focusNext make sure that the focused index is kept within the bounds of the avaiable number of options and then focus the previous (or next) one by calling option.focus() directly (line 49).

There is one more key press concerning related to options, the return key. It should select the currently focused option, if there is one:

 1// addon/components/auto-complete.js
 2export default Ember.Component.extend({
 3  (...)
 4  options: Ember.computed.readOnly('list.options'),
 5  selectOption: function(event) {
 6    event.preventDefault();
 7    const focused = this.get('focusedOption');
 8    if (focused) {
 9      this.send('selectItem', focused.get('item'), focused.get('label'));
10    }
11    this.set('isDropdownOpen', false);
12  },
13});

This code also leverages the access to the options, indirectly through this.get('focusedOption'). Furthermore, it assumes that each option has an item and label properties. Not stellar.

It won't be a piece of cake to get rid of direct coupling in all of these, so let's get to it.

Change the focused option without accessing the options

In the first step, we'll change the focused option without directly commanding the options to focus/unfocus. We'll then tackle selecting the focused option.

We can use simple data binding to have the focused option available. By maintaining and yielding a focusedIndex in the "control center", the autocomplete component, autocomplete-option components can bind to it and know whether they are focused or not.

Here is how the templates need to change:

1<!-- addon/templates/components/autocomplete.hbs -->
2{{yield isDropdownOpen
3        inputValue
4        focusedIndex
5        selectedIndex
6        (action "toggleDropdown")
7        (action "selectItem")
8        (action "inputDidChange")}}
 1<!-- tests/dummy/app/templates/index.hbs -->
 2{{#auto-complete
 3      on-select=(action "selectArtist")
 4      on-input=(action "filterArtists")
 5      options=matchingArtists
 6      displayProperty="name"
 7      class="autocomplete-container" as |isDropdownOpen inputValue
 8                                         focusedIndex selectedIndex
 9                                         toggleDropdown onSelect onInput|}}
10  <div class="input-group">
11    {{auto-complete-input
12        value=inputValue
13        on-change=onInput
14        type="text"
15        class="combobox input-large form-control"
16        placeholder="Select an artist"}}
17    {{#auto-complete-list
18        isVisible=isDropdownOpen
19        class="typeahead typeahead-long dropdown-menu" as |list|}}
20      {{#each matchingArtists as |artist index|}}
21        {{#auto-complete-option
22            label=artist.name
23            item=artist
24            on-click=onSelect
25            isFocused=(eq focusedIndex index)
26            isSelected=(eq selectedIndex index)}}
27          <a href="#">{{artist.name}}</a>
28        {{/auto-complete-option}}
29      {{else}}
30        <li><a href="#">No results.</a></li>
31      {{/each}}
32    {{/auto-complete-list}}
33    (...)
34  </div>
35{{/auto-complete}}

Note the new focusedIndex and selectedIndex attributes, yielded by the top-level component that isFocused and isSelected in the auto-complete-option are bound to.

The eq helper comes from ember-truth-helpers and will evaluate to true if its params are equal which is exactly what we want.

The autocomplete component needs to change to manage the new indexes instead of setting its focusedOption and calling option.set directly:

 1// addon/components/auto-complete.js
 2export default Ember.Component.extend({
 3  (...)
 4  optionsLength: Ember.computed.readOnly('options.length'),
 5  focusPrevious: function(event) {
 6    event.preventDefault();
 7    const currentIndex = this.get('focusedIndex');
 8    let newIndex;
 9    if (Ember.isNone(currentIndex)) {
10      newIndex = this.get('optionsLength') - 1;
11    } else if (currentIndex === 0) {
12      newIndex = this.get('optionsLength') - 1;
13    } else {
14      newIndex = currentIndex - 1;
15    }
16    this.set('focusedIndex', newIndex);
17    this.set('isDropdownOpen', true);
18  },
19
20  focusNext: function(event) {
21    event.preventDefault();
22    const currentIndex = this.get('focusedIndex');
23    const lastIndex = this.get('optionsLength') - 1;
24    let newIndex;
25    if (Ember.isNone(currentIndex)) {
26      newIndex = 0;
27    } else if (currentIndex === lastIndex) {
28      newIndex = 0;
29    } else {
30      newIndex = currentIndex + 1;
31    }
32    this.set('focusedIndex', newIndex);
33    this.set('isDropdownOpen', true);
34  },
35
36  selectOption: function(event) {
37    event.preventDefault();
38    const focusedIndex = this.get('focusedIndex');
39    if (Ember.isPresent(focusedIndex)) {
40      this.set('selectedIndex', focusedIndex);
41    }
42    this.set('isDropdownOpen', false);
43  },
44});

That is simpler and less intrusive than before. (Setting isDropdown to true has been added as before the option's focus method did the opening).

What's missing is for the selected item to be sent to the outer world (in other words, for the selectItem to be triggered). Before, it was done by sending the selectItem action with the focused option's item and label (see line 9 in the last snippet of the previous section) but we can no longer indulge in accessing the options directly. Consequently, it was replaced by setting the selectedIndex to the focusedIndex (see line 40 above).

The problem now is that selectItem needs to be called with the item and the label (the name of the selected artist to be set as the input's value) and only the selected auto-complete-option component has that knowledge. So we need to set up a way for the auto-complete-option components to know when they become selected and then call that action. As these components are not the source of the event that lead to an option being selected by key press, we choose to use an observer:

 1// addon/components/auto-complete-option.js
 2import Ember from 'ember';
 3
 4export default Ember.Component.extend({
 5  tagName: 'li',
 6  classNames: 'ember-autocomplete-option',
 7  classNameBindings: Ember.String.w('isSelected:active isFocused:focused'),
 8
 9  label: null,
10  item: null,
11  'on-click': null,
12  isFocused: false,
13  isSelected: false,
14
15  didClick: Ember.on('click', function() {
16    this._selectItem();
17  }),
18
19  didBecomeSelected: Ember.observer('isSelected', function() {
20    const isSelected = this.get('isSelected');
21    if (isSelected) {
22      this._selectItem();
23    }
24  }),
25
26  _selectItem() {
27    const item = this.get('item');
28    this.get('on-click')(item, this.get('label'));
29  }
30});

Line 21 and 22 is where the option realizes it has become the selected option, and then calls the corresponding (closure) action on line 28.

We're done, we got rid of all the direct passing of component instances, registrations and direct property access and method calling. Even though we're Demeter compliant, there are things that could be improved.

In the next episode...

One of these things is the observer. Observers fell out of favor some time ago, and for a good reason. They can be over-eager and lead to scenarios where it is hard to see what's going on. To prove my point, let me show you a bug I've just accidentally introduced. I call it the "JPJ is too good to be replaced" bug:

JPJ is too good to be replaced

(The code for this series is publicly available on Github here. I've tagged where we are now with ccd-part-two.)

So we're not done yet. In the next post of the series, we're going to fix that bug by replacing the observer and make other worthy improvements. Stay tuned!

Share on Twitter