Skip to content
This repository has been archived by the owner on Nov 22, 2021. It is now read-only.

Commit

Permalink
feat(tagsInput): Add visual feedback for invalid tags
Browse files Browse the repository at this point in the history
Add visual feedback for invalid tags so one knows why a tag cannot be
added. Make the directive invalid when it loses focus and there is some
leftover text that couldn't be turned into a tag (either because it's
invalid or the addOnBlur option is off). That behavior can be turned off
by setting the newly created allowLeftoverText option to true.

Closes #77.
  • Loading branch information
mbenford committed Mar 23, 2014
1 parent ce8bb03 commit f469c27
Show file tree
Hide file tree
Showing 11 changed files with 213 additions and 140 deletions.
3 changes: 2 additions & 1 deletion .jshintrc
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"makeObjectArray": true,
"findInObjectArray": true,
"KEYS": true,
"generateArray": true
"generateArray": true,
"customMatchers": true
}
}
3 changes: 2 additions & 1 deletion karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ module.exports = function(config) {
'test/lib/jquery-1.10.2.min.js',
'test/lib/angular.js',
'test/lib/angular-mocks.js',
'test/*.spec.js',
'test/helpers.js',
'test/matchers.js',
'test/*.spec.js',
'src/init.js',
'src/*.js',
'templates/*.html'
Expand Down
120 changes: 65 additions & 55 deletions scss/tags-input.scss
Original file line number Diff line number Diff line change
@@ -1,72 +1,82 @@
@import "mixins";
@import "variables";

tags-input .tags {
-moz-appearance: textfield;
-webkit-appearance: textfield;
padding: 1px;
overflow: hidden;
word-wrap: break-word;
cursor: text;
background-color: $tags-bgcolor;
border: $tags-border;
box-shadow: $tags-border-shadow;
tags-input {
.tags {
-moz-appearance: textfield;
-webkit-appearance: textfield;
padding: 1px;
overflow: hidden;
word-wrap: break-word;
cursor: text;
background-color: $tags-bgcolor;
border: $tags-border;
box-shadow: $tags-border-shadow;

&.focused {
outline: none;
@include box-shadow($tags-outline-box-shadow);
}
&.focused {
outline: none;
@include box-shadow($tags-outline-box-shadow);
}

.tag-list {
margin: 0;
padding: 0;
list-style-type: none;
}
.tag-list {
margin: 0;
padding: 0;
list-style-type: none;
}

.tag-item {
margin: 2px;
padding: 0 5px;
display: inline-block;
float: left;
font: $tag-font;
height: $tag-height;
line-height: $tag-height - 1px;
border: $tag-border;
border-radius: $tag-border-radius;
@include gradient($tag-color);

.tag-item {
margin: 2px;
padding: 0 5px;
display: inline-block;
float: left;
font: $tag-font;
height: $tag-height;
line-height: $tag-height - 1px;
border: $tag-border;
border-radius: $tag-border-radius;
@include gradient($tag-color);
&.selected {
@include gradient($tag-color-selected);
}

&.selected {
@include gradient($tag-color-selected);
.remove-button {
margin: 0 0 0 5px;
padding: 0;
border: none;
background: none;
cursor: pointer;
vertical-align: middle;
font: $remove-button-font;
color: $remove-button-color;

&:active {
color: $remove-button-color-active;
}
}
}

.remove-button {
margin: 0 0 0 5px;
.input {
border: 0;
outline: none;
margin: 2px;
padding: 0;
border: none;
background: none;
cursor: pointer;
vertical-align: middle;
font: $remove-button-font;
color: $remove-button-color;
padding-left: 5px;
float: left;
height: $tag-height;
font: $input-font;

&:active {
color: $remove-button-color-active;
&.invalid-tag {
color: $tag-color-invalid;
}
}
}

.input {
border: 0;
outline: none;
margin: 2px;
padding: 0;
padding-left: 5px;
float: left;
height: $tag-height;
font: $input-font;
&::-ms-clear {
display: none;
}
}
}

.input::-ms-clear {
display: none;
&.ng-invalid .tags {
@include box-shadow($tags-outline-box-shadow-invalid);
}
}
2 changes: 2 additions & 0 deletions scss/variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ $tags-bgcolor: #fff;
$tags-border: 1px solid darkgray;
$tags-border-shadow: 1px 1px 1px 0 lightgray inset;
$tags-outline-box-shadow: 0 0 3px 1px rgba(5, 139, 242, 0.6);
$tags-outline-box-shadow-invalid: 0 0 3px 1px rgba(255, 0, 0, 0.6);

$tag-height: 26px;
$tag-font: 14px $base-font-family;
$tag-color: rgba(240, 249, 255, 1) 0%, rgba(203, 235, 255, 1) 47%, rgba(161, 219, 255, 1) 100%;
$tag-border: 1px solid rgb(172, 172, 172);
$tag-border-radius: 3px;
$tag-color-selected: rgba(254, 187, 187, 1) 0%, rgba(254, 144, 144, 1) 45%, rgba(255, 92, 92, 1) 100%;
$tag-color-invalid: #ff0000;

$remove-button-color: #585858;
$remove-button-color-active: #ff0000;
Expand Down
2 changes: 1 addition & 1 deletion src/auto-complete.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ tagsInput.directive('autoComplete', function($document, $timeout, $sce, tagsInpu
};

tagsInput
.on('tag-added duplicate-tag', function() {
.on('tag-added invalid-tag', function() {
suggestionList.reset();
})
.on('input-change', function(value) {
Expand Down
65 changes: 41 additions & 24 deletions src/tags-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
* @param {number=} maxLength Maximum length allowed for a new tag.
* @param {number=} minTags Sets minTags validation error key if the number of tags added is less than minTags.
* @param {number=} maxTags Sets maxTags validation error key if the number of tags added is greater than maxTags.
* @param {boolean=} [allowLeftoverText=false] Sets leftoverText validation error key if there is any leftover text in
* the input element when the directive loses focus.
* @param {string=} [removeTagSymbol=×] Symbol character for the remove tag button.
* @param {boolean=} [addOnEnter=true] Flag indicating that a new tag will be added on pressing the ENTER key.
* @param {boolean=} [addOnSpace=false] Flag indicating that a new tag will be added on pressing the SPACE key.
Expand All @@ -30,7 +32,7 @@
*/
tagsInput.directive('tagsInput', function($timeout, $document, tagsInputConfig) {
function TagList(options, events) {
var self = {}, getTagText, setTagText;
var self = {}, getTagText, setTagText, tagIsValid;

getTagText = function(tag) {
return tag[options.displayProperty];
Expand All @@ -40,6 +42,14 @@ tagsInput.directive('tagsInput', function($timeout, $document, tagsInputConfig)
tag[options.displayProperty] = text;
};

tagIsValid = function(tag) {
var tagText = getTagText(tag);

return tagText.length >= options.minLength &&
options.allowedTagsPattern.test(tagText) &&
!findInObjectArray(self.items, tag, options.displayProperty);
};

self.items = [];

self.addText = function(text) {
Expand All @@ -51,22 +61,18 @@ tagsInput.directive('tagsInput', function($timeout, $document, tagsInputConfig)
self.add = function(tag) {
var tagText = getTagText(tag).trim();

if (tagText.length >= options.minLength && options.allowedTagsPattern.test(tagText)) {

if (options.replaceSpacesWithDashes) {
tagText = tagText.replace(/\s/g, '-');
}

setTagText(tag, tagText);
if (options.replaceSpacesWithDashes) {
tagText = tagText.replace(/\s/g, '-');
}

if (!findInObjectArray(self.items, tag, options.displayProperty)) {
self.items.push(tag);
setTagText(tag, tagText);

events.trigger('tag-added', { $tag: tag });
}
else {
events.trigger('duplicate-tag', { $tag: tag });
}
if (tagIsValid(tag)) {
self.items.push(tag);
events.trigger('tag-added', { $tag: tag });
}
else {
events.trigger('invalid-tag', { $tag: tag });
}

return tag;
Expand Down Expand Up @@ -122,7 +128,8 @@ tagsInput.directive('tagsInput', function($timeout, $document, tagsInputConfig)
enableEditingLastTag: [Boolean, false],
minTags: [Number],
maxTags: [Number],
displayProperty: [String, 'text']
displayProperty: [String, 'text'],
allowLeftoverText: [Boolean, false]
});

$scope.events = new SimplePubSub();
Expand Down Expand Up @@ -164,22 +171,30 @@ tagsInput.directive('tagsInput', function($timeout, $document, tagsInputConfig)
events
.on('tag-added', scope.onTagAdded)
.on('tag-removed', scope.onTagRemoved)
.on('tag-added duplicate-tag', function() {
scope.newTag = '';
.on('tag-added', function() {
scope.newTag.text = '';
})
.on('tag-added tag-removed', function() {
ngModelCtrl.$setViewValue(scope.tags);
})
.on('invalid-tag', function() {
scope.newTag.invalid = true;
})
.on('input-change', function() {
tagList.selected = null;
scope.newTag.invalid = null;
})
.on('input-focus', function() {
ngModelCtrl.$setValidity('leftoverText', true);
})
.on('input-blur', function() {
if (options.addOnBlur) {
tagList.addText(scope.newTag);
tagList.addText(scope.newTag.text);
}
ngModelCtrl.$setValidity('leftoverText', options.allowLeftoverText ? true : !scope.newTag.text);
});

scope.newTag = '';
scope.newTag = { text: '', invalid: null };

scope.getDisplayText = function(tag) {
return tag[options.displayProperty].trim();
Expand All @@ -190,7 +205,7 @@ tagsInput.directive('tagsInput', function($timeout, $document, tagsInputConfig)
};

scope.newTagChange = function() {
events.trigger('input-change', scope.newTag);
events.trigger('input-change', scope.newTag.text);
};

scope.$watch('tags', function(value) {
Expand Down Expand Up @@ -223,15 +238,15 @@ tagsInput.directive('tagsInput', function($timeout, $document, tagsInputConfig)
key === KEYS.comma && options.addOnComma ||
key === KEYS.space && options.addOnSpace) {

tagList.addText(scope.newTag);
tagList.addText(scope.newTag.text);

scope.$apply();
e.preventDefault();
}
else if (key === KEYS.backspace && scope.newTag.length === 0) {
else if (key === KEYS.backspace && scope.newTag.text.length === 0) {
var tag = tagList.removeLast();
if (tag && options.enableEditingLastTag) {
scope.newTag = tag[options.displayProperty];
scope.newTag.text = tag[options.displayProperty];
}

scope.$apply();
Expand All @@ -243,6 +258,8 @@ tagsInput.directive('tagsInput', function($timeout, $document, tagsInputConfig)
return;
}
scope.hasFocus = true;
events.trigger('input-focus');

scope.$apply();
})
.on('blur', function() {
Expand Down
6 changes: 4 additions & 2 deletions templates/tags-input.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
<a class="remove-button" ng-click="tagList.remove($index)">{{options.removeTagSymbol}}</a>
</li>
</ul>
<input type="text" class="input"
<input type="text"
class="input"
placeholder="{{options.placeholder}}"
maxlength="{{options.maxLength}}"
tabindex="{{options.tabindex}}"
ng-model="newTag"
ng-model="newTag.text"
ng-change="newTagChange()"
ng-trim="false"
ng-class="{'invalid-tag': newTag.invalid}"
ti-autosize>
</div>
</div>
4 changes: 2 additions & 2 deletions test/auto-complete.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -232,12 +232,12 @@ describe('autoComplete directive', function() {
expect(isSuggestionsBoxVisible()).toBe(false);
});

it('hides the suggestion box when a duplicate tag is tried to be added', function() {
it('hides the suggestion box when an invalid tag is tried to be added', function() {
// Arrange
suggestionList.visible = true;

// Act
eventHandlers['duplicate-tag']();
eventHandlers['invalid-tag']();

// Assert
expect(isSuggestionsBoxVisible()).toBe(false);
Expand Down
14 changes: 14 additions & 0 deletions test/matchers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use strict';

var customMatchers = {
toHaveClass: function () {
return {
compare: function(actual, expected) {
var result = {};
result.pass = actual.hasClass(expected);
result.message = 'Expected element' + (result.pass ? ' not ' : ' ') + 'to have class \'' + expected + '\'';
return result;
}
};
}
};
Loading

0 comments on commit f469c27

Please sign in to comment.