Search code examples
javascriptangularjsng-showangularjs-ng-transclude

Angular: Next section shown before previous section hidden - using ng-show


I'm implementing a reusable step-by-step wizard directive in angular based on this example. It's working pretty well, but like in the example, I'm using ng-show to hide all steps but the current one. This results in a quick flicker whenever I change steps where both the current and next step is shown simultaneously. What I can't figure out is how to do away with the flicker and make sure only a single step is shown at any one time.

What I've tried: My own initial attempt at solving this issue was to change the show/hide-mechanic to use ng-switch but it doesn't work well since the ng-switch-when directive only accepts strings (so I can't populate it automatically with an index). Furthermore, ng-switch-when works through translcusion meaning I would have 2 transclusion directives on a single element which doesn't really make sense.

The wizard-directive as I've currently implemented it looks like this:

// Wizard
// ======
//
// This component implements a wizard-directive and a dependent step-directive
// which together can be used to define a step-by-step wizard with arbitrary
// html in each step.
//
// Ex:
// ```html
// <wizard>
//     <step>
//         <h1>Step one</h1>
//     </step>
//     <step>
//         <h1>Step two</h1>
//     </step>
// </wizard>
// ```
//
angular.module('wizard', [])


// Wizard Directive
// ----------------
//
// The main directive which defines the wizard element. A wizard can contain
// arbitrary html, but will only display a single step-element at a time. The
// directive also defines a couple of ways to navigate the wizard - through
// buttons and bottom "tabs".
//
.directive('wizard', function($rootScope) {
    return {
        restrict: 'E',
        transclude: true,
        scope: {},
        templateUrl: $rootScope.templateBasePath + '/components/wizard/wizard.html',
        controller: function($scope, $element) {
            // Initialize the array of steps. This will be filled by any child
            // steps added to the wizard.
            var steps = $scope.steps = [];

            // Search through the wizard to find what step is currently visible.
            function getCurrentStepIndex() {
                var index;
                angular.forEach(steps, function(step, i) {
                    if (step.selected) {
                        index = i;
                        // End early when the selected step is found.
                        return;
                    }
                });
                return index;
            }

            // Make the imagePath available to the template.
            $scope.imagePath = $rootScope.imagePath;

            // Move to the next step in the wizard.
            $scope.next = function () {
                var index = getCurrentStepIndex();
                if (index < steps.length - 1) {
                    steps[index].selected = false;
                    steps[index+1].selected = true;
                }
            };

            // Move to the previous step of the wizard.
            $scope.previous = function () {
                var index = getCurrentStepIndex();
                if (index > 0) {
                    steps[index].selected = false;
                    steps[index-1].selected = true;
                }
            };

            // Select a given step in the wizard.
            $scope.select = function(step) {
                angular.forEach(steps, function(step) {
                    step.selected = false;
                });
                step.selected = true;
            };

            $scope.onFirstStep = function() {
                return getCurrentStepIndex() === 0;
            }

            $scope.onLastStep = function() {
                return getCurrentStepIndex() === steps.length - 1;
            }

            // Called by the step directive to add itself to the wizard.
            this.addStep = function(step) {
                // Select the first step when added.
                if (steps.length === 0) {
                    $scope.select(step);
                }
                // Add the step to the step list.
                steps.push(step);
            };
        }
    };
})

// Step Directive
// --------------
//
// The Step Directive defines a section of code which constitues a distinct step
// in the overall goal of the wizard. The directive can only exist as a direct
// child of a wizard-tag.
//
.directive('step', function() {
    return {
        require: '^wizard', // require a wizard parent
        restrict: 'E',
        transclude: true,
        scope: true,
        template: '<div class="wizard__step ng-hide" ng-show="selected"></div>',
        link: function(scope, element, attrs, wizardCtrl, transclude) {
            // Add itself to the wizard's list of steps.
            wizardCtrl.addStep(scope);

            // Make the wizard scope available under "wizard" in the transcluded
            // html scope.
            scope.wizard = scope.$parent.$parent;

            // Transclude the tag content in order to set the scope. This allows
            // the content to access the wizard's next() and previous() functions.
            var transDiv = angular.element(element).find('.wizard__step');
            transclude(scope, function (clone) {
                transDiv.append(clone);
            });
        }
    };
});

The corresponding wizard template looks like so:

<div class="wizard" tabindex="1">
    <div class="wizard__display">
        <div class="wizard__previous" ng-click="previous()"><div class="guideBack" ng-hide="onFirstStep()"></div></div>
        <div class="wizard__content" ng-transclude></div>
        <div class="wizard__next" ng-click="next()"><div class="guideNext" ng-hide="onLastStep()"></div></div>
    </div>
    <ul class="nav wizard__tabs">
        <li ng-repeat="step in steps" ng-click="select(step)" ng-class="{active:step.selected}"></li>
    </ul>
</div>

Solution

  • I was making it unnecessarily difficult for myself by focusing all my attention on making it work with ng-show or ng-switch. The problem is eliminated quickly if you just perform the showing and hiding manually through jqlite/jquery. After some refactoring, the wizard-code looks like this:

    // Wizard
    // ======
    //
    // This component implements a wizard-directive and a dependent step-directive
    // which together can be used to define a step-by-step wizard with arbitrary
    // html in each step.
    //
    // Ex:
    // ```html
    // <wizard>
    //     <step>
    //         <h1>Step one</h1>
    //     </step>
    //     <step>
    //         <h1>Step two</h1>
    //     </step>
    // </wizard>
    // ```
    //
    angular.module('ssbbtip.wizard', [])
    
    
    // Wizard Directive
    // ----------------
    //
    // The main directive which defines the wizard element. A wizard can contain
    // arbitrary html, but will only display a single step-element at a time. The
    // directive also defines a couple of ways to navigate the wizard - through
    // buttons, bottom breadcrumbs and keyboard arrow keys.
    //
    .directive('wizard', function($rootScope) {
        return {
            restrict: 'E',
            transclude: true,
            scope: {},
            templateUrl: $rootScope.templateBasePath + '/components/wizard/wizard.html',
            controller: function($scope, $element) {
                // Initialize the array of steps. This will be filled by any child
                // steps added to the wizard.
                var steps = $scope.steps = [],
                    // currentStep is the shadow variable supporting the
                    // `$scope.currentStep` property.
                    currentStep = 0;
    
                // This utility function will adjust a value to fit inside the
                // specified range inclusively.
                function clampToRange(min, max, value) {
                    // Make sure the max is at least as big as the min.
                    max = (min > max) ? min : max;
    
                    if (value < min) {
                        return min;
                    } else if (value > max) {
                        return max;
                    } else {
                        return value;
                    }
                }
    
    
                // This property specifies the currently visible step in the wizard.
                Object.defineProperty($scope, 'currentStep', {
                    enumerable: true,
                    configurable: false,
                    get: function () { return currentStep; },
                    set: function (value) {
                        if (value && typeof(value) === 'number') {
                            currentStep = clampToRange(0, steps.length-1, value);
                        } else {
                            currentStep = 0;
                        }
                    }
                });
    
                // Make the imagePath available to the template.
                $scope.imagePath = $rootScope.imagePath;
    
                // Handle keyboard events on the wizard to allow navigation by
                // keyboard arrows.
                $scope.onKeydown = function (event) {
                    event.preventDefault();
                    console.log(event);
                    switch (event.which) {
                        case 37: // left arrow
                        case 38: // up arrow
                            $scope.previous();
                            break;
                        case 39: // right arrow
                        case 40: // down arrow
                        case 32: // space bar
                            $scope.next();
                            break;
                    }
                };
    
                // Move to the next step in the wizard.
                $scope.next = function () {
                    $scope.currentStep = $scope.currentStep + 1;
                };
    
                // Move to the previous step of the wizard.
                $scope.previous = function () {
                    $scope.currentStep = $scope.currentStep - 1;
                };
    
                $scope.onFirstStep = function() {
                    return $scope.currentStep === 0;
                };
    
                $scope.onLastStep = function() {
                    return $scope.currentStep === steps.length - 1;
                };
    
                // Called by the step directive to add itself to the wizard.
                this.addStep = function (step) {
                    steps.push(step);
                };
    
                // This watches the `$scope.currentStep` property and updates the UI
                // accordingly.
                $scope.$watch(function () {
                    return $scope.currentStep;
                }, function (newValue, oldValue) {
                    $element.find('step .wizard__step').eq(oldValue).addClass('ng-hide');
                    $element.find('step .wizard__step').eq(newValue).removeClass('ng-hide');
                });
            }
        };
    })
    
    // Step Directive
    // --------------
    //
    // The Step Directive defines a section of code which constitues a distinct step
    // in the overall goal of the wizard. The directive can only exist as a direct
    // child of a wizard-tag.
    //
    .directive('step', function() {
        return {
            require: '^wizard', // require a wizard parent
            restrict: 'E',
            transclude: true,
            scope: true,
            template: '<div class="wizard__step ng-hide"></div>',
            link: function(scope, element, attrs, wizardCtrl, transclude) {
                // Add itself to the wizard's list of steps.
                wizardCtrl.addStep(scope);
    
                // Make the wizard scope available under "wizard" in the transcluded
                // html scope.
                scope.wizard = scope.$parent.$parent;
    
                // Transclude the tag content manually in order to set the scope.
                // This allows the content to access the `wizard.next()` and
                // `wizard.previous()` functions.
                var transDiv = angular.element(element).find('.wizard__step');
                transclude(scope, function (clone) {
                    transDiv.append(clone);
                });
            }
        };
    });
    

    and the template:

     <div class="wizard" tabindex="1" ng-keydown="onKeydown($event)">
         <!-- tabindex 1 is needed to make the div selectable in order to capture keydown -->
         <div class="wizard__display">
             <div class="wizard__previous" ng-click="previous()"><div class="guideBack" ng-hide="onFirstStep()"></div></div>
             <div class="wizard__content" ng-transclude></div>
             <div class="wizard__next" ng-click="next()"><div class="guideNext" ng-hide="onLastStep()"></div></div>
         </div>
         <ul class="nav wizard__tabs">
             <li ng-repeat="step in steps track by $index" ng-click="currentStep = $index" ng-class="{active: currentStep === $index}"></li>
         </ul>
     </div>