How to make other directives work inside uib-tab elements
Asked Answered
F

2

5

Is there a callback function for uib-tab directives I can use to refresh the inner directives after a tab has been rendered?

I am trying to find the source of an issue with a third-party directive that appears when I use that directive inside the uib-tab directive provided by angular-bootstrap. The third-party directive is angular-multi-slider and the issue was first reported in that repository.

A use case is available in plnkr. Click on the second tab, and you will see that the inner slider has all its handles one on top of the others (i.e., widths=0px). Then click on one of the handles and it will appear correctly. The issue persists even after following your recommendation regarding scopes in the FAQ.

Angular App

'use strict';

angular.module('multiSliderDemo', ['angularMultiSlider', 'ngAnimate', 'ui.bootstrap']);

angular.module('multiSliderDemo')
  .controller('DemoCtrl', function ($rootScope, $scope, $sce, $uibModal) {
    var s = [
                {value: 2, title:"Brainstorming", component: "Proposal Making", 
                   symbol: $sce.trustAsHtml("1")},
                {value: 50, title:"Working groups formation", component: "Proposal Making", 
                   symbol: $sce.trustAsHtml("2")},
                {value: 100, title:"Proposal drafting",component:"Proposal Making", 
                   symbol: $sce.trustAsHtml("3")},
                {value: 130, title:"Proposal editing", component: "Versioning", 
                   symbol: $sce.trustAsHtml("4")},
                {value: 160, title:"Proposal selection", component: "Versioning", 
                   symbol: $sce.trustAsHtml("5")},
                {value: 200, title:"Discussion of proposals", component: "Deliberation", 
                   symbol: $sce.trustAsHtml("6")},
                {value: 250, title:"Technical assessment", component: "Deliberation", 
                   symbol: $sce.trustAsHtml("7")},
                {value: 300, title:"Voting on proposals", component: "Voting", 
                   symbol: $sce.trustAsHtml("8")}
    ];

        $scope.app = {sliders:s}


  });

index.html

<html ng-app="multiSliderDemo">
<head>
  <meta charset="UTF-8">
  <title>Multi Slider</title>
  <link rel="stylesheet" 
        href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
  <link rel="stylesheet" href="multislider.css">
</head>
<body>
<div ng-controller="DemoCtrl" class="container">
  <article>
    <h2>Multi-Slider Issue with uib-tabs</h2>
    <form name="sliderForm" id="sliderForm" novalidate autocomplete="off">
      <fieldset class="row">
        <uib-tabset>
          <uib-tab heading="Tab 1" active="true">
            <multi-slider name="mySlider"
                        floor="0"
                        step="1"
                        precision="2"
                        ceiling="365"
                        bubbles="true"
                        ng-model="app.sliders">
              </multi-slider>
          </uib-tab>
          <uib-tab heading="Tab 2" active="false">
            <section class="col-sm-6 padding-10">
              <multi-slider name="mySlider"
                        floor="0"
                        step="1"
                        precision="2"
                        ceiling="365"
                        bubbles="true"
                        ng-model="app.sliders">
              </multi-slider>
            </section>
          </uib-tab>
        </uib-tabset>
      </fieldset>
    </form>
  </article>
</div>
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.4.7/angular.js"></script>
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.4.7/angular-animate.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular-ui-bootstrap/0.14.3/ui-bootstrap-tpls.min.js"></script>
<script src="multislider.js"></script>
<script src="script.js"></script>
</body>
</html>

CSS

.angular-multi-slider {
display: inline-block;
position: relative;
height: 5px;
width: 100%;
margin: 25px 5px 25px 5px;
vertical-align: middle;
}
.angular-multi-slider div {
    white-space: nowrap;
    position: absolute;
}
.angular-multi-slider div.bar {
    width: 100%;
    height: 100%;
    border-radius: 6px;
    background: #999;
    overflow: hidden;
}
.angular-multi-slider div.handle {
    cursor: pointer;
    width: 10px;
    height: 30px;
    top: -15px;
    background-color: #13b6ff; /*can override with color in slider object*/
    border: 2px solid #000;
    z-index: 2;
    border-radius: 4px;
    -o-transition: .3s;
    -ms-transition: .3s;
    -moz-transition: .3s;
    -webkit-transition: .3s;
    -webkit-transition-property: background-color;
    transition-property: background-color;
}
.angular-multi-slider div.handle:hover,
.angular-multi-slider div.handle:focus,
.angular-multi-slider div.handle:active,
.angular-multi-slider div.handle.active {
    -webkit-filter: brightness(70%);
    filter: brightness(70%);
}
.angular-multi-slider div.handle:hover + .bubble,
.angular-multi-slider div.handle:focus + .bubble,
.angular-multi-slider div.handle.grab + .bubble,
.angular-multi-slider div.handle:hover,
.angular-multi-slider div.handle:focus,
.angular-multi-slider div.handle.grab {
    -webkit-transform: scale(1.1);
    transform: scale(1.1);
    z-index: 9999;
}
.angular-multi-slider div.handle.grab + .bubble,
.angular-multi-slider div.handle.grab{
    background-color: rgba(0,0,0,1);
}
.angular-multi-slider div.bubble {
    display: none;
    cursor: default;
    top: -36px;
    padding: 1px 3px 1px 3px;
    font-size: 0.7em;
    font-family: sans-serif;
    -o-transition: .1s;
    -ms-transition: .1s;
    -moz-transition: .1s;
    -webkit-transition: .1s;
    -webkit-transition-property: top;
    transition-property: top;
}
.angular-multi-slider div.bubble:nth-child(2) {
    top: 34px !important;
    z-index:9999;
}
.angular-multi-slider div.bubble.active {
    display: inline-block;
    color: #fff;
    font-size:12px;
    font-family: 'Arial', sans-serif;
    text-align: center;
    background-color: rgba(0,0,0,0.75);
    border-radius: 8px;
    padding: 3px 8px;
}
.angular-multi-slider div.limit {
    margin-top: 12px;
    color: #000;
    font-weight: bold;
}

Multislider.js

 'use strict';

angular.module('angularMultiSlider', [])
.directive('multiSlider', function($compile, $timeout) {
  var events = {
    mouse: {
      start: 'mousedown',
      move: 'mousemove',
      end: 'mouseup'
    },
    touch: {
      start: 'touchstart',
      move: 'touchmove',
      end: 'touchend'
    }
  };

  function roundStep(value, precision, step, floor) {
    var remainder = (value - floor) % step;
    var steppedValue = remainder > (step / 2) ? 
                       value + step - remainder : value - remainder;
    var decimals = Math.pow(10, precision);
    var roundedValue = steppedValue * decimals / decimals;
    return parseFloat(roundedValue.toFixed(precision));
  }

  function offset(element, position) {
    return element.css({
      left: position
    });
  }

  function pixelize(position) {
    return parseInt(position) + "px";
  }

  function contain(value) {
    if (isNaN(value)) return value;
    return Math.min(Math.max(0, value), 100);
  }

  return {
    restrict: 'EA',
    require: '?ngModel',
    scope: {
      floor       : '@',
      ceiling     : '@',
      step        : '@',
      precision   : '@',
      bubbles     : '@',
      sliders     : '=ngModel'
    },
    template :
      '<div class="bar"></div>' +
      '<div class="limit floor">{{ floor }}</div>' +
      '<div class="limit ceiling">{{ ceiling }}</div>',

    link : function(scope, element, attrs, ngModel) {
      if (!ngModel) return; // do nothing if no ng-model

      //base copy to see if sliders returned to original
      var original;

      ngModel.$render = function() {
        original = angular.copy(scope.sliders);
      };

      element.addClass('angular-multi-slider');

      // DOM Components
      var sliderStr = '';
      angular.forEach(scope.sliders, function(slider, key){
          sliderStr += ('<div class="handle">
                        </div>
                        <div class="bubble">{{ sliders[' + key.toString() 
                               + '].title }}{{ sliders[' + key.toString() 
                               + '].value}}
                        </div>');
      });
      var sliderControls = angular.element(sliderStr);
      element.append(sliderControls);
      $compile(sliderControls)(scope);


      var children  = element.children();
      var bar       = angular.element(children[0]),
        ngDocument  = angular.element(document),
        floorBubble = angular.element(children[1]),
        ceilBubble  = angular.element(children[2]),
        bubbles = [],
        handles = [];

      //var sliderChildren = sliderControls.children();
      angular.forEach(scope.sliders, function(slider, key) {
        handles.push(angular.element(children[(key * 2) + 3]));
        bubbles.push(angular.element(children[(key * 2) + 4]));
      });

      // Control Dimensions Used for Calculations
      var handleHalfWidth = 0,
        barWidth = 0,
        minOffset = 0,
        maxOffset = 0,
        minValue = 0,
        maxValue = 0,
        valueRange = 0,
        offsetRange = 0;

      if (scope.step === undefined) scope.step = 1;
      if (scope.floor === undefined) scope.floor = 0;
      if (scope.ceiling === undefined) scope.ceiling = 500;
      if (scope.precision === undefined) scope.precision = 0;
      if (scope.bubbles === undefined) scope.bubbles = false;

      var bindingsSet = false;

      var updateCalculations = function() {
        scope.floor = roundStep(parseFloat(scope.floor), parseInt(scope.precision), 
                      parseFloat(scope.step), parseFloat(scope.floor));
        scope.ceiling = roundStep(parseFloat(scope.ceiling), parseInt(scope.precision), 
                      parseFloat(scope.step), parseFloat(scope.floor));

        angular.forEach(scope.sliders, function(slider) {
          slider.value = roundStep(parseFloat(slider.value), parseInt(scope.precision), 
                      parseFloat(scope.step), parseFloat(scope.floor));
        });

        handleHalfWidth = handles[0][0].offsetWidth / 2;
        barWidth = bar[0].offsetWidth;
        minOffset = 0;
        maxOffset = barWidth - handles[0][0].offsetWidth;
        minValue = parseFloat(scope.floor);
        maxValue = parseFloat(scope.ceiling);
        valueRange = maxValue - minValue;
        offsetRange = maxOffset - minOffset;
      };

      var updateDOM = function () {

        updateCalculations();

        var percentOffset = function (offset) {
          return contain(((offset - minOffset) / offsetRange) * 100);
        };

        var percentValue = function (value) {
          return contain(((value - minValue) / valueRange) * 100);
        };

        var pixelsToOffset = function (percent) {
          return pixelize(percent * offsetRange / 100);
        };

        var setHandles = function () {
          offset(ceilBubble, pixelize(barWidth - ceilBubble[0].offsetWidth));
          angular.forEach(scope.sliders, function(slider,key){
            if (slider.color) {
              handles[key].css({ "background-color": slider.color });
            }

            offset( handles[key], 
                    pixelsToOffset(percentValue(slider.value)));
            offset( bubbles[key], 
                    pixelize(handles[key][0].offsetLeft 
                    - (bubbles[key][0].offsetWidth / 2) + handleHalfWidth));
          });
        };

        var bind = function (handle, bubble, currentRef, events) {
          var onEnd = function () {
            handle.removeClass('grab');
            bubble.removeClass('grab');
            if (!(''+scope.bubbles === 'true')) {
              bubble.removeClass('active');
            }

            ngDocument.unbind(events.move);
            ngDocument.unbind(events.end);

            if (angular.equals(scope.sliders, original)) {
              ngModel.$setPristine();
            }

            scope.$apply();
          };

          var onMove = function (event) {
            // Suss out which event type we are capturing and get the x value
            var eventX = 0;
            if (event.clientX !== undefined) {
              eventX = event.clientX;
            }
            else if ( event.touches !== undefined && event.touches.length) {
              eventX = event.touches[0].clientX;
            }
            else if ( event.originalEvent !== undefined &&
              event.originalEvent.changedTouches !== undefined &&
              event.originalEvent.changedTouches.length) {
              eventX = event.originalEvent.changedTouches[0].clientX;
            }

            var newOffset = 
                Math.max( Math.min( 
                           (eventX - element[0].getBoundingClientRect().left             
                            - handleHalfWidth), maxOffset), minOffset),
                            newPercent = percentOffset(newOffset),
                            newValue = minValue 
                                        + (valueRange * newPercent / 100.0);

            newValue = roundStep(newValue, parseInt(scope.precision), parseFloat(scope.step), 
                       parseFloat(scope.floor));
            scope.sliders[currentRef].value = newValue;

            setHandles();
            ngModel.$setDirty();
            scope.$apply();
          };

          var onStart = function (event) {
            updateCalculations();
            bubble.addClass('active grab');
            handle.addClass('active grab');
            setHandles();
            event.stopPropagation();
            event.preventDefault();
            ngDocument.bind(events.move, onMove);
            return ngDocument.bind(events.end, onEnd);
          };

          handle.bind(events.start, onStart);
        };

        var setBindings = function () {
          var method, i;
          var inputTypes = ['touch', 'mouse'];
          for (i = 0; i < inputTypes.length; i++) {
            method = inputTypes[i];
            angular.forEach(scope.sliders, function(slider, key){
              bind(handles[key], bubbles[key], key, events[method]);
            });
          }

          bindingsSet = true;
        };

        if (!bindingsSet) {
          setBindings();

          // Timeout needed because bubbles offsetWidth is incorrect
          // during initial rendering of html elements
          setTimeout( function() {
            if (''+scope.bubbles === 'true') {
              angular.forEach(bubbles, function(bubble) {
                bubble.addClass('active');
              });
            }
            //added this for tab 1...
            updateCalculations();
            setHandles();
          }, 1);
        }
      };

      // Watch Models based on mode
      scope.$watch('sliders', updateDOM);
      // Update on Window resize
      window.addEventListener('resize', updateDOM);
    }
  }
});

Notes: The version of AngularJS I am using is 1.4.7. The version of angular-bootstrap is 0.14.3. The version of angular-multi-slider is 0.1.1

Freshman answered 11/11, 2015 at 0:32 Comment(1)
FYI I tested the angular-range-slider which I based my control from and it DOES work in the uib-Tab. So then I hardcoded 8 handles and bubbles and removed the $compile to see if it was the dynamic DOM additions and so far no luck.Sletten
Y
6

The problem is the slider on the 2nd tab is being rendered and hitting the multislider.js - updateCaclulations (or whatever function is calculating the space between the slider handles) function before the tab content area is visible. So there is no parent space to calc from. This plunk demonstrates using an ng-if to only render the multi-slider when the tab is active. And since SO won't let you post an answer with a plunk without code, here it is:

    <uib-tabset>
      <uib-tab heading="Tab 1" active="activeTabs[0]">
        <multi-slider name="mySlider"
                    floor="0"
                    step="1"
                    precision="2"
                    ceiling="365"
                    bubbles="true"
                    ng-model="app.sliders"
                    ng-if="activeTabs[0]">
          </multi-slider>
      </uib-tab>
      <uib-tab heading="Tab 2" active="activeTabs[1]">
        <section class="col-sm-6 padding-10">
          <multi-slider name="mySlider"
                    floor="0"
                    step="1"
                    precision="2"
                    ceiling="365"
                    bubbles="true"
                    ng-model="app.sliders"
                    ng-if="activeTabs[1]">
          </multi-slider>
        </section>
      </uib-tab>
    </uib-tabset>

controller:

$scope.activeTabs = [true, false];
Yonder answered 11/11, 2015 at 12:44 Comment(5)
This works! As the updated plnkr examples show, using ng-if (to control when the slider is rendered) in combination with ng-click (to change the model that is used to control which tab is active) solves the issue.Freshman
Quick note: after switching back and forth a couple of times, sometimes the problem returns (just try it clicking back and fort between tabs in the example).Freshman
@cdparra, does the problem return ONLY by switching back and forth in the tabs or are you also toggling the ng-if value?Ataxia
@Ataxia I am toggling the ng-if value via ng-click function that simply changes the active tab according to which one is clicked.Freshman
I would have thought that once rendered, you'd be fine. You probably don't want the ng-if to toggle back to false once true. Try decoupling the tab's active state from the ng-if directive but as Rob pointed out, you do want to wait for that initial active state to happen...maybe use a one-time binding.Ataxia
A
1

I've been looking at this for over an hour and have tried a bunch of different things and none of them have worked. It would be easy to blame the third party library but I suspect it's also due to how the tabs are rendered (i.e., via replace: true). Yours would not be the first problem we've experienced with users having issues with tab contents. We need to come up with a best practices for tab contents - especially when users put complex directives in the contents.

Ataxia answered 11/11, 2015 at 4:38 Comment(3)
What have you tried? I created angular-multi-slider and have attempted a few items but lo luck so far either.Sletten
Rob J, below, provided a way of fixing this particular case. However, I agree with you that some best practices are needed (perhaps using ng-if/ng-click as suggested in the other answer)Freshman
I tried a TON of things and like @RobJacobs, I tried using ng-if but i didn't try it on the directive itself, rather a wrapper <div> and using a $timeout to flip it. I used a long enough delay to let me switch to the second tab but it still didn't render.Ataxia

© 2022 - 2024 — McMap. All rights reserved.