Angular and DOM Manipulations in Directives

If you are following Angular “way” to create applications, you know that all DOM manipulations should be reserved for directives. What if you would like to alter DOM there by adding new DOM elements and at the same time use other directives, such as ng-model inside new DOM elements.  You would think that something like the following will work (code is in TypeScript, but JS code is pretty much the same).

 var link = angular.element('<li><a data-no-click data-ng-click="navigate({{menuId}})" href="">{{Name}}</a></li>'
                                    .replace('{{Name}}', item.Name)
                                    .replace('{{menuId}}', item.MenuId.toString()));
                                link.appendTo(itemsDiv);

The code above is from a directive I wrote that builds Bootstrap navigation bar from array of menu items.  I am not using ng-repease because I would like to have more control over how the menu is built and how subitems are iterated.  My menu structure is based on the following menu object:


declare module app.home.models {


    interface IMenuItem {
        MenuId: number;
        ParentMenuId?: number;
        Name: string;
        Description: string;
        SortOrder: number;
        ImagePath: string;
        IsMenu: Boolean;
        View: string;
        Controller: string;
        SubMenus: IMenuItem[];
    }

}

So, as you can see I would like to show Menu property in the navigation bar item description and have the click call navigate function in my controller.  If you write this code you will quickly find out that your function is not called.  In other words Angular bindings are not working in newly added DOM elements.  This is because Angular already processed the DOM, and no longer watches it.  So, the solution is simple, you need to teach Angular about new DOM and its bindings.  Luckily, this is very easy using $compile service. The steps as simple

  • Compile new (or existing) DOM element that was altered
  • Call the function that compile() method returns and pass in the scope that contains needed bindings.

You can actually do both in a single line of code, as in

var itemsDiv = angular.element('.bar-menu-items');
//....
$compile(itemsDiv)(scope); // actual work to process DOM and update bindings

This is how you would activate bindings on new DOM structure.

If you would like to see how to create navigation bar items directive, here is TypeScript version

interface IMenuItemScope extends ng.IScope {
        item: app.home.models.IMenuItem;
        navigate: (menu: app.home.models.IMenuItem) => void;
    }

    export class NavBarDirective extends app.directives.BaseDirective {
        constructor($compile: ng.ICompileService) {
            super();
            this.restrict = 'A';
            this.link = function (
                scope: app.home.controllers.IHomeScope,
                element: ng.IAugmentedJQuery,
                attributes: ng.IAttributes) {
                element.addClass("hide");
                var newScope: IMenuItemScope;
                var menuItemsProperty: string = "menuItems";
                if (attributes.$attr["menuItems"]) {
                    menuItemsProperty = element.attr(<string>attributes.$attr["menuItems"]);
                }
                var itemsDiv = angular.element('.bar-menu-items');
                scope.$watch(menuItemsProperty, () => {
                    var items: app.home.models.IMenuItem[] = scope.$eval(menuItemsProperty);
                    if (items && items.length > 0) {
                        var topMenuItems = getMenuItems(items, 0);
                        angular.forEach(topMenuItems, (item: app.home.models.IMenuItem) => {
                            var subItems = getMenuItems(items, item.MenuId);


                            if (subItems.length > 0) {
                                newScope = createScope(item);
                                var dropDown = angular.element(
                                    '<li class="dropdown"><a href="#" data-no-click class="dropdown-toggle" data-toggle="dropdown">{{item.Name}}<b class="caret"></b></a></li>');
                                $compile(dropDown)(newScope);
                                var ul = angular.element('<ul class="dropdown-menu"></ul>');

                                angular.forEach(subItems, (subItem: app.home.models.IMenuItem) => {
                                    newScope = createScope(subItem);
                                    var subItemElement = angular.element('<li><a data-no-click data-ng-click="navigate(item)" href="">{{item.Name}}</a></li>');
                                    $compile(subItemElement)(newScope);
                                    subItemElement.appendTo(ul);
                                });
                                ul.appendTo(dropDown);
                                dropDown.appendTo(itemsDiv);
                            } else {
                                newScope = createScope(item);
                                var link = angular.element('<li><a data-no-click data-ng-click="navigate(item)" href="">{{item.Name}}</a></li>');
                                link.appendTo(itemsDiv);
                                $compile(link)(newScope);
                            }

                        });
                        element.removeClass('hide');
                    }

                });

                var createScope = (item: app.home.models.IMenuItem): IMenuItemScope => {
                    newScope = <IMenuItemScope>scope.$new(true);
                    newScope.item = item;
                    newScope.navigate = scope.navigate;
                    return newScope;
                };

                var getMenuItems = (items: app.home.models.IMenuItem[], parentId: number): app.home.models.IMenuItem[]=> {
                    var returnValue = [];
                    angular.forEach(items, (item: app.home.models.IMenuItem) => {
                        if (item.ParentMenuId === parentId) {
                            returnValue.push(item);
                        }
                    });
                    return returnValue;
                };
            };
        }
    }

You can attach this the following way to your HTML:

 <nav class="navbar navbar-default navbar-fixed-top row" role="navigation" data-nav-bar data-menu-items="menuItems" ng-cloak>
            <div class="navbar-brand custom-brand">{{applicationName}}</div>
            <ul class="bar-menu-items nav navbar-nav"></ul>
        </nav>

Enjoy.

Leave a Reply

Your email address will not be published. Required fields are marked *