Making a Smarter TabContainer

June 25, 2010 by Ken · 4 Comments 

I’m sure this one has been asked numerous times, but it came up most recently on IRC from darkschneider (with an additional nod from ttrenka). The dijit.layout.TabContainer widget is tremendously useful, but it has a noticeable shortcoming that, while minor and innocuous, can still be irritating: if you’ve navigated between tabs and then close the current one, it always sends you back to the first tab in the container, rather than the previously active tab.

This is something I had noticed while working with Dojo on a mockup at my job over a year ago, and I developed a solution to it then. Now that I’ve got this blog here, I might as well share it for others’ benefit.

UPDATED on 6/28 to bring the example more in line with how TabContainer works in Dojo 1.4+

RetentiveTabContainer

In Action

Here’s an example to play around with, contrasting the two implementations. First is the original dijit.layout.TabContainer, second is the extension, kgf.dijit.layout.RetentiveTabContainer.

To give this a run for its money, click various tabs, remembering the order you clicked them, then close the active tab; kgf.dijit.layout.RetentiveTabContainer will always return you to the previously active tab. Closing an inactive tab will also remove it from the history stack (but won’t change the active tab), so you can’t trick it into attempting to visit a tab that no longer exists.

The Code

If the example sold you and you’d like to make use of the extension, here’s the code responsible. Two versions are presented here:

  • The first is the closest to the original code I implemented in 2009, which only works on Dojo 1.3. It’s presented here since it might be slightly more straightforward, and for those who might be interested in seeing an example of how APIs can change between Dojo versions, forcing reassessment of solutions.
  • The second is a revision I wrote on 6/28/10 geared towards working with Dojo 1.4, respecting its instantiation-time resolution of controller widget class. It still works with Dojo 1.3 since it doesn’t disagree with any assumptions 1.3 makes, it’s just perhaps a tad less straightforward in execution.

The differences between the two versions are explained further below the code.

If you’ve already got a TabContainer/TabController extension of your own, it should still be relatively easy to add this in; all members are new except for postCreate in the TabController and controllerWidget in the TabContainer.

RetentiveTabContainer for Dojo 1.3

dojo.provide('kgf.dijit.layout.RetentiveTabContainer');

dojo.require('dijit.layout.TabContainer');

dojo.declare('kgf.dijit.layout.RetentiveTabController', dijit.layout.TabController,
{
    // summary:
    //      Extension of TabController augmented with memory of order of
    //      most-recently-accessed tabs.
    
    // _tabHistory: array
    //      Holds stack of most-recently-visited tabs
    _tabHistory: [],
    
    postCreate: function() {
        this.inherited(arguments); //do superclasses' postCreate
        //connect new behaviors to appropriate event placeholders
        this.connect(this, 'onRemoveChild', this._popHistory);
        this.connect(this, 'onSelectChild', this._addToHistory);
    },

    _removeFromHistory: function(pane) {
        // summary:
        //      If the given pane is in the history stack, remove it.
        //      Called by _addToHistory and _popHistory.
        
        var i;
        
        while ((i = dojo.indexOf(this._tabHistory, pane)) > -1) {
            this._tabHistory.splice(i, 1);
        }
    },
    
    _addToHistory: function(page) {
        // summary:
        //      Called when a tab is selected.
        //      Push the newly-selected pane to the top of the history stack.
        
        this._removeFromHistory(page); //remove from any old position first
        this._tabHistory[this._tabHistory.length] = page; //push
    },
    
    _popHistory: function(page) {
        // summary:
        //      Called when a tab is removed, to remove it from history.
        
        var hist = this._tabHistory, len = hist.length;
        
        if (len < 1) {
            //nothing to do if no history!
            return;
        }
        
        //Check if the removed pane was the active tab (top of stack).
        if (page == hist[len - 1]) {
            //pop removed pane from stack (faster than search+splice)
            hist.pop();
            
            if (--len > 0) {
                //restore focus to tab which was previously active (now top of stack)
                dijit.byId(this.containerId).selectChild(hist[len - 1]);
            }
        } else {
            //remove the removed child from the history stack, wherever it was.
            this._removeFromHistory(page);
        }
    }
});

dojo.declare('kgf.dijit.layout.RetentiveTabContainer', dijit.layout.TabContainer,
{
    // summary:
    //      Extension of TabContainer using RetentiveTabController as controller
    
    _controllerWidget: 'kgf.dijit.layout.RetentiveTabController'
});

RetentiveTabContainer for Dojo 1.4

dojo.provide('kgf.dijit.layout.RetentiveTabContainer');

dojo.require('dijit.layout.TabContainer');

dojo.declare('kgf.dijit.layout.RetentiveTabContainer', dijit.layout.TabContainer,
{
    // summary:
    //      Extension of TabContainer augmented with memory of order of
    //      most-recently-accessed tabs.
    
    // _tabHistory: array
    //      Holds stack of most-recently-visited tabs
    _tabHistory: null,
    
    constructor: function(args) {
        this._tabHistory = [];
    },
    
    postCreate: function() {
        this.inherited(arguments);
        
        //hook functionality to controller selected/instantiated by superclasses
        var tl = this.tablist;
        this.connect(tl, 'onRemoveChild', '_popHistory');
        this.connect(tl, 'onSelectChild', '_addToHistory');
    },
    
    _removeFromHistory: function(pane) {
        // summary:
        //      If the given pane is in the history stack, remove it.
        //      Called by _addToHistory and _popHistory.
        
        var i;
        
        while ((i = dojo.indexOf(this._tabHistory, pane)) > -1) {
            this._tabHistory.splice(i, 1);
        }
    },
    
    _addToHistory: function(page) {
        // summary:
        //      Called when a tab is selected.
        //      Push the newly-selected pane to the top of the history stack.
        
        this._removeFromHistory(page); //remove from any old position first
        this._tabHistory[this._tabHistory.length] = page; //push
    },
    
    _popHistory: function(page) {
        // summary:
        //      Called when a tab is removed, to remove it from history.
        
        var hist = this._tabHistory, len = hist.length;
        
        if (len < 1) {
            //nothing to do if no history!
            return;
        }
        
        //Check if the removed pane was the active tab (top of stack).
        if (page == hist[len - 1]) {
            //pop removed pane from stack (faster than search+splice)
            hist.pop();
            
            if (--len > 0) {
                //restore focus to tab which was previously active (now top of stack)
                this.selectChild(hist[len - 1]);
            }
        } else {
            //remove the removed child from the history stack, wherever it was.
            this._removeFromHistory(page);
        }
    }
});

The Differences

If you’ve looked at both the 1.3 and 1.4 versions, you’ll notice that the internals of the controller have remained intact; it’s how that controller is put into action that has changed.

The reason for this change has to do with changes that happened in Dojo 1.4: in this version, dijit.layout.TabContainer got an awesome new feature allowing it to scroll its tabs (when they’re rendered along the top/bottom) and provide a menu listing all tabs, much akin to Firefox. However, since this is only available in situations with horizontally-arranged tabs, the controller’s class name is no longer statically defined at the prototype level, but rather procedurally within each instance’s invocation of postCreate. My original stab at this completely ignored that change (much due to the fact that I first developed it on 1.3), and thus ended up completely losing the scrolling tab bar functionality.

When faced with this discovery of my own stupidity, I could immediately think of a couple of options:

  • I could declare subclasses of each of the TabController classes, and override postCreate by either duplicating the existing logic or performing some string patching based on its present value. This approach seems not only hackish, but also horrendously brittle – the moment anything relevant changes in the base classes I’m extending, I’m probably toast again.
  • I could simply mix my functionality into each instance, since it really only adds to the implementation without overriding anything existing. The drawback to this is I have to do it individually on every instance of TabContainer, but the code should be far more maintainable, and I’m not mixing in a large number of properties.
  • I could define/connect everything within the subclass of dijit.layout.TabContainer itself, avoiding both brittle code and the necessity of per-instance mixin execution, though perhaps this solution doesn’t immediately look quite as intuitive, since I’m really patching methods of the controller (and indeed it took a while for this alternative to occur to me).
  • I could freak out and kill people; it’s what ninjas do.

If you read the 1.4 version of the code above, you can probably tell I went with the third option (no one was harmed in the writing of this update).

Rather than add functionality to the controller and simply change the value of _controllerWidget, I now simply define everything directly in the subclass of dijit.layout.TabContainer. This may seem a tad unintuitive, especially in postCreate where I’m essentially connecting events of the controller to methods of the container; however, it actually affords me 2 things:

  • In postCreate, I can connect to methods of this class directly, without having to mix the functionality in on a per-instance basis.
  • In _popHistory, I no longer have to kludge a by-id lookup of the container widget from its controller, since this now refers directly to it.

So there you have it; dedicated solutions for both Dojo 1.3 and 1.4. I’ve certainly learned a thing or two from this little mishap on my part; hopefully some of you can as well.

Further Reading

Comments

4 Responses to “Making a Smarter TabContainer”
  1. cbarrett1 says:

    Very nice KGF. Another question is how to disable a tab from being able to be clicked on. That seems simple enough, but I don’t know if I saw a resolution to that question.

    • Ken says:

      I just took 15 minutes to look into your question. Perhaps I’m missing something more trivial, but the answer appears to be a tad buried, since there doesn’t seem to be any inherent support for honoring the disabled attribute in StackController buttons (which TabController tabs inherit from). Perhaps I should make a separate post about what solution I found…

      Meanwhile, this digging has led me to realize that my initial solution to TabContainer “memory” is actually still somewhat dated – it’d work just fine for 1.3 (which of course is what I initially developed it on, just change controllerWidget back to _controllerWidget), but 1.4 actually delays the controllerWidget decision until postMixinProperties, since top/bottom-aligned tabs support the new ScrollingTabController while while left/right aligned tabs do not. My original sample above blindly assumed TabController, i.e. you lose the scrolling, which is not cool!

      So thanks for your question since it made me realize my own stupidity :P

  2. Gabriele Dini Ciacci says:

    Great, you know this is golden code that is working out of the box. Thanks so much.
    darkschneider

  3. Shawn Rebelo says:

    Thank you for this insight. Helped me a lot in controlling tab history.

Speak Up