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’.
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.
- If the directive is in read only (
read-only=='true'
) mode, remove the text input element -
Bind event handlers to handle the
blur
andkeypress
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. - 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 thecaption
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 buttondivs
. edit-tag
- Applied with the
tag-action
class on one of the buttondivs
.
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