5

Are there any Angular JS Tabs directives that allow to reorder them (like a browser's tabs)

If not a starting implementation would be great

Using angular-ui-bootstap

<tabset> 
    <tab ng-repeat="tab in vm.tabs" active="tab.active" sortable-tab> </tab> 
    <tab disabled="true" ng-click"vm.addNewTab()" class="nonSortable-addTab-plusButton"></tab> 
</tabset>

How to make them reorderable?

EDIT: Bounty added to use original tabset syntax above.

parliament
  • 21,544
  • 38
  • 148
  • 238

4 Answers4

14

Using the Angular UI Bootstrap tabset, with just a sortable-tab directive:

<tabset>
  <tab sortable-tab ng-repeat="tab in tabs" heading="{{tab.title}}" active="tab.active" disabled="tab.disabled">
    <p>{{tab.content}}</p>
  </tab>
  <tab disabled="true">
    <tab-heading>
      <i class="glyphicon glyphicon-plus"></i>
    </tab-heading>
  </tab>
</tabset>

First of all it needed a bit of a trick/hack to integrate with ngRepeat, so it can then re-order the array. It (re)parses the ng-repeat attribute, and fetching the array from the scope, just like ngRepeat does

// Attempt to integrate with ngRepeat
// https://github.com/angular/angular.js/blob/master/src/ng/directive/ngRepeat.js#L211
var match = attrs.ngRepeat.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?\s*$/);
var tabs;
scope.$watch(match[2], function(newTabs) {
  tabs = newTabs;
});

You can then also watch the $index variable on the scope, to make sure you alway have the latest index of the current element:

var index = scope.$index;
scope.$watch('$index', function(newIndex) {
  index = newIndex;
});

And then use HTML5 drag and drop, passing the index of the element as its data via setData and getData

attrs.$set('draggable', true);

// Wrapped in $apply so Angular reacts to changes
var wrappedListeners = {
  // On item being dragged
  dragstart: function(e) {
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.dropEffect = 'move';
    e.dataTransfer.setData('application/json', index);
    element.addClass('dragging');
  },
  dragend: function(e) {
    e.stopPropagation();
    element.removeClass('dragging');
  },

  dragleave: function(e) {
    element.removeClass('hover');
  },
  drop: function(e) {
    e.preventDefault();
    e.stopPropagation();
    var sourceIndex = e.dataTransfer.getData('application/json');
    move(sourceIndex, index);
    element.removeClass('hover');
  }
};

// For performance purposes, do not
// call $apply for these
var unwrappedListeners = {
  dragover: function(e) {
    e.preventDefault();
    element.addClass('hover');
  },
  /* Use .hover instead of :hover. :hover doesn't play well with 
     moving DOM from under mouse when hovered */
  mouseenter: function() {
    element.addClass('hover');
  },
  mouseleave: function() {
    element.removeClass('hover');
  }
};

angular.forEach(wrappedListeners, function(listener, event) {
  element.on(event, wrap(listener));
});

angular.forEach(unwrappedListeners, function(listener, event) {
  element.on(event, listener);
});

function wrap(fn) {
  return function(e) {
    scope.$apply(function() {
      fn(e);
    });
  };
}

Note: there is a bit of a hack regarding using a hover class, instead of :hover for some of the hover effects. This is partially due to CSS :hover styles not being removed on elements after they were re-arranged from out of under the mouse, at least in Chrome.

The function to actually move the tabs, takes the array that ngRepeat uses, and re-orders it:

function move(fromIndex, toIndex) {
  // http://stackoverflow.com/a/7180095/1319998
  tabs.splice(toIndex, 0, tabs.splice(fromIndex, 1)[0]);
};

You can see all this in a Plunker

Michal Charemza
  • 25,940
  • 14
  • 98
  • 165
  • Thanks for the solution Im real glad to see it working in the plunkr. I'm having a slight problem though `e.dataTransfer` is undefined in the wrapped dragstart listener. When I compare the callstack in your plunkr to my own I see that in your case angular.js fires the event and in my callstack jquery.js fires the event. Any idea why? – parliament May 03 '14 at 17:58
  • 1
    If you're using jQuery, the events might be wrapped. You might have to use `e = e.originalEvent` in the listeners to get to the native event object. – Michal Charemza May 03 '14 at 18:01
  • The regex wasn't able to match correct for my ng-repeat: `ng-repeat="tab in vm.tabsCollection"` I think it tries to ensure the variables ends with "s" where "collection" doesn't. Further it's not directly on scope and scope['vm.tabsCollection'] doesn't work to access nested props so I just removed the hack and went with: `var tabs = scope['vm']['tabsCollection'] || scope['vm']['tabs'];` It's sufficient for my case to use this convention. – parliament May 03 '14 at 18:28
  • I don't think it was the "s", but the ".". I have edited the post + linked Plunker to work with any [expression](https://docs.angularjs.org/guide/expression). – Michal Charemza May 03 '14 at 18:40
  • Michal, drag and ddrop of tabs is working fine. Now, I have 50 tabs in my application, so along with drag and drop I need to support, pagination where at a time 5 tabs will be displayed and has, prev and next icons to navigate to and fro of the tabs. In addition when the tab is dragged to the edge of the visible tab set, the remaining tabs should be scrolled automatically so that that the dragged tab can be dropped anywhere else within the entire tabset. is it possible to modify the directive with this features? – smart987 Jan 22 '16 at 14:58
  • drag and drop is fine but the current tab remains to be the source one. – Sana Ahmed Dec 27 '17 at 06:44
  • Works well, I just wrote it with ES6 syntax and had to change event to originalEvent "e.originalEvent". Also after move function I broadcast the tab order and save it to the database $rootScope.$broadcast('tabOrderChanged', tabs); – Shnigi Nov 06 '18 at 08:40
  • If `originalEvent` _and_ `dataTransfer` is missing you can get the HTMLElement and attach the browser event with `element[0].addEventListener(event, wrap(listener));` – djthoms Dec 07 '18 at 18:40
2

If you don't want to use Angular UI, say for size reasons, you can roll a basic version of your own. Demo is at http://plnkr.co/edit/WnvZETQlxurhgcm1k6Hd?p=preview .

Data

You say you don't need the tabs to be dynamic, but it probably makes them a bit more reusable. So in the wrapping scope, you can have:

$scope.tabs = [{
  header: 'Tab A',
  content: 'Content of Tab A'
},{
  header: 'Tab B',
  content: 'Content of Tab B'
}, {
  header: 'Tab C',
  content: 'Content of Tab C'
}];

Tabs HTML

Designing the HTML structure, you can repeat over the list above for both the buttons and the content

<tabs>
  <tab-buttons>
    <tab-button ng-repeat="tab in tabs">{{tab.header}}</tab-button>
  </tab-buttons>
  <tab-contents>
    <tab-content ng-repeat="tab in tabs">{{tab.content}}</tab-body>
  </tab-contents>
</tabs>

Tab Directives

There are lots of ways of doing this, but one way is to register click handlers on the individual button directives, and then communicating them up to the parent tabs controller. This can be done using the require attribute, exposing a method on the parent controller, in this case show, and passing up the current index of the button by passing the variable $index that ngRepeat adds to the scope.

app.directive('tabs', function($timeout) {
  return {
    restrict: 'E',
    controller: function($element, $scope) {
      var self = this;

      this.show = function(index) {
        // Show only current tab
        var contents = $element.find('tab-content');
        contents.removeClass('current');
        angular.element(contents[index]).addClass('current');

        // Mark correct header as current
        var buttons = $element.find('tab-button');
        buttons.removeClass('current');
        angular.element(buttons[index]).addClass('current');
      };

      $timeout(function() {
        self.show('0');
      });
    }
  };
});

app.directive('tabButton', function() {
  return {
    restrict: 'E',
    require: '^tabs',
    link: function(scope, element, attr, tabs) {
      element.on('click', function() {
        tabs.show(scope.$index);   
      });
    }
  };
});

Assuming you have the right CSS in the page, specifically the styles for the .current class, as at http://plnkr.co/edit/WnvZETQlxurhgcm1k6Hd?p=preview , at this point have a working set of tabs.

Sortable

Using HTML5 drag + drop API, you can have some basic drag + dropping without worrying about things like mouse position. The first thing to do would be to design the attributes that are needed to make it work. In this case a sortable attribute on a parent item that references the list, and a sortable-item attribute that contains a reference to the index of the current item.

<tabs sortable="tabs">
  <tab-buttons>
    <tab-button ng-repeat="tab in list" sortable-item="$index">{{tab.header}}</tab-button>
  </tab-buttons>
  <tab-contents>
    <tab-content ng-repeat="tab in list">{{tab.content}}</tab-body>
  </tab-contents>
</tabs>

The sortable and sortableItem directives can be as below (and more details can be found at http://www.html5rocks.com/en/tutorials/dnd/basics/ )

app.directive('sortable', function() {
  return {
    controller: function($scope, $attrs) {
      var listModel = null;
      $scope.$watch($attrs.sortable, function(sortable) {
        listModel = sortable;
      });
      this.move = function(fromIndex, toIndex) {
        // http://stackoverflow.com/a/7180095/1319998
        listModel.splice(toIndex, 0, listModel.splice(fromIndex, 1)[0]);
      };
    }
  };
});

app.directive('sortableItem', function($window) {
  return {
    require: '^sortable',
    link: function(scope, element, attrs, sortableController) {
      var index = null;
      scope.$watch(attrs.sortableItem, function(newIndex) {
        index = newIndex;
      });

      attrs.$set('draggable', true);

      // Wrapped in $apply so Angular reacts to changes
      var wrappedListeners = {
        // On item being dragged
        dragstart: function(e) {
          e.dataTransfer.effectAllowed = 'move';
          e.dataTransfer.dropEffect = 'move';
          e.dataTransfer.setData('application/json', index);
          element.addClass('dragging');
        },
        dragend: function(e) {
          e.stopPropagation();
          element.removeClass('dragging');
        },

        // On item being dragged over / dropped onto
        dragenter: function(e) {
          element.addClass('hover');
        },
        dragleave: function(e) {
          element.removeClass('hover');
        },
        drop: function(e) {
          e.preventDefault();
          e.stopPropagation();
          element.removeClass('hover');
          var sourceIndex = e.dataTransfer.getData('application/json');
          sortableController.move(sourceIndex, index);
        }
      };

      // For performance purposes, do not
      // call $apply for these
      var unwrappedListeners = {
        dragover: function(e) {
          e.preventDefault();
        }
      };

      angular.forEach(wrappedListeners, function(listener, event) {
        element.on(event, wrap(listener));
      });

      angular.forEach(unwrappedListeners, function(listener, event) {
        element.on(event, listener);
      });

      function wrap(fn) {
        return function(e) {
          scope.$apply(function() {
            fn(e);
          });
        };
      }
    }
  };
});

The main points to note us that each sortableItem only needs to be aware of its current index. If it detects that another item has been dropped on it, it then calls a function on the sortable controller, which then re-orders the array on the outer scope. ngRepeat then does its usual thing and moves the tabs about.

Although I suspect there are simpler solutions, this one has the sortable behaviour and the tab behaviour completely decoupled. You can use sortable on elements that aren't tabs, and you can use tabs without the sortable behaviour.

Michal Charemza
  • 25,940
  • 14
  • 98
  • 165
  • Not a bad implementation but a little overkill. I tried switching out my existing angularui tabset and it just wrecked havoc on the UI. This would have to be used from the beginning in my case. I added a 100 bounty if you can think of a single directive to overlay on top of the angular-ui tabset – parliament Apr 27 '14 at 18:54
1

There's at least 2 ways to accomplish it.

1st. Go to http://angular-ui.github.io/bootstrap/ and download bootstrap tabs. Bootstrap UI is written in Angularjs and contains a lot of useful modules. Though you will have to implement some code yourself to dynamically add new tabs but this should be trivial. Just create a button/div with ng-click which calls a function that dynamically adds a new tab.

2nd. Implement it yourself with ng-repeat. Below is just some pseudo code to how it might look.

HTML:

<div class="tabs" ng-controller="TabController">
   <div class="add-tab" ng-click="add_tab()"></div>

   <div ng-repeat="tab in tabs" class="tab"></div>
</div>

Controller(JS):

app.controller('TabController',['$scope', function($scope){
$scope.tabs = [1, 1]
$scope.add_tab = function(){
$scope.tabs.push(1);
}
}]);

Concerning the sortable part. You can either create your own sortable(basically giving the tabs a draggable component, if you do this, you should write this as a directive), use jQuery, or use some Angularjs sortable/draggable which is pretty easy to find by searching.

Samir Alajmovic
  • 3,247
  • 3
  • 26
  • 28
  • Sorry for not clarifying but the sortable is the functionality that I'm not wrapping my head around thus focus of title. Addings tabs is easy and yes it will be bootstrap and a directive of course. I removed the dynamic adding of tabs from the question so there's no more confusion. Thanks – parliament Apr 04 '14 at 01:01
  • Makes sense. I assume you're not keen on using jQuery to accomplish it. I once wrote a native Angularjs draggable but scraped it because it's just easier, better looking, broader browser support, and better performance wise to use jQuery for it. Otherwise if you're looking to write your own, a good starting to place to do it angularjs native, http://docs.angularjs.org/guide/directive, they have a demo of a draggable there. You can use that and then implement some constraints on the moving object. – Samir Alajmovic Apr 04 '14 at 02:10
1

I made a Plunker about it. For doing it, I have used AngularJS with ui-sortable Angular directive from Angular-UI. I also used Bootstrap tabs to make it easier.

All that remained to do was connect all of that.

I hope this example could help you.

Noémi Salaün
  • 4,866
  • 2
  • 33
  • 37
  • Can you use original angular-ui `tabset` directive without dissecting into generated markup? Please see additional bounty – parliament Apr 27 '14 at 18:57