Tabbed interface without router, or with?


#1

I’m working on a new project that requires part of the interface be a tabbed design. There would be links on the left hand side, which when clicked create a new tab on the right hand side. The content of these tabs needs to not change when switching, and ideally be able to update in the background.

So far I have it pretty much doing what I want by using action instead of link-to helpers, and dynamically rendering the template with its view and controller - but I’m not sure if thats the right way to go.

I guess if I stick to that option, then I will need to manually destroy the view and controller when the tab closes (not a big deal), but I’m not sure if I would need to destroy anything else?

The other option I thought of, was to maybe still use routes, but somehow have them render into dynamic outlets somehow? I’m not really sure on that one.

I guess my main issue is: Does this sound like the right way to go about it, or am I missing something obvious that would work better? I just feel that I’m missing out by not using the routing, but then having the URL change doesn’t really make sense for tabs.


#2

So it turns out destroying the rendered template is harder than I had first thought - I don’t seem to be able to find a reference to it :frowning:


#3

Personally I would leave view management to Ember.js and do something like this:

<!-- view with tabs -->
<ul class="tabs-list">
  {{#each tab in tabs}}
    <li>{{tab.name}}</li>
  {{/each}}
</ul>

{{#each tab in tabs}}
  <div class="tab-content" {{bindAttr class="tab.active:active"}}>
    {{render tab.id tab.context}}
  </div>
{{/each}}

With a list of tabs like:

[{ name: 'First Tab', id: 'firstTab', active: true, context: object1 },
{ name: 'Second Tab', id: 'secondTab', context: object2 }]

Both tab contents will be rendered and only one of them will have an active class. When you remove a tab from the list, it should also be removed from the view. Additionally, because the visibility is regulated by a class name, the view and controller created for each tab will be there even if it’s not visible.

I used {{render}} here, but what you want to use depends on what is the nature of the views you want to render as a tabs content. If all of the tabs have unique views, you can skip passing context. However if there can be multiple tabs created out of one view, and you don’t have a context, you will have to use {{control}} helper.

Although I think that what you describe is something like a list of files on the left, and a tabbed file view on the right, so render + context should work fine.


#4

This is pretty much what I’ve ended up going with now - I think I was just confusing myself earlier on! One issue I do still have, is that when the tab is closed and reopened, I’d like the controller to be reset.

I though that maybe using the {{control}} helper as you suggested would solve that, but it seems to always use the same instance, even if I call controller.destroy() when closing the tab.


#5

Never mind, it does work, but control needs to have a unique controlID passed each time!


#6

tab view component similar to the one in bootstrap

http://jsbin.com/IyoraRA/3/

<script type="text/x-handlebars" id="components/tab-view">
  <ul class="nav-tabs">
    {{#each pane in panes}}
      {{#nav-tab paneId=pane.paneId}} {{pane.name}} {{/nav-tab}}
    {{/each}}
  </ul>

  <div class="tab-content">
    {{yield}}
  </div>
</script>

App.NavTabComponent = Ember.Component.extend({
  tagName: 'li',

  classNames: ['nav-tab'],
  
  classNameBindings: ['isActive:active'],

  isActive: function() {
    return this.get('paneId') === this.get('parentView.activePaneId');
  }.property('paneId', 'parentView.activePaneId'),

  click: function() {
    this.get('parentView').setActivePane(this.get('paneId'));
  }

});

App.TabPaneComponent = Ember.Component.extend({
  classNames: ['tab-pane'],
  
  classNameBindings: ['isActive:active'],

  isActive: function() {
    return this.get('elementId') === this.get('parentView.activePaneId');
  }.property('elementId', 'parentView.activePaneId'),

  didInsertElement: function() {
    this.get('parentView.panes').pushObject({paneId: this.get('elementId'), name: this.get('name')});
    
    if (this.get('parentView.activePaneId') === null) {
      this.get('parentView').setActivePane(this.get('elementId'));
    }
  }

});

App.TabViewComponent = Ember.Component.extend({
  classNames: ['tab-view'],

  activePaneId: null,

  didInsertElement: function() {
    this.set('panes', []);
  },

  setActivePane: function(paneId) {
    if (this.get('activePaneId') !== null) {
      if (paneId !== this.get('activePaneId')) {
        this.set('activePaneId', paneId);
      }      
    } else {
      this.set('activePaneId', paneId);
    }
  }

});

Using Tab View

{{#tab-view}}
  {{#tab-pane name="Tab A"}}
    Tab A Content
  {{/tab-pane}}

  {{#tab-pane name="Tab B"}}
    Tab A Content
  {{/tab-pane}}
{{/tab-view}}

#7

Amajdee,

I am trying to implement your tab-view component set, but it’s not going as smoothly as I’d hoped. I think I have everything copied over and into the right place (it’s a large app, so our directory structure is quite complex). The only noticeable difference I can see is that we are using Ember 1.5.0-beta4, where your working example is 1.3.1. Updating the jsbin to use this version does not cause any problem, so I’m not sure where my implementation (to behonest, copy/paste job) is falling short.

I’m struggling with a few things conceptually.

  1. How does the tab-view panes array get populated? I don’t see anything linking the {{#tab-pane}} component block to the panes array.
  2. How does the activePaneId get set initially? I see the TabPaneComponent and NavTabComponent observing the parent’s activePaneId, but the only thing I see setting that is the initialization to null.
  3. How do the nav-tab and tab-pane components get away without templates of their own?

I feel like if I knew these things, I could finish debugging my problems myself. I can post a detail of what markup is being generated, if you like, to see if that would help pinpoint anything.

Many thanks for the excellent work.

Update: turns out my app was breaking due to a conflict between sub templates I was trying to render inside the tab-pane content blocks. It’s now working for me, but I would really like to understand the questions I asked above. Thanks again.


#8

Hi amajdee!!! your example was very helpful, to get me on the right track. But I stumbled on a problem when I want the tabs name to come from a belongsTo association.

{{#tab-view}}
  {{#each practicePart in practiceParts}}
    {{#tab-pane name=practicePart.part.symbol}}
      Content for tab...
    {{/tab-pane}}
  {{/each}}
{{/tab-view}}

practicePart.part.symbol returns undefined so the tab name shows up empty. The strange thing is that I can do practicePart.part.id and it shows the id, so my guess is that for some reason the attributes are not loaded.

I hope you can help me!

UPDATE: If I iterate over the hasMany on the template I can render the title fine

{{#each practicePart in practiceParts}}
  <p>{{practicePart.part.symbol}}</p>
{{/each}}

So this problem only appears when trying to access the association from the component.


#12

I’m still asking myself whether this is the right way to implement it. I currently have something far less optimal using bootstrap and I’m in the need of a rewrite.

While the above structure is a working way, it won’t update the url. And hence you can’t really bookmark the state of the app. Is anybody implementing these kind of tabs with a router?


#13

Did you all figure out a better/alternative way to accomplish this?

Following the example from @amajdee I get:

Uncaught Error: Assertion Failed: `blockHelperMissing` was invoked without a helper name, which is most likely due to a mismatch between the version of Ember.js you're running now and the one used to precompile your templates. Please make sure the version of `ember-handlebars-compiler` you're using is up to date.

#14

Quick note Amajdee’s solution will not work out the box for ember 1.8 and later because of the following change:

“didInsertElement is now always called on a child view before it is called on a rendering parent view. In previous releases of Ember.js didInsertElement would often be called first on a parent view, however this behavior was inconsistent. In general, developers are encouraged to consider scheduling work into the afterRender queue if it includes accessing DOM not immediately under that view’s control.”

Basically I changed didInsertElement to afterRender in the tab-view component and it worked fine for me in version 1.9.1


#15

here’s an updated version

App.TabViewComponent = Ember.Component.extend({
  
  classNames: 'tab-view',

  _defaultPane: Ember.computed('defaultPane', function() {
    return this.get('defaultPane') ? this.get('defaultPane') : 0;
  }),

  _activePane: Ember.computed('_panes.@each.active', function() {
    return this.get('_panes').filterBy('active', true)[0];
  }),

  _resetPanes: function() {
    this.set('_panes', []);
  }.on('init'),

  _populatePanes: function() {
    var _this = this;

    this.get('childViews').forEach(function(view) {
      _this.get('_panes').pushObject(view);
    });

    this.get('_panes')[this.get('_defaultPane')].set('active', true);
  }.on('didInsertElement'),

  _setActivePane: function(pane) {
    var activePane = this.get('_activePane');
    
    activePane.set('active', false);

    pane.set('active', true);
  },

  actions: {
    _goto: function(pane) {
      this._setActivePane(pane);
    }
  }

});


App.TabPaneComponent = Ember.Component.extend({
  
  classNames: 'tab-pane',
  
  classNameBindings: ['isActive:active'],
  
  isActive: Ember.computed('parentView._activePane', function() {
    return this === this.get('parentView._activePane');
  })
  
});


<script type="text/x-handlebars" id="components/tab-view">
  <ul class="nav-tabs">
    {{#each pane in _panes}}
      <li {{bind-attr class=":nav-tab pane.active:active"}} {{action '_goto' pane}}>
        <a>{{pane.title}}</a>
      </li>

    {{/each}}
  </ul>

  <div class="tab-content">
    {{yield}}
  </div>
</script>

<script type="text/x-handlebars">
  {{#tab-view defaultPane=0}}
    {{#tab-pane title="Tab A"}}
      Tab A Content
    {{/tab-pane}}
  
    {{#tab-pane title="Tab B"}}
      Tab B Content
    {{/tab-pane}}
  {{/tab-view}}
</script>

#16

With. Use queryParams and pass the reference to the tab to your “tabbed component”.

An example of that by @ryanflorence : http://instructure.github.io/ic-tabs/query-params.html#/?country=0&food=0