Complex Components in Ember.js - Part 1 - Analyzing user flows

18 December 2015

This is Part 1 of the Complex Component Design series. Here are the posts in the series:


In this post I continue the Complex Component Design series I started back in September. I slightly renamed the series title as the original idea was to design and develop the component in the parts of the series but since the component is mostly "done", I prefer to show how it works and how the different pieces fit together. I think this way of presenting things is still (perhaps equally) valuable and we'll have a few open issues to work on "together" to further improve the component.

The component I described in the intro post serves to select an item from a list of items, either via a dropdown or by starting to type its name and then selecting it. Here is a very short demo about how that looks in practice:

Selecting an artist

We'll go through the main UI flows and see how they are implemented via communication of the different layers of the component.

Getting familiar with the component

The template we'll use (and which the above demo uses) to understand the functioning of the component looks like this:

 1<!-- tests/dummy/app/templates/index.hbs -->
 2<div class="form-group">
 3  <label>Choose an artist</label>
 4  {{#auto-complete
 5        on-select=(action "selectArtist")
 6        on-input=(action "filterArtists")
 7        class="autocomplete-container" as |autocomplete isDropdownOpen inputValue
 8                                           toggleDropdown onSelect onInput|}}
 9    <div class="input-group">
10      {{auto-complete-input
11          autocomplete=autocomplete
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 autocomplete=autocomplete isVisible=isDropdownOpen
18              class="typeahead typeahead-long dropdown-menu" as |list|}}
19        {{#each matchingArtists as |artist|}}
20          {{#auto-complete-option
21              id=artist.id
22              label=artist.name
23              item=artist
24              list=list
25              on-click=onSelect
26              activeId=selectedArtist.id}}
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      {{#auto-complete-dropdown-toggle on-click=toggleDropdown
34              class="input-group-addon dropdown-toggle"}}
35        <span class="caret"></span>
36      {{/auto-complete-dropdown-toggle}}
37    </div>
38  {{/auto-complete}}
39</div>

This might seem somewhat daunting at first but as we grow acquainted with its details, our intimidation will subside.

The top-level component is auto-complete. This is the "command center", the piece that manages the "global" state of the whole widget, like whether the dropdown is visible and what the current value of the input field is.

You might, with good reason, wonder why these are not handled by the sub-component where it'd feel more appropriate: the current value of the input field by auto-complete-input and the opened/closed state of the dropdown by auto-complete-dropdown-toggle.

The answer is that a change in these states can be triggered from multiple places and that several child components might need to know about them. The dropdown can be closed by the user clicking on one of the items in the dropdown (not on the little arrow of the toggle), while the current text in the input can be modified by inferring the item when the user starts to type (not just by actually typing out the whole text).

Data down, actions up - all the way down (and up)

That slight violation of separation of concerns (or is it at all?) fits perfectly with the most important component communication paradigm: Data down, actions up.

The input, when its value changes, sends an action up to its parent, notifying it of the change. The parent can then react to this, and communicate any data (state) changes via the attribute bindings it has to the input. This is why auto-complete needs to handle, or at least access, state that is used downstream by its sub-components.

The classical way of passing down data (and establishing a binding) from the parent to the child is through block parameters of the parent component. The auto-complete component has quite some:

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  (...)
8{{/auto-complete}}

The block parameters are those found between the pipes, after the as keyword. You have to look into the component's own template to see where they come from:

1<!-- addon/templates/components/auto-complete.hbs -->
2{{yield this isDropdownOpen inputValue
3        (action "toggleDropdown") (action "selectItem") (action "inputDidChange")}}

Parameters are matched by position, so what is yielded in the first position becomes the first block parameter. In this case, we yield the component itself as the first parameter, the aforementioned component states as the 2nd and 3rd and then (closure) actions that will trigger functions in the auto-complete component when called in one of the child components. These serve as "remote controls" (a term used by Miguel Camba in his awesome presentation at EmberCamp) for child components to control their parent.

The way of upward communication from child components is calling these actions when appropriate.

We now have sufficient knowledge to follow the implemention of basic user flows, so let's get into it.

Understanding UX flows

Manual selection from the dropdown

The most basic thing one can do with the widget is to pop open the list of options.

I discarded the parts that are not relevant to understand this, so we're left with the following:

 1<!-- tests/dummy/app/templates/index.hbs -->
 2<div class="form-group">
 3  <label>Choose an artist</label>
 4  {{#auto-complete
 5        on-select=(action "selectArtist")
 6        on-input=(action "filterArtists")
 7        class="autocomplete-container" as |autocomplete isDropdownOpen inputValue
 8                                           toggleDropdown onSelect onInput|}}
 9    <div class="input-group">
10      {{#auto-complete-list autocomplete=autocomplete isVisible=isDropdownOpen
11              class="typeahead typeahead-long dropdown-menu" as |list|}}
12        (...)
13      {{/auto-complete-list}}
14      {{#auto-complete-dropdown-toggle on-click=toggleDropdown
15              class="input-group-addon dropdown-toggle"}}
16        <span class="caret"></span>
17      {{/auto-complete-dropdown-toggle}}
18    </div>
19  {{/auto-complete}}
20</div>

The auto-complete-dropdown-toggle is the component that can be clicked to open or close the list of items. At a glance it seems like its on-click attribute is the action that will be triggered when the user clicks it but let's see for sure:

 1// addon/components/auto-complete-dropdown-toggle.js
 2import Ember from 'ember';
 3
 4export default Ember.Component.extend({
 5  tagName: 'span',
 6  classNames: 'ember-autocomplete-toggle',
 7  'data-dropdown': 'dropdown',
 8  'on-click': null,
 9
10  toggleDropdown: Ember.on('click', function() {
11    this.get('on-click')();
12  })
13});

Indeed, it just calls the action that was passed into it, which is the toggleDropdown action of the topmost auto-complete component:

 1// addon/components/auto-complete.js
 2import Ember from 'ember';
 3
 4export default Ember.Component.extend({
 5  (...)
 6  actions: {
 7    toggleDropdown() {
 8      this.toggleProperty('isDropdownOpen');
 9    },
10  }
11});

The toggleProperty method flips the value of its parameter, so if it was false it now becomes true. isDropdownOpen is yielded as a block parameter so when it becomes true, auto-complete-list will rerender as one of its attributes, isVisible has changed. That will then open the dropdown:

 1<!-- tests/dummy/app/templates/index.hbs -->
 2<div class="form-group">
 3  <label>Choose an artist</label>
 4  {{#auto-complete
 5      (...)
 6      class="autocomplete-container" as |autocomplete isDropdownOpen inputValue
 7                                           toggleDropdown onSelect onInput|}}
 8    <div class="input-group">
 9      {{#auto-complete-list autocomplete=autocomplete isVisible=isDropdownOpen
10              class="typeahead typeahead-long dropdown-menu" as |list|}}
11        (...)
12      {{/auto-complete-list}}
13    </div>
14  {{/auto-complete}}
15</div>

The same process is triggered when the toggle is clicked again, only this time isDropdownOpen goes back to false and thus the dropdown is closed.

Picking an item

The second feature we'll look at is more like the second half of the first one: selecting an item by clicking (tapping) on it.

I have again restrained the template to the relevant bits, throwing away the input and the toggle:

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

When one of the items is clicked, the on-click attribute (which is the onSelect closure action provided by auto-complete) is called in the auto-complete-option component:

 1// addon/components/auto-complete-option.js
 2import Ember from 'ember';
 3
 4export default Ember.Component.extend({
 5  (...)
 6  selectOption: Ember.on('click', function() {
 7    this.get('on-click')(this.get('item'), this.get('label'));
 8  }),
 9});

So where is onSelect defined? It is one of the block parameters yielded by auto-complete, more precisely the (action "selectItem") action:

1<!-- addon/templates/components/auto-complete.hbs -->
2{{yield this isDropdownOpen inputValue
3        (action "toggleDropdown") (action "selectItem") (action "inputDidChange")}}

selectItem is quite straightforward:

 1// addon/components/auto-complete.js
 2import Ember from 'ember';
 3
 4export default Ember.Component.extend({
 5  (...)
 6  actions: {
 7    selectItem(item, value) {
 8      this.get('on-select')(item);
 9      this.set('isDropdownOpen', false);
10      this.set('inputValue', value);
11    },
12    (...)
13  }
14});

It first calls the on-select action that was passed into it from the "outside" (the controller), which just sets selectedArtist to the artist object encapsulated in the list item. It then sets the isDropdownOpen flag to false (which, by the mechanism seen in the previous point, closes the list) and sets the text in the input to the item's label (the artist's name).

Auto-completing an item

As the final example, let's see a more complicated use case. When the user starts to type, the items that do not match the typed string will not be shown as options. Also, the first matching item will be auto-completed and selected, and the dropdown will be closed.

No surprises here, the same design principle will be applied as before. Pass down an action that should be called from a child, then change some property in the parent component that trickles down to the child which then rerenders itself because of the changed attribute.

Let's see the relevants parts of the template:

 1<!-- tests/dummy/app/templates/index.hbs -->
 2<div class="form-group">
 3  <label>Choose an artist</label>
 4  {{#auto-complete
 5        on-select=(action "selectArtist")
 6        on-input=(action "filterArtists")
 7        class="autocomplete-container" as |autocomplete isDropdownOpen inputValue
 8                                           toggleDropdown onSelect onInput|}}
 9    <div class="input-group">
10      {{auto-complete-input
11          autocomplete=autocomplete
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 autocomplete=autocomplete isVisible=isDropdownOpen
18              class="typeahead typeahead-long dropdown-menu" as |list|}}
19        {{#each matchingArtists as |artist|}}
20          {{#auto-complete-option
21              (...)
22          {{/auto-complete-option}}
23        {{else}}
24          <li><a href="#">No results.</a></li>
25        {{/each}}
26      {{/auto-complete-list}}
27      (...)
28    </div>
29  {{/auto-complete}}
30</div>

We'll start by the auto-complete-input this time where the input event, triggered by the user's typing, is handled:

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

This is almost the exact copy of calling the on-select action we saw before from auto-complete-option. Here, the on-change function is called that was passed down from the block param of auto-complete.

If we take a look in the template of auto-complete we see it creates a (action 'inputDidChange') closure action and yield that, so that should be the next thing to look at. Here is where most of the stuff happens:

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

We first call the on-input action which filters out the artists that do not match the typed prefix. The result of that is that matchingArtists will only contain the artists that do match. The dropdown is then opened to display these items (or an explanatory blurb if none matches). If there is at least one matching item, the first one is selected (and becomes selectedArtist).

As an UX improvement, the "inferred" range from the label in the input is selected, so that the user can continue typing and thus select another artist if the first one was not what they meant. (See when I type "J" in the demo).

Design concepts

I'm not totally happy with the current state of the component because of the following:

1) The auto-complete component reaches inside the auto-complete-input one (set in its input property) to call setSelectionRange on it (see the last code snippet).

2) The same component retrieves the options from the list and gets its item to select it. Again, this is quite intrusive and will break if the internals of auto-complete-option change.

3) Still the auto-complete component yields an instance of itself as a block parameter. This enables "downstream consumers" to access any of its properties and methods, breaking its encapsulation.

In presenting about these concepts at the Global Ember Meetup and at Ember.js Belgium, I said that I like to think about components as the objects of the UI. Thinking about them as objects helps to deliver the point that some (most?) object oriented practices should be applied to components, too. If this assumption is correct, we can leverage OOP design concepts and guidelines that we've been developing for decades, giving us a headstart on how to design (and what to watch out for) complex component hierarchies.

For example, I consider the set of block parameters yielded by a component as its public API. This means that yielding this from a component's template is considered bad practice as it breaks encapsulation. In some cases, it's relatively easy to find a way around it, in others it's much more difficult. We'll see if I can pull it off in the above case.

As a closing thought, notice how 95% of the feature's implementation relied on block parameters and closure actions. They are fantastic tools to work with and I don't know how anything could be achieved without them before they existed.

Pointers

Incidentally, Miguel Camba seems to think about components lately, too. I already mentioned his fantastic talk at EmberCamp this year called "Composable components", but above that he has released ember-power-select, which serves the same purpose as the auto-complete component in my blog post series.

However, it's much more mature and flexible so if you need a select dropdown in your app, use ember-power-select, as my component is for learning and demonstration purposes only. That said, I published it on Github under balinterdi/ember-cli-autocomplete if you want to take a look or follow along the blog posts while looking at its source code. I put a tag called ccd-part-one on the repo for this blog post.

In the next episode...

... of the series, I'd like to address (some of) my concerns I mentioned above and see how to fix them. Stay tuned!

Share on Twitter