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:
- Intro
- Part 1 - Analyzing User Flows
- Part 2 - Towards a more reactive component
- Part 3 - Remove the observer
- Part 4 - Use the hash helper
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:
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:
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:
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:
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:
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:
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:
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:
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:
So where is onSelect
defined? It is one of the block parameters yielded by auto-complete
, more precisely the (action "selectItem")
action:
selectItem
is quite straightforward:
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:
We'll start by the auto-complete-input
this time where the input
event, triggered by the user's typing, is handled:
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