Introduction
"Modals", also referred to as "pop ups", are a very commonly used type of user-interface wherein the user is shown some information and is able to interact with the website, without leaving the page. They're great for showing small-medium amounts of information to a user, whilst keeping the flow of interaction on one page.
This article provides an approach to displaying and controlling the lifecycle of modals within Ember.js applications.
Pre-requisites
Using the Code
The source code is in this repository.
Start off by creating an ember util
called ModalUtil
.
In terminal:
ember g util modal
This utility will export an extension of the Parse.Object
class, including methods set the header and body templates, show and hide the modal, etc.
import Ember from 'ember';
const {computed} = Ember;
export default Ember.Object.extend({
id: null,
init() {
this.set('id', Math.random().toString(36).substr(2, 16));
},
elementId: computed('id', function () {
return `${this.get('id')}-modal`;
}),
isOpen: false,
controller: null,
setController(controller) {
this.set('controller', controller);
return this;
},
model: null,
setModel(model) {
this.set('model', model);
if (this.get('controller')) {
this.get('controller').set('model', model);
}
return this;
},
title: null,
setTitle(title) {
this.set('title', title);
return this;
},
bodyTemplate: null,
setBodyTemplate(template) {
this.set('bodyTemplate', template);
return this;
},
show(context) {
this.set('isOpen', true);
context.render(this.get('bodyTemplate'), {
into: 'application',
outlet: this.get('id'),
controller: this.get('controller')
});
return this;
},
hide() {
this.set('isOpen', false);
return this;
},
onClose: null,
setOnCloseCallback(callback) {
this.set('onClose', callback);
return this;
},
setOnSubmitCallback(callback) {
this.set('onSubmit', callback);
return this;
},
onHidden: null,
onShown: null,
onSubmit: null,
backdropClose: true,
disableCloseOnOutsideClick(canClose = false) {
this.set('backdropClose', canClose);
return this;
},
isWide: false,
makeWide(isWide = true) {
this.set('isWide', isWide);
return this;
},
headerTemplate: null,
setHeaderTemplate(partial) {
this.set('headerTemplate', partial);
return this;
},
cancelLabel: 'Cancel',
submitLabel: 'Submit',
showCancelButton: false,
showSubmitButton: false,
setCancelLabel(label) {
this.set('cancelLabel', label);
this.displayCancelButton();
return this;
},
setSubmitLabel(label) {
this.set('submitLabel', label);
this.displaySubmitButton();
return this;
},
displayCancelButton(show = true) {
this.set('showCancelButton', show);
return this;
},
displaySubmitButton(show = true) {
this.set('showSubmitButton', show);
if (!this.get('onSubmit')) {
console.error
("Modal generated to display submit button, but no 'onSubmit' callback was set.");
}
return this;
},
showFooter: computed('showCancelButton', 'showSubmitButton', function () {
return this.get('showCancelButton')
|| this.get('showSubmitButton');
})
})
Now, to keep track of active modals across the application, create an ember service
called ModalsService
.
This service will also abstract the boiler plate aspect of modal creation; setting the body template.
import Ember from 'ember';
import Modal from '../utils/modal';
export default Ember.Service.extend({
activeModals: new Ember.A(),
bodyElement: null,
buildModal(bodyTemplate, options = {}) {
let modal = Modal.create();
if (!this.get('bodyElement')) {
Ember.run.later(() => {
this.set('bodyElement', Ember.$("body"));
}, 100);
}
modal.setBodyTemplate(bodyTemplate);
modal.set('onHidden', (function () {
if (modal.get('onClose')) {
if (!modal.get('controller')) {
throw new Error("Cannot build modal: given onClose but no controller provided.");
}
modal.get('onClose')(modal.get('controller'));
}
this.removeModal(modal.get('id'));
if (this.get('activeModals.length') &&
!this.get('bodyElement').hasClass('modal-open')) {
Ember.run.later(() => {
this.get('bodyElement').addClass('modal-open')
}, 100);
}
}.bind(this)));
if (options.disableOutsideClick) {
modal.disableCloseOnOutsideClick()
}
if (options.wide) {
modal.makeWide();
}
if (options.onClose) {
modal.setOnCloseCallback(options.onClose);
}
if (options.onSubmit) {
modal.setOnSubmitCallback(options.onSubmit);
}
if (options.title) {
modal.setTitle(options.title);
}
if (options.showSubmitButton) {
modal.displaySubmitButton();
}
if (options.showCancelButton) {
modal.displayCancelButton();
}
if (options.submitLabel) {
modal.setSubmitLabel(options.submitLabel);
}
if (options.cancelLabel) {
modal.setCancelLabel(options.cancelLabel);
}
this.get('activeModals').pushObject(modal);
return modal;
},
removeModal(modalId) {
if (!modalId) {
return;
}
let modal = this.get('activeModals').findBy('id', modalId);
if (modal) {
this.get('activeModals').removeObject(modal);
modal.destroy();
}
return this;
},
showOnly(modal, context) {
modal.show(context);
Ember.run.later(() => {
this.closeAll(modal);
}, 100);
return this;
},
closeAll(exceptThisModal) {
this.get('activeModals').forEach((modal) => {
if (!exceptThisModal || modal.get('id') !== exceptThisModal.get('id')) {
modal.hide();
}
});
return this;
}
});
The array activeModals
is maintained by this service and can be accessed (observed or computed) by injecting routes. Before using the build modal method, let's set the activeModal
structure template in the ApplicationTemplate
(application.hbs).
{{!-- The following component displays Ember's default welcome message. --}}
{{welcome-page}}
{{!-- Feel free to remove this! --}}
{{outlet}}
{{#each activeModals as |activeModal|}}
{{#bs-modal
id=activeModal.id
open=activeModal.isOpen
onHidden=activeModal.onHidden
onSubmit=activeModal.onSubmit
footer=false
backdropClose=activeModal.backdropClose
class=(if activeModal.isWide 'modal-wide')
as |modal|
}}
{{#if activeModal.title}}
{{#modal.header}}
{{#if activeModal.headerTemplate}}
{{partial activeModal.headerTemplate}}
{{else}}
<h4 class="modal-title">{{activeModal.title}}</h4>
{{/if}}
{{/modal.header}}
{{/if}}
{{#modal.body}}
{{outlet activeModal.id}}
{{/modal.body}}
{{#if activeModal.showFooter}}
{{#modal.footer as |footer|}}
{{#if activeModal.showCancelButton}}
{{#bs-button onClick=(action modal.close)
type="default"}}{{activeModal.cancelLabel}}{{/bs-button}}
{{/if}}
{{#if activeModal.showSubmitButton}}
{{#bs-button onClick=(action modal.submit)
type=activeModal.submitButtonType}}{{activeModal.submitLabel}}{{/bs-button}}
{{/if}}
{{/modal.footer}}
{{/if}}
{{/bs-modal}}
{{/each}}
Now the good part, create a simple body template and an action to use the methods above to build and show the modal. I tend to create the body templates in a subfolder for the route that creates the modal, in this case, 'application/modal/login':
ember g template application/modal/login
- - application
- -- modal
- --- login.hbs
<label>Email</label>
{{input type='email'
value=email}}<span id="cke_bm_141E" style="display: none;"> </span>
<label>Password</label>
{{input type='password'
value=password}}
<button class='btn btn-success'>Login</button>
Within ApplicationRoute
, set an action that builds and displays this modal. First, ensure you have an ApplicationRoute
file; if not, run the following command and press 'N
' if it asks you to overwrite the template.
ember g route application
Inject ModalsService
before creating the action.
import Ember from 'ember';
const {inject: {serivce}} = Ember;
export default Ember.Route.extend({
modals: service(),
actions: {
openLoginModal() {
let loginModal = this.get('modals')
.buildModal('application/modal/login')
.setTitle('Login')
.setController(this.controllerFor('application'))
.show(this);
},
closeAllModals() {
this.get('modals').closeAll();
}
}
});
And that's it! Simply trigger the openLoginModal
action and you'll have the modal up! You can inject ModalService
into any route.
Points of Interest
Careful when setting the controller and model on the modal; if you are using the controller for another route or template, setting a different model will inactivate that route/template and therefore cause unexpected behaviour.