Ember Functional Modifiers
Learn all about the powerful new Modifiers API introduced in Ember 3.8 with this exciting addon!
Summary
Ember 3.8 introduced some new low-level APIs that will let us create our own custom element modifiers for our Ember apps. While the final API for exactly how we'll do that shakes out, some addons have already emerged that let us easily take advantage of these lower-level APIs today.
One of these is Ember Functional Modifiers, and it's the one we'll be covering in this video.
An element modifier is a function that gets executed in element space:
<button {{my-modifier}}>
...
</button>
Modifiers have access to the Element instance in which they are invoked, making it easy to run custom JavaScript logic that needs to attach to or somehow modify existing elements in the DOM. They unlock a new level of composability that was previously impossible using just Components.
For more reading on Modifiers, check out this excellent blog post by Chris Garrett.
Today, we're going to use Ember Functional Modifiers to write a {{tooltips}}
modifier we can attach to any element in our Ember app.
To start, let's install the addon, as well as the polyfill (since we're not using Ember 3.8).
ember install ember-functional-modifiers
ember install ember-modifier-manager-polyfill
Our goal is to write a modifier that will replace our existing tooltip implementation. Currently, we have a "dumb" {{ui-tooltip}}
presenter component that we can use to render a tooltip.
<button class='px-3 py-2 flex text-grey-darker'
onclick={{action 'increasePlaybackRate'}}
data-attachment-id='increase-speed'
onmouseenter={{action (mut isShowing) true}}
onmouseleave={{action (mut isShowing) false}}
>
{{fa-icon 'plus'}}
</button>
{{#if isShowing}}
{{#ui-tooltip attachTo="[data-attachment-id='increase-speed']"}}
Increase speed (o)
{{/ui-tooltip}}
{{/if}}
This is a bit clunky, for a few reasons:
- We need to give our
<button>
element an identifier (thedata-attachment-id
attribute), just to give ourui-tooltip
something to attach to - We need to manually wire up the
mouseenter
/mouseleave
events, to show & hide the tooltip
We could make our {{ui-tooltip}}
"smarter", and have it do the work of attaching/removing the events to our target element:
<button class='px-3 py-2 flex text-grey-darker'
onclick={{action 'increasePlaybackRate'}}
data-attachment-id='increase-speed'
- onmouseenter={{action (mut isShowing) true}}
- onmouseleave={{action (mut isShowing) false}}
>
{{fa-icon 'plus'}}
</button>
+ {{#ui-tooltip attachTo="[data-attachment-id='increase-speed']"}}
+ Increase speed (o)
+ {{/ui-tooltip}}
- {{#if isShowing}}
- {{#ui-tooltip attachTo="[data-attachment-id='increase-speed']"}}
- Increase speed (o)
- {{/ui-tooltip}}
- {{/if}}
This certainly helps, but we're still left with the slightly annoying step of having to give this element a "name" via the data-attachment-id
label, just so our tooltip can attach to it.
What if we didn't have to?
<button class='px-3 py-2 flex text-grey-darker'
onclick={{action 'increasePlaybackRate'}}
- data-attachment-id='increase-speed'
+ {{tooltip 'Increase speed (o)'}}
>
{{fa-icon 'plus'}}
</button>
- {{#ui-tooltip attachTo="[data-attachment-id='increase-speed']"}}
- Increase speed (o)
- {{/ui-tooltip}}
This is exactly the sort of thing modifiers let us do. Let's build it.
First, we'll create a tooltip
modifier:
// app/modifiers/tooltip.js
import makeFunctionalModifier from 'ember-functional-modifiers';
export default makeFunctionalModifier((element, [ text ]) => {
console.log(element);
console.log(text);
});
Our modifier is essentially a function that runs whenever its host element is rendered to the DOM. (It also can return a function that runs when the host element is removed.) We can use it to modify the element, without needing any sort of identifier for it.
So, we now have a function that gives us the element instance and the text we want to render for our tooltip โ but we still need to actually render our {{ui-tooltip}}
component.
If we use an Ember Service, our Modifier can set that { element, text }
pair onto the service, and we can use that service elsewhere (say, our Application template) to actually render those ui-tooltip
components.
{{! ...rest of Application template }}
{{#each tooltipsService.tips as |tip|}}
{{#ui-tooltip attachTo=tip.element}}
{{tip.text}}
{{/ui-tooltip}}
{{/each}}
All we need is a barebones Service with a tips
array:
// app/services/tooltips.js
import Service from '@ember/service';
import { A } from '@ember/array';
export default Service.extend({
init() {
this._super(...arguments);
this.tips = A([]);
}
});
Next, let's inject the Service into our Modifier, and push our new tip onto that tips
array. We can do that like this:
// app/modifiers/tooltip.js
import makeFunctionalModifier from 'ember-functional-modifiers';
function addTooltip(tooltips, element, [ text ]) {
console.log(element);
console.log(text);
console.log(tooltips);
});
export default makeFunctionalModifier(
{ services: ['tooltips'] },
addTooltip
);
That first argument is now the tooltips service. So if we call tooltips.tips.pushObject
, we should be able to render some tooltips...
function addTooltip(tooltips, element, [ text ]) {
let tip = { element, text };
tooltips.pushObject(tip);
});
And just like that, we've got a tooltip showing!
Now, we'd like to show/hide the tooltip based on the user mousing over it. Because we have access to the element instance, the modifier is the perfect place to add and remove those event listeners:
function addTooltip(tooltips, element, [ text ]) {
let tip = { element, text };
- tooltips.pushObject(tip);
+ element.addEventListener('mouseenter', () => {
+ tooltips.pushObject(tip);
+ });
+ element.addEventListener('mouseleave', () => {
+ tooltips.removeObject(tip);
+ });
});
Now our event handling & rendering logic is all encapsulated by our Modifier, new service, and snippet of HBS in our Application template!
That means we can add new tooltips as easily as this, anywhere in our Ember application:
<a href='#' {{action (toggle 'autoplay' this)}}
data-test-id='autoplay-control'
+ {{tooltip 'Autoplay next video'}}
class='no-underline p-2 {{if autoplay 'text-em-orange' 'text-grey-dark'}}
>
{{fa-icon 'repeat'}}
</a>
No new nesting, no contextual components, no burden of state management on the caller. Just a simple drop-in custom Modifier that works on any element in our entire app. ๐
Pretty cool stuff!
Links: