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:
- 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 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:
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
.
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:
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:
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:
(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