Migrating from Liquid Fire modals to Ember Elsewhere modals in Ember.js

Glimmer 2 is out in alpha and i’m keen to try it out but currently Liquid Fire is not yet compatible.

In the meantime the master branch on github now supports Ember 2.8 beta so to help prepare for Glimmer 2 I investigated updating to that first.

In that changelog it mentioned that with this commit support for modals has been removed. For one of my applications in particular this had big implications as it includes numerous modals.

This reply to an issue explains why it’s necessary

Yes, this is only an issue for liquid-modal, which I intend to remove anyway — it makes particularly egregious use of private API and it’s an inferior experience to just using liquid-fire with https://github.com/ef4/ember-elsewhere to implement modals.

As this is something that I imagine might impact others too I’ve documented the process I used to make the switch in the hope that it might be useful to others facing a similar situation.

Spoiler: I’m happy to say I found it to be pretty straightforward.


Current Liquid Fire example

First, a quick rundown of the old way using Liquid Fire with a very simple example.

A modal route added to the router

In the Router.map function of router.js:

this.modal(“modal-track-upload-error”, {
dialogClass: “error-modal”,
withParams: “trackUploadError”
});

This just added an error-modal class to the dialog for styling and was triggered by the trackUploadError query parameter.

A query parameter added to the relevant controller

trackUploadError was included in the application controller file like this:

export default Ember.Controller.extend({
queryParams: [
‘trackUploadError’,
],
trackUploadError: false
});

The modal handlebars template

As the modal is so simple there was no need for a component js file, only a template file, modal-track-upload-error.hbs, which looks like this:

<h2 id=”error-modal-title”>Track Upload Error</h2>
<p>Lorem ipsum...</p>
<div class=”close-modal”>
<button {{action (route-action ‘tryAgain’)}} class=”button-large” id=”error-modal-button”>OK, got it</button>
</div>

Note the use of a route-action here, the ember-route-action-helper addon makes it simple to send an action direct to the route from within the component.

Including the modal in the application template

Finally, the only other code needed was to include the liquid-modal component in application.hbs:

{{liquid-modal}}

Styling the generated HTML

The DOM produced by this code looks like this:

<div id=”ember587" class=”ember-view liquid-modal”>
<div id=”ember1076" class=”ember-view liquid-child”>
<div id=”ember1085" tabindex=”0" class=”ember-view lm-container”>
<div role=”dialog” class=”lf-dialog error-modal”>
<div id=”ember1090" class=”ember-view”>
<h2 id=”error-modal-title”>Track Upload Error</h2>
<p>Lorem ipsum...</p>
<div class=”close-modal”>
<button class=”button-large” id=”error-modal-button” data-ember-action=”1102">OK, got it</button>
</div>
</div>
</div>
</div>
<span id=”ember1111" class=”ember-view lf-overlay”></span>
</div>
</div>

Some of the classes, such as lm-container, lf-dialog and lf-overlay are css class hooks used by the Liquid Fire library for default styling and this will all be replaced.

At its most simple the CSS styling was just to make a full screen background tint, like this:

.lf-overlay {
background-color: black;
opacity: 0.75;
}

Animating the transition

To animate the modal appearing and disappearing I had used this (in hindsight overly complicated) code in the transitions.js file:

this.transition(
this.inHelper(‘liquid-modal’),
this.toValue(true),
this.use(‘explode’, {
pick: ‘.lf-overlay’,
use: [‘cross-fade’, {duration: 500, maxOpacity: 0.75}]
}, {
pick: ‘.lm-container’,
use: [‘cross-fade’, {duration: 500, delay: 500}]
}),
this.reverse(‘explode’, {
pick: ‘.lf-overlay’,
use: [‘cross-fade’]
}, {
pick: ‘.lm-container’,
use: [‘cross-fade’]
})
);

Again, this is something that I’m hoping to simplify in the refactoring.


Displaying the modal using Ember Elsewhere

The first immediate goal for me was to reproduce a working modal using Ember Elsewhere instead of Liquid Fire in it’s most basic form. The animation and closing the modal by clicking outside it could once the modal appeared correctly.

Installing Ember Elsewhere

ember install ember-elsewhere

Making a replacement modal component

I started by generating a new component called modal-target (the name comes from the source code to the Ember Elsewhere example — see here) which would replace {{liquid-modal}}

ember g component modal-target

Initially only the handlebars template (modal-target.hbs) needed to be updated:

{{#from-elsewhere name=”modal” as |modal|}}
{{#if modal}}
<div class=”modal-background”></div>
<div class=”modal-container”>
<div class=”modal-dialog” >
{{component modal}}
</div>
</div>
{{/if}}
{{/from-elsewhere}}

Which rendered the DOM needed for the modal to display in the correct place and added some very simple styling to centre the modal in the browser window with a sold tint behind.:

.modal-background {
background-color: black;
opacity: 0.75;
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
}
.modal-container {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
}

Then updated application.hbs to replace the existing {{liquid-modal}} with the {{modal-target}} component.

Triggering the modal with the query parameter

Instead of including the modal details in the router and specifying the queryParams there it’s now triggered directly in the template where the query parameter exists (personally I think this feels a lot more natural).

In my example the query parameter trackUploadError is in the application controller so to trigger the modal it I added to application.hbs:

{{#if trackUploadError}}
{{to-elsewhere named=”modal” send=”modal-track-upload-error” }}
{{/if}}

And that was everything needed to change for it to work!

You can now remove the legacy code from the router and the modal should still display exactly as it did before via the same query parameter.


Handling actions and closing the modal

By using a component in this way we can now take advantage of some of the benefits of this approach.

You may notice that clicking outside the modal doesn’t currently close it —although this is very often the desired behaviour.

Fortunately there is an addon specifically created to solve this problem called ember-ignore-children-helper

Install the addon

ember install ember-ignore-children-helper

Adding it to the modal window

We can take advantage of the new hash helper to pass in the action at the same time as the component like this (in application.hbs):

{{to-elsewhere named="modal" send=(hash body=(component "modal-track-upload-error") onOutsideClick=(action (mut trackUploadError) false)) }}

Then invoke this action from the click event of the modal-container div by adding it to the modal-target.hbs:

{{#from-elsewhere name=”modal” as |modal|}}
{{#if modal}}
<div class=”modal-background”></div>
<div class=”modal-container” onclick={{action (ignore-children modal.onOutsideClick) }}>
<div class=”modal-dialog” >
{{component modal.body}}
</div>
</div>
{{/if}}
{{/from-elsewhere}}

Clicking outside the modal should now close it.

Adding additional actions

We can use this same technique with closure actions to replace the old Liquid Fire modal actions approach of including them in the router (or in my example using the route-action addon) with a more simple approach too, so instead of the button tag in modal-track-upload-error.hbs:

<button {{action (route-action ‘tryAgain’)}} class=”button-large” id=”error-modal-button”>OK, got it</button>

It can be updated to:

<button {{action onClose}} class=”button-large” id=”error-modal-button”>OK, got it</button>

and add the onClose closure action to the send hash like this (pay attention to the parenthesis here, it’s the body which contains both the component and onClose action code):

{{to-elsewhere named=”modal” send=(hash body=(component “modal-track-upload-error” onClose=(action (mut trackUploadError) false)) onOutsideClick=(action (mut trackUploadError) false)) }}

I think this is another real benefit of using Ember Elsewhere instead of Liquid Fire for modals.

Previously I had a lot of boilerplate code for each actions, whereas now I can pass in the actions as and when I need them directly.


Animating the modal

The final step to reproduce the Liquid Fire modal is to include the animation.

Instead of including the code in the transitions.js file (as shown earlier) we can remove that completely and instead add it to the component’s modal-target.js file directly like this:

export default Ember.Component.extend({
modalAnimation
});
function modalAnimation() {
return this.lookup(‘explode’).call(this, {
pick: ‘.modal-background’,
use: [‘fade’, { maxOpacity: 0.75 }]
}, {
pick: ‘.modal-dialog’,
use: ‘to-down’
});
}

Although I don’t yet fully understand how this code works (I took it from the example) I like the fact that the transition code is now included in the component code itself.

The final update required is to include this animation by adding a liquid-bind to the modal-target.hbs file:

{{#from-elsewhere name=”modal” as |modal|}}
{{#liquid-bind modal containerless=true use=modalAnimation as |currentModal|}}
{{#if currentModal}}
<div class=”modal-background”></div>
<div class=”modal-container” onclick={{action (ignore-children currentModal.onOutsideClick) }}>
<div class=”modal-dialog” >
{{component currentModal.body}}
</div>
</div>
{{/if}}
{{/liquid-bind}}
{{/from-elsewhere}}

The modal should now animate between states just like with Liquid Fire modals and is a fully functionally equivalent to the original version.


Summary

I was a bit reluctant initially to make this change as I didn’t really like the modal code I’d written but now I’m very happy I did.

Even though it meant installing two additional addons the process was straightforward and now I feel that my modal code relates much more directly to the modal-target component.

This has made the code much easier to rationalise and has removed a lot of the ‘magic’ of Liquid Fire modals. I’m now much more confident to refactor them to make them more reusable between use cases.

Overall, my opinion is a very positive one and i’m now looking forward to upgrading first to Ember 2.8 and then Glimmer 2.

If you’re going through the same process I’d like to get your opinion on this article— especially if you think I’ve missed out on something important.