AngularJs native tag editor

Introduction

Continuing my series on directives that fill gaps in the angular framework, today I will discuss my implementation of a tag editor. In some ways this is simpler than my previous attempts, no new services needed to be created, however, there are a lot of styling concerns that need to be dealt with. I think I found a reasonable approach that provides a decent default look and feel and provides implementers the flexibility to provide their own styling.

The code is available on github. It is also available on ngmodules.org, please click the ‘I use it button’.

Demo here.

Design Goals

  • Native
  • Control styling
  • Enable tag interactivity
  • Support edit & read-only modes

Supported Browsers

This directive relies 2 events that have been supported by all major browsers for a while:

  • blur
  • keypress

API

The directive works with Tag object which are defined as:

{
Name: "",
Description: "",
ID: ""
}

Events

These are methods that need to be defined on the controller’s scope object, and are called by the directive.

onValidate(tag)
Called before the tag is added to the parent’s Tag array. If this function returns false the tag will not be added. It is up to this function to display any error messages to the user.
onHover(tag, elementPos)
Called when the mouse is over the displayed tag. This event is only called once, when the mouse enters the tag’s bounding box. The second argument details the screen location of the element the mouse is over, the intent being that you can display more information about the tag in a reasonable place on the page
onHoverOut()
Called when the mouse has left the tag’s bounding box

Attributes

These are attributes added to the directive markup in the HTML template. Attributes are angular expressions and optional unless otherwise noted.

useDefaultCss
Do not include the default <style> element in the DOM. You will be responsible for providing your own CSS classes. (default: false)
readOnly
Do not display the tag input box (default: false)
focusInput
Give focus to the tag editor’s input element when the page load. This value is ignored if readOnly === 'true'. If multiple elements claim focus the browser will decide which one wins. (default: false)
caption
If present this will be displayed under the tag editor’s input element
tags
Reference, Required This is the name of an array of tag objects on the directive’s parent.

Tag Editor Directive

The directive is pretty straightforward, the biggest tric

angular
.module('lvl.directives.tageditor', ['lvl.services'])
.directive('lvlTagEditor', ['$timeout', 'uuid', function ($timeout, uuid) {

    var css = "<style>.tag-list{  " +
"    margin: 0; " +
"    padding: 0; " +
"    list-style: none; " +
"    display: inline-block; " +
"} " +
" " +
".tag-editor { " +
"   display: inline-block; " +
"} " +
" " +
".tag-item " +
"{ " +
"    overflow: hidden; " +
"    height: auto !important; " +
"    height: 15px; " +
"    margin: 3px; " +
"    padding: 1px 3px; " +
"    background-color: #eff2f7; " +
"    color: #000; " +
"    cursor: default; " +
"    border: 1px solid #ccd5e4; " +
"    font-size: 11px; " +
"    border-radius: 5px; " +
"    -moz-border-radius: 5px; " +
"    -webkit-border-radius: 5px; " +
"    float: left; " +
"    white-space: nowrap; " +
"} " +
" " +
" .edit-tag:before { " +
"    content: 'e '; " +
"    font-size: 6pt;" +
" }" +
" " +
" .delete-tag:before { " +
"    content: 'x '; " +
"    font-size: 6pt;" +
" }" +
" " +
".tag-name " +
"{ " +
"    margin: 0; " +
"    display: inline-block; " +
"} " +
" " +
".tag-help { " +
"    color: #595959; " +
"    display: block; " +
"    margin-bottom: 2px; " +
"} " +
" " +
".tag-action { " +
"        color: white; " +
"        border-radius: 4px; " +
"        width: 12px; " +
"        height: 12px; " +
"        display:inline-block; " +
"        text-align: center; " +
"        padding-bottom:8px; " +
"        border: 1px solid gray; " +
"} " +
" " +
".edit-tag { " +
"        background-color: #004181; " +
"        color: #a3a3a3; " +
"} " +
" " +
".delete-tag { " +
"        background-color: #b40000; " +
"        color: #a3a3a3; " +
"} " +
" " +
".edit-tag:hover { " +
"        background-color: gray; " +
"        color: black; " +
"} " +
" " +
".delete-tag:hover { " +
"        background-color: gray; " +
"        color: black; " +
"} " +
" " +
".edit-tag:after { " +
"        word-spacing: 1em; " +
"} " +
"</style>";

    return {
        restrict: 'E',
        template: ' ' +
'<div class="tag-editor"> ' +
'   <div> ' +
'       <input ' +
'           type="text" ' +
'           ng-model="currentTag.Name" ' +
'           style="display: inline-block" /> ' +
'       <small class="tag-caption" ng-show="caption">{{caption}}</small> ' +
'   </div> ' +
'   <div ng-show="tags.length"> ' +
'      <ul class="tag-list"> ' +
'          <li ng-repeat="tag in tags" class="tag-item" ng-mouseover="over($event)" ng-mouseleave="out($event)" data-tag-id={{tag.Id}}> ' +
'              <p class="tag-name"> ' +
'                  {{tag.Name}} ' +
'              </p> ' +
'              <div class="tag-action delete-tag" ng-hide="readOnly" ng-click="removeTag(tag)" title="remove tag"></div> ' +
'              <div class="tag-action edit-tag" ng-hide="readOnly" ng-click="editTag(tag)" title="edit tag"></div> ' +
'          </li> ' +
'      </ul> ' +
'   </div> ' +
'</div>',
        replace: true,
        scope: {
            tags: "=",
            useDefaultCss: "@",
            readOnly: "@",
            focusInput: "@",
            caption: "@",
            onValidate: "&",
            onHover: "&",
            onHoverOut: "&"
        },
        compile: function compile(tElement, tAttrs, transclude) {
            var input = angular.element(angular.element(tElement).children(0).children(0)[0]);
            var inputId = uuid.new();
            input.attr("id", inputId);

            if (tAttrs.readOnly === 'true') {
                input.remove();
            } else {
                input.bind("blur", function(event) {
                    var scope = angular.element(this).scope();
                    scope.$apply(function() {
                        scope.addTag(event);
                    });
                });

                input.bind("keypress", function(event) {
                    var scope = angular.element(this).scope();
                    scope.$apply(function() {
                        if (event.which == 13) {
                            scope.addTag(event);
                            event.preventDefault();
                        }
                    });
                });
            }

            if (tAttrs.useDefaultCss === 'true') {
                angular.element(tElement).prepend(css);
            }

            return function postLink(scope, el, attrs, ctl) {
                var runValidation = attrs.onValidate != undefined;

                if (attrs.focusInput === 'true') {
                    document.getElementById(inputId).focus();
                }

                var hovering = false;
                scope.over = function($event) {
                    if (hovering) return;
                    hovering = true;

                    var domEl = $event.currentTarget;
                    var el = angular.element(domEl);
                    if (!el.attr("data-tag-id")) {
                        el = el.parent();
                    }

                    var id = el.attr("data-tag-id");
                    var tag = scope.tags.getByFunc(function(t) {return t.Id == id;});
                    var pos = domEl.getBoundingClientRect();
                    scope.onHover({tag: tag, elementPos: {
                                                            height: pos.height,
                                                            width: pos.width,
                                                            left: pos.left,
                                                            bottom: pos.bottom,
                                                            right: pos.right,
                                                            top: pos.top
                                                        }});
                };

                scope.out = function($event) {
                    hovering = false;

                    scope.onHoverOut();
                }

                scope.addTag = function ($event) {
                    scope.valErr = false;
                    
                    if (scope.currentTag.Name == "") return;

                    var tagValid = (runValidation && scope.onValidate({ tag: scope.currentTag })) || !runValidation;

                    if (!tagValid) return;

                    if (!scope.tags.tagExists(scope.currentTag)) {
                        scope.tags.push(scope.currentTag);
                    }

                    scope.currentTag = new tag();
                };

                scope.editTag = function (tag) {
                    scope.removeTag(tag);
                    scope.currentTag = tag;
                    document.getElementById(inputId).focus();

                    scope.onHoverOut();
                };

                scope.removeTag = function (tag) {
                    scope.tags.removeByFunc(function (t) {
                        return t.Id == tag.Id;
                    });

                    scope.onHoverOut();
                };

                scope.currentTag = new tag();
                
                function tag() {
                    return {
                        Name: "",
                        Description: "",
                        Id: uuid.new()
                    };
                }
            };
        }
    };
}]);

Array.prototype.tagExists = function(tag) {
    for (var i = 0; i < this.length; i++) {
        if (this[i].Name == tag.Name) {
            return true;
        }
    }

    return false;
};

Array.prototype.removeByFunc = function () {
    var what, a = arguments;
    for (var ax = this.length - 1; ax >= 0; ax--) {
        what = this[ax];
        if (a[0](what)) {
            this.splice(ax, 1);
        }
    }
    return this;
};

Array.prototype.getByFunc = function() {
  var what, a = arguments;
    for (var ax = this.length - 1; ax >= 0; ax--) {
        what = this[ax];
        if (a[0](what)) {
            return what;
        }
    }
    return null;  
}

Lines 5 - 91: The default styles for the directive. The styles are only added to the DOM if the attribute use-default-style = 'true'.

Lines 94 - 126: Setup the directive definition object with the directive UI, and the events and attributes.

Lines 128 - 155: A number of things are happening during the compilation phase.

  1. If the directive is in read only (read-only=='true') mode, remove the text input element
  2. Bind event handlers to handle the blur and keypress which are used to add tags to the array.

    Lines 136 & 143: Since we are not operating in an angular context we retrieve the scope associated with the current element.

    Lines 137 & 144: Again, we are not in an angular context (i.e. not in a digest cycle) so start one before calling into the directive’s scope functions, so two-way binding works as expected.

    Lines 145 - 148: If the user pressed the enter key, add the tag and prevent browser default behavior.
  3. Adds the default styles to the DOM if use-default-css==='true'

Line 158: Determine if tags should be validated.

Line 161: Focus input element if focus-input === 'true'

Lines 164 - 186: Handle the onmouseenter DOM event, and fire the on-hover directive event as required. The variable hovering ensures the on-hover event is only fired once.

Lines 171 - 173: Ensure that the variable el is referencing the li which contain’s the tag’s id as an attribute.

Line 176: Retrieve the tag using a monkey-patch method defined later in the codefile.

Lines 177 - 186: Find the position of the element being moused over. One weird thing is that I couldn’t pass the pos variable directly in the event, so I had to redefine the object on Lines 175 - 180.

Lines 188 - 192: Handle the onmouseleave DOM event, and fire the on-hover-out directive event.

Lines 194 - 208: If a validation function has been specified (attribute: on-validate) run it. If the tag passes validation add it the tag to the parent’s tag array (specified in the directives tag attribute).

Lines 210 - 216: When the tag is edited, remove it from the tag array and add it to the input box. Fire the on-hover-out event to ensure any UI the directive’s parent created is cleared.

Lines 218 - 224: Remove the tag from the parent’s tag array using a monkey patch method defined later in the codefile. Fire the on-hover-out event to ensure any UI the directive’s parent created is cleared.

Lines 226 - 234: Define a tag object, and create a new tag that is used as the ng-mode for the input text box.

Lines 240 - 245: Add an array method to determine whether a tag exists in the array. Ensure tag names are unique by comparing names instead of Ids.

Lines 250 - 259: Add an array method to remove tags based on a predicate function. The predicate function is called with a tag object as a parameter and returns true to remove the tag. See line 219 for example usage.

Lines 261 - 270: Add an array method to get a tag based on a predicate function. The predicate function is called with a tag object as a parameter and returns true to return the tag. See line 176 for example usage.

Styling Tags

The following classes are used

tag-editor
Applied to the directive’s root div.
tag-caption
Applied to the small element that contains the text specified by the caption attribute.
tag-list
Applied to the ul element that contains the tags.
tag-item
Applied to the li elements containing the tag mark up.
tag-name
Applied to the p element that contains the tag’s name.
tag-action
Applied to a div elements that are the buttons to control editing and deleting a tag.
delete-tag
Applied with the tag-action class on one of the button divs.
edit-tag
Applied with the tag-action class on one of the button divs.

Using the directive

I’m happy with the way this directive came out. I think I hit the right level of abstraction to make plugging it into a web page easy.

Html

<script src="lib/lvl-uuid.js"></script>
<script src="script/lvl-tag-editor.js"></script>

<!-- editable tags -->
<lvl-tag-editor
  use-default-css='true'
  focus-input='true'
  tags='tags'
  on-hover='hover(tag, elementPos)'
  on-hover-out='hoverOut()'
  on-validate='validate(tag)'
></lvl-tag-editor>

<!-- read only tags -->
<lvl-tag-editor
  use-default-css='false'
  read-only='true'
  tags='tags'
  on-hover='hover(tag, elementPos)'
  on-hover-out='hoverOut()'
  style="display:inline-block"
></lvl-tag-editor>

Script

angular
.module('tagApp', ['lvl.services', 'lvl.directives.tageditor'])
.controller('tagCtl', ['$scope', '$timeout', "uuid", function($scope, $timeout, uuid) {
	$scope.selected = null;
	
	$scope.tags = [
		{Name: "one", Id: uuid.new(), Description: "the one tag"}, 
		{Name: "two", Id: uuid.new(), Description: "the two tag"},
		{Name: "three", Id: uuid.new(), Description: "the three tag"}];

	$scope.hover = function(tag, elementPos) {
		$scope.selected = tag;
		$scope.selectedPos = elementPos;
	};

	$scope.hoverOut = function() {
		$scope.selected = null;
	};

	$scope.validate = function(tag) {
		var isValid = (tag.Name != 'flarn');
		if (!isValid) {
			$scope.error = true;
			$timeout(function() {
				$scope.error = false;
			}, 2500);
		}

		return isValid;
	};

}]);

The End

Thanks for reading. If you find the post and/or directive useful, please click the ‘I use it!’ button on ngmodules.

~~~jason

2 thoughts on “AngularJs native tag editor

  1. Pingback: AngularJS Highlights – Week Ending 29 September 2013 | SyntaxSpectrum
  2. Suggestion – make your explanations as line by line comments with your code so that it’s easier to follow along! :) Awesome project though!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s