A knockoutjs binding for twitter bootstraps popover

A custom knockoutjs binding for twitter bootstraps popover. This provides declaring popover using knockoutjs, assigning custom html, ensuring only one popover is open at any one time, and closing the popover from a button or link within the popover.
February 07 2013

I’ve just added twitter bootstraps’ popover to my project, using it to confirm delete’s of items bound using knockoutjs.  I wanted a way to easily setup popovers in a knockoutjs world and to provide some level of customisation using knockout.

  • Declare a popover using knockoutjs – declaratively
  • Completely customise the contents of the popover (html)
  • Close the popover from a button/link within the popover body or custom html.
  • Allow only one popover to be displayed at any one time

 

 

I definitely did not want to write a bunch of javascript each time I wanted a popover or have to call the following to configure popovers:

$('.mypopovers').popover(options);

Declaring a popover using knockoutjs

Declaring a popover with knockoutjs involves creating a knockout binding and from within that binding registering the DOM element as a popover

ko.bindingHandlers.popover = {
    init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {                
        var options = ko.utils.unwrapObservable(valueAccessor());
        var defaultOptions = { };

        options = $.extend(true, {}, defaultOptions, options);

        $(element).popover(options);
    }
};

This will at it’s most simplest, create the popover.  You can either set properties using the options object, or using data attributes.

<a data-bind="popover: { title: 'My Title' }"></a>                                

The above would create an empty popover with title “My Title”.  Lets get a bit more complex though, and allow setting of the contents of the popover to any HTML.

 

Completely customise the contents of the popover (html)

There were two routes I could have taken here, either set the HTML to display in the view model, or define the HTML in the view and pass in a container id that the HTML will be extracted from.  I chose the later.

<div id=”popover-html” style="display:none">                                    
    <div style="width:140px; margin:auto">                                        
        <button class="btn btn-primary" data-bind="click: deleteTemplate">Yes</button>
        <button class="btn" style="margin-left: 10px">No</button>
    </div>
</div>
                                                                
<a class="pull-right" data-bind="popover: { contentHtmlId: “popover-html”, title: 'Delete Template', html: true }"><i class="icon-remove"></i></a>

I’ve defined my own knockout binding property contentHtmlId. This binding is used to know where to extract the html from that will be inserted into my popover. I also need to set html to true.

Some logic is needed within the binding to extract the html and insert it into the popover. The part I stumbled with was setting the knockout data binding once the html was created. Because the popover is created as a part of calling ko.applyBindings for the view, inserting HTML with declared bindings into the popup will not bind those bindings; it must be done manually.

ko.bindingHandlers.popover = {
    init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {                
        var options = ko.utils.unwrapObservable(valueAccessor());
        var defaultOptions = {  };

        options = $.extend(true, {}, defaultOptions, options);

        var htmlContent = '';
        var containerId; 
        if (options.contentHtmlId) {
            
            containerId = 'popoverHtml-' + options.contentHtmlId;
            htmlContent = "<div id='" + containerId + "'>" + $("#" + options.contentHtmlId).html() + "</div>";
            options.content = htmlContent;
        }
      
        $(element).popover(options);
                
        ko.utils.registerEventHandler(element, "click", function () {
            
            if (options.contentHtmlId) {
                var thePopover = document.getElementById(containerId);

                if (thePopover)
                    ko.applyBindings(viewModel, thePopover);
            }
        });
    }
};

If a contentHtmlId is supplied the binding retrieves the html within that element and assigns it to the popovers’ content option. html should also be enabled in the options, or the html will be escaped. Next it needs to set the bindings on the newly inserted HTML, just in-case there were any bindings declared. This is done in the click event of the element. You may notice in the HTML definition that I’ve declared data-bind=”click: deleteTemplate”. This is what needs to be bound the above code. I ran into a problem where I was originally calling data-bind=”click: $parent.deleteTemplate” because the parent of the item I was deleting (a Template) is responsible for deleting the Template and removing the deleted Template from it’s collection of Templates. However, viewModel passed into my init function is only a viewModel of the Template, there is no way a $parent binding will work. To get around this limitation I had to add a deleteTemplate function to my Template and a reference to its parent. I then called parent.deleteTemplate(self) from within the Template view model.

Close the popover from a button/link within the popover body or custom html

Next I needed to be able to close the popover from the “No” button within the popover.  To do that I needed some way to identify the button that would act as the close and then hook up the close event.

Declare the close button:

<button class="btn" data-popoverclose="true" style="margin-left: 10px">No</button>

Update my custom binding to find the "close" button and attach an event that will call hide on the popover:

ko.bindingHandlers.popover = {
    init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {                
        var options = ko.utils.unwrapObservable(valueAccessor());

        options = $.extend(true, {}, defaultOptions, options);

        var htmlContent = '';
        var containerId; 
        if (options.contentHtmlId) {
            
            containerId = 'popoverHtml-' + options.contentHtmlId;
            htmlContent = "<div id='" + containerId + "'>" + $("#" + options.contentHtmlId).html() + "</div>";
            options.content = htmlContent;
        }
       
        $(element).popover(options);
                
        ko.utils.registerEventHandler(element, "click", function () {
            
            if (options.contentHtmlId) {
                var thePopover = document.getElementById(containerId);

                if (thePopover)
                    ko.applyBindings(viewModel, thePopover);
            }

            $('button[data-popoverclose]').click(function() {
                $(element).popover('hide');
            });
        });
    }
};

Nearly there but I also only wanted one popover open at any one time. I didn't want to close all but one popover for every possible popover, just the ones that needed to be open only by themselves. To do that I attach a click event to the body that scans for all "exclusive" popovers and closes them.

ko.bindingHandlers.popover = {
    init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {                
        var options = ko.utils.unwrapObservable(valueAccessor());
        var defaultOptions = { exclusive: true };

        options = $.extend(true, {}, defaultOptions, options);

        var htmlContent = '';
        var containerId; 
        if (options.contentHtmlId) {
            
            containerId = 'popoverHtml-' + options.contentHtmlId;
            htmlContent = "<div id='" + containerId + "'>" + $("#" + options.contentHtmlId).html() + "</div>";
            options.content = htmlContent;
        }

        if (options.exclusive) {
            $(element).attr("exclusive", "");
            
            // Setup event to close any open 'exclusive' popovers.
            var $visiblePopover;
            $('body').on('click', '[exclusive]', function() {                
                var $this = $(this);

                if ($this.data("popover").tip().hasClass('in')) {
                    // if another was showing hide it
                    $visiblePopover && $visiblePopover.popover('hide');

                    $visiblePopover = $this;
                } else {
                    $visiblePopover = '';
                }
            });
        }

        $(element).popover(options);
                
        ko.utils.registerEventHandler(element, "click", function () {
            
            if (options.contentHtmlId) {
                var thePopover = document.getElementById(containerId);

                if (thePopover)
                    ko.applyBindings(viewModel, thePopover);
            }

            $('button[data-popoverclose]').click(function() {
                $(element).popover('hide');
            });
        });
    }
};

By default I'm assuming that all popovers are exclusive, as set in the defaultOptions. Then, if exclusive is turned on for this element I add an exclusive attribute which will be used by the body click event to select the appropriate popovers to hide.

All Done!

There are a few improvements that could and should be made. One such is only creating the body click event once.

Update

There was a bug in my initial implementation above. That was, when you close a popover via the No button then try to open that popover again (without a page refresh) the popover does not open.

To get around that problem I upated my click handler on the popover trigger.  Rather than relying on a body event I now close all the other popovers in the click to open event of a popover. The new block of code now looks like this (and works!)

ko.utils.registerEventHandler(element, "click", function () {
                        
    $('*').filter(function() {
        if ($(this).data('popover') !== undefined) {         
            return !$(this).is($(element));                    
        }
        return false;
    }).popover('hide');
            
            
    if (options.contentHtmlId) {
        var thePopover = document.getElementById(containerId);

        if (thePopover) {
            ko.applyBindings(viewModel, thePopover);                    
        }
    }

            
    $('button[data-popoverclose]').click(function() {
        $(element).popover('hide');
    });
});

Post a comment

comments powered by Disqus