Recently, I had need of a popup in a Marionette application. No problem, jQuery UI has you covered for dialogs, right? Except they’re ugly and a bit of a pain to make not ugly, and don’t really fit into the Backbone/Marionette idiom. We didn’t necessarily want a dialog box, either.
A quick Google for “Marionette modal popup” yields a number of hits, but most of them rely on Bootstrap for its modal method, which we weren’t using. Sounds like a good reason to reinvent the wheel!
First, what did we need it to do?
- Show a popup with the option of being modal
- Let that popup be skinnable as desired, easily
- Be able to anchor it to an element in the DOM
- If non-modal, allow click-off to dismiss
With that in hand, we start by working with the fact that the popup will not be shown in a Marionette Region, so there’s no view which can show the popup as a child. That has a few implications:
- You won’t get onDomRefresh callbacks
- Normally you wouldn’t get onShow either but we can take care of that ourself
- We’ll have to do onBeforeAttach/onAttach ourself as well
- You will get onRender and onDestroy from Marionette as usual
We’ll use a typical create/show mechanic:
[code]
var MyPopupView = Marionette.APopupView.extend({…});
var myPopup = new MyPopupView();
myPopup.show();
[/code]
And the basic view declaration:
[code]
Marionette.APopupView = Marionette.ItemView.extend({
/*
Show and position the popup. Display modal if desired.
Add click handler for click-off if not modal
*/
show: function()
{
}
});
[/code]
Now to add the requirements:
1) Optionally modal. Easy enough in the ancient and venerable fashion:
[code]
if (this.modal)
{
$(document.body).append($(‘<div class="popup-overlay"/>’));
}
$(document.body).append(this.$el);
[/code]
And some CSS for the overlay
[code]
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #FFF;
opacity: 0.5;
filter: alpha(opacity=50);
}
[/code]
2) Easily skinnable. It’s templates all the way down. You define the template, you define the CSS. Add a mix of template-specific and popup-general classes on elements to make the UX designer’s life easier.
[code]
<script id="delete-confirm-popup-template" type="text/template">
<div class="delete-confirm-text popup-text">Delete this widget?</div>
<div class="delete-confirm-error-text popup-error-text" id="delete-confirm-error-text"></div>
<div class="delete-confirm-button-container popup-button-container">
<input type="button" id="delete-confirm-no-button" class="popup-button popup-no-button delete-confirm-no-button" value="Cancel"/>
<input type="button" id="delete-confirm-yes-button" class="popup-button popup-yes-button delete-confirm-yes-button" value="Delete"/>
</script>
</script>
[/code]
And some basic CSS for that
[code]
.popup {
background-color:#FFFFFF;
border:1px solid #999999;
display:none;
position:absolute;
z-index:50;
text-align: center;
padding: 25px;
}
.popup-error-text {
height: 20px;
line-height: 20px;
font-size: 12px;
color: red;
text-align: center;
}
[/code]
3) Anchorable. Happily, jQuery gives us .position which makes life good. We can declare our subclasses using the jQuery position idiom:
[code]
var MyPopup = Marionette.APopupView.extend({
…
anchor: document,
my: ‘center center’,
at: ‘center center’,
…
});
[/code]
And then some code in the popup show method (which you can see at the end).
4) Click outside to dismiss.
Not only do we want to dismiss the (non-modal) popup if the user clicks outside of the popup area, we also don’t want that click to interact with anything on the page. We just want to intercept all of the clicks, destroy the popup and not propagate the event. One event listener, coming up!
[code]
click: function(evnt)
{
if (!this.$el.is(evnt.target) && this.$el.has(evnt.target).length === 0)
{
this.destroy();
evnt.stopPropagation();
return false;
}
}
[/code]
Meanwhile, in the show method we need to hook that up. When the popup is destroyed, it’s a good idea to get rid of that listener.
Pulling it all together:
[code]
Marionette.APopupView = Marionette.ItemView.extend({
/*
Show and position the popup. Display modal if desired.
Add click handler for click-off if not modal
*/
show: function()
{
this.render();
this.$el.addClass(‘popup’);
// onBeforeAttach, if defined
if (_.isFunction(this.onBeforeAttach))
{
this.onBeforeAttach(this);
}
if (this.modal)
{
$(document.body).append($(‘<div class="popup-overlay"/>’));
}
$(document.body).append(this.$el);
// onAttach, if defined
if (_.isFunction(this.onAttach))
{
this.onAttach(this);
}
// Position the popup, default to center center @ center center
// if anchor is document, otherwise center top @ center bottom
// if an element is given
if (!this.my)
{
this.my = this.anchor === document ? ‘center center’ : ‘center top’;
}
if (!this.at)
{
this.at = this.anchor === document ? ‘center center’ : ‘center bottom’;
}
this.$el.position({
of: $(this.anchor),
my: this.my,
at: this.at,
collision: this.collision || ‘flip flip’
});
// We’ll do show/onShow ourself
this.$el.show();
this._isShown = true;
if (_.isFunction(this.onShow))
{
this.onShow(this);
}
if (!this.modal)
{
// While open, we don’t want clicks on elements under the popup to
// do anything, just destroy the popup
this.boundClick = _.bind(this.click, this);
document.addEventListener(‘click’, this.boundClick, true);
}
},
/*
When we are destroyed by any means, make sure to clean up our listener
and any popup overlay that may exist
*/
onDestroy: function()
{
$(‘.popup-overlay’).remove();
document.removeEventListener(‘click’, this.boundClick, true);
},
/*
Click handler so that on a mouseclick outside of the popup
we destroy the popup (which removes it from the DOM and cleans it up),
Args:
evnt: The click event
*/
click: function(evnt)
{
if (!this.$el.is(evnt.target) && this.$el.has(evnt.target).length === 0)
{
this.destroy();
evnt.stopPropagation();
return false;
}
}
});
[/code]
And the barebones CSS before the UX designer starts
[code]
.popup {
background-color:#FFFFFF;
border:1px solid #999999;
display:none;
position:absolute;
z-index:50;
text-align: center;
padding: 25px;
}
.popup-error-text {
height: 20px;
line-height: 20px;
font-size: 12px;
color: red;
text-align: center;
}
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #FFF;
opacity: 0.5;
filter: alpha(opacity=50);
}
[/code]
Finally, an example of a subclass (that uses the previously defined delete widget template). Note how .delete-confirm-no-button simply invokes the destroy method, and our onDestroy will of course take care of cleaning up the listener and removing any modal overlay.
[code]
module.ADeleteWidgetConfirmPopup = Marionette.APopupView.extend({
template: ‘#delete-confirm-popup-template’,
className: ‘delete-confirm-popup’,
anchor: ‘#widget-delete-button’,
my: ‘right top’,
at: ‘right bottom’,
events: {
‘click .delete-confirm-yes-button’: ‘deleteWidget’,
‘click .delete-confirm-no-button’: ‘destroy’
},
/*
Do the thing
*/
deleteWidget: function()
{
module.widgetView.model.destroy({
success: _.bind(this.success, this),
error: _.bind(this.error, this)
});
},
/*
Success – destroy the popup, refresh the widget list, destroy the view
of the widget we just deleted
*/
success: function(model, response)
{
this.destroy();
module.refreshWidgetList();
module.widgetView.destroy();
},
/*
Failed – show the returned error message, leave the popup
*/
error: function(model, response)
{
$(‘#delete-confirm-error-text’).text(response.responseJSON.detail);
}
});
[/code]
And that’s it! For our purposes this does the trick. Of course, there are still places to go with this to extend and improve. For example, you could make the click-off functionality optional. Maybe auto-prepend a title bar with close button if a ‘title’ attribute is defined in the subclass. If you need, you could fake out onDomRefresh after showing the element.
(photo by https://www.flickr.com/photos/tammets/)