We’ve been working on a project recently in which our designs called for a hover dropdown menu that did not close until the user clicks outside of the drop-down. Creating something like this in jQuery and in a Ruby on Rails app would have been a no-brainer. But I quickly discovered it wasn’t going to be quite so straightforward in a new Ember.js client project. (learn more)
So here’s the problem: when you create your view, all of the actions you write for it are scoped specifically to that view. Think of an Ember view like a container. When you click out of the container, you are actually interacting with a different container (a completely different view) with a different set of actions and properties.
So say you wanted to trigger an event when the mouse is clicked outside of the view (in our case: close the dropdown). If you click outside of the view, it would make sense that the action should be written for whatever view you clicked on.
That said, we still want to communicate with our drop-down view so that it knows what to do and when.
Here’s how I solved that problem.
My solution was to have the parent container (in this case, the application view) and the drop-down view share computed properties. These properties are defined to detect two things:
- When the drop-down is active and open.
- When the mouse is outside of the dropdown view.
So first let’s define our properties in the application controller. These are defined here so that the views can grab the properties from the controller and share between them.
//controllers/application.js
import Ember from "ember";
export default Ember.Controller.extend({
dropdownActive: false,
dropdownHovered: false
});
Now let’s create our application view. There are two important steps here:
First, we define the same computed properties by grabbing them from their parent controller.
Second, is to write the click function for the application view. This click closes the drop-down if the mouse is outside the drop-down.
//views/application.js
import Ember from "ember";
export default Ember.View.extend({
dropdownActive: Ember.computed.alias('controller.dropdownActive'),
dropdownHovered: Ember.computed.alias('controller.dropdownHovered'),
click: function() {
if (!this.get('dropdownHovered')) {
this.set('dropdownActive', false);
}
},
});
Then we can move onto the drop-down view itself. The goal here is to grab and share the same computed properties between the views.
The controller for the drop-down view is application.js, so calling: Ember.computed.alias('controller.dropdownActive') will return the correct property.
The logic here toggles the dropdownHovered property between true and false on mouseEnter and mouseLeave so we can detect where the user is moving their mouse. We define dropdownActive to true so the drop-down will open when mouseEnter happens. It would stay open indefinitely without our click action in the application view.
//views/drop-down.js
import Ember from "ember";
export default Ember.View.extend({
dropdownActive: Ember.computed.alias('controller.dropdownActive'),
dropdownHovered: Ember.computed.alias('controller.dropdownHovered'),
mouseEnter: function() {
this.set(‘dropdownHovered’, true);
this.set(‘dropdownActive’, true);
},
mouseLeave: function() {
this.set(‘dropdownHovered’, false);
}
});
The hard part is done, for the sake of clarity here's an example of what the markup would look like:
//templates/views/drop-down.hbs
<p>Some content goes here so we have something to hover over</p>
{{#if dropdownActive}}
<!-- Our dropdown markup -->
{{/if}}
Voila!
Extra notes about views versus components: I initially struggled here trying to keep everything in components instead of views. Getting rid of views in lieu of using only components has been brought up throughout the community at Ember Conf in Portland this year, and in multiple blog posts like this one: http://ember.zone/the-confusion-around-ember-views-and-components/
For that reason, I wanted to try to stay away from views.
However, I found using a view in this case was essential. I needed to access those properties from the controller, that (to my knowledge) would have not been possible as a component. Components are isolated and can be passed context but don’t seem to be built for doing things such as grabbing the parent controller properties.
So I’d love to hear if anyone else has any other solutions to this problem. Is it possible to create this functionality with only components?