diff --git a/dist/mention.js b/dist/mention.js index ed4c051..45737b7 100644 --- a/dist/mention.js +++ b/dist/mention.js @@ -2,9 +2,10 @@ var _slicedToArray = (function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i['return']) _i['return'](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError('Invalid attempt to destructure non-iterable instance'); } }; })(); -angular.module('ui.mention', []).directive('uiMention', function ($q, $timeout, $document) { +angular.module('ui.mention', []).directive('uiMention', function () { return { require: ['ngModel', 'uiMention'], + controller: 'uiMentionController', controllerAs: '$mention', link: function link($scope, $element, $attrs, _ref) { var _ref2 = _slicedToArray(_ref, 2); @@ -13,324 +14,326 @@ angular.module('ui.mention', []).directive('uiMention', function ($q, $timeout, var uiMention = _ref2[1]; uiMention.init(ngModel); - }, - controller: function controller($element, $scope, $attrs) { - var _this2 = this; - - // Beginning of input or preceeded by spaces: @sometext - this.pattern = this.pattern || /(?:\s+|^)@(\w+(?: \w+)?)$/; - this.$element = $element; - this.choices = []; - this.mentions = []; - var ngModel; - - /** - * $mention.init() - * - * Initializes the plugin by setting up the ngModelController properties - * - * @param {ngModelController} model - */ - this.init = function (model) { - var _this = this; - - // Leading whitespace shows up in the textarea but not the preview - $attrs.ngTrim = 'false'; - - ngModel = model; - - ngModel.$parsers.push(function (value) { - // Removes any mentions that aren't used - _this.mentions = _this.mentions.filter(function (mention) { - if (~value.indexOf(_this.label(mention))) return value = value.replace(_this.label(mention), _this.encode(mention)); - }); - - _this.render(value); - - return value; - }); - - ngModel.$formatters.push(function () { - var value = arguments.length <= 0 || arguments[0] === undefined ? '' : arguments[0]; - - // In case the value is a different primitive - value = value.toString(); - - // Removes any mentions that aren't used - _this.mentions = _this.mentions.filter(function (mention) { - if (~value.indexOf(_this.encode(mention))) { - value = value.replace(_this.encode(mention), _this.label(mention)); - return true; - } else { - return false; - } - }); - - return value; - }); - - ngModel.$render = function () { - $element.val(ngModel.$viewValue || ''); - _this.render(); - }; - }; - - /** - * $mention.render() - * - * Renders the syntax-encoded version to an HTML element for 'highlighting' effect - * - * @param {string} [text] syntax encoded string (default: ngModel.$modelValue) - * @return {string} HTML string - */ - this.render = function () { - var html = arguments.length <= 0 || arguments[0] === undefined ? ngModel.$modelValue : arguments[0]; - - html = (html || '').toString(); - _this2.mentions.forEach(function (mention) { - html = html.replace(_this2.encode(mention), _this2.highlight(mention)); - }); - $element.next().html(html); - return html; - }; - - /** - * $mention.highlight() - * - * Returns a choice in HTML highlight formatting - * - * @param {mixed|object} choice The choice to be highlighted - * @return {string} HTML highlighted version of the choice - */ - this.highlight = function (choice) { - return '' + this.label(choice) + ''; - }; - - /** - * $mention.decode() - * - * @note NOT CURRENTLY USED - * @param {string} [text] syntax encoded string (default: ngModel.$modelValue) - * @return {string} plaintext string with encoded labels used - */ - this.decode = function () { - var value = arguments.length <= 0 || arguments[0] === undefined ? ngModel.$modelValue : arguments[0]; - - return value ? value.replace(/@\[([\s\w]+):[0-9a-z-]+\]/gi, '$1') : ''; - }; - - /** - * $mention.label() - * - * Converts a choice object to a human-readable string - * - * @param {mixed|object} choice The choice to be rendered - * @return {string} Human-readable string version of choice - */ - this.label = function (choice) { - return choice.first + ' ' + choice.last; - }; - - /** - * $mention.encode() - * - * Converts a choice object to a syntax-encoded string - * - * @param {mixed|object} choice The choice to be encoded - * @return {string} Syntax-encoded string version of choice - */ - this.encode = function (choice) { - return '@[' + this.label(choice) + ':' + choice.id + ']'; - }; - - /** - * $mention.replace() - * - * Replaces the trigger-text with the mention label - * - * @param {mixed|object} mention The choice to replace with - * @param {regex.exec()} [search] A regex search result for the trigger-text (default: this.searching) - * @param {string} [text] String to perform the replacement on (default: ngModel.$viewValue) - * @return {string} Human-readable string - */ - this.replace = function (mention) { - var search = arguments.length <= 1 || arguments[1] === undefined ? this.searching : arguments[1]; - var text = arguments.length <= 2 || arguments[2] === undefined ? ngModel.$viewValue : arguments[2]; - - // TODO: come up with a better way to detect what to remove - // TODO: consider alternative to using regex match - text = text.substr(0, search.index + search[0].indexOf('@')) + this.label(mention) + ' ' + text.substr(search.index + search[0].length); - return text; - }; - - /** - * $mention.select() - * - * Adds a choice to this.mentions collection and updates the view - * - * @param {mixed|object} [choice] The selected choice (default: activeChoice) - */ - this.select = function () { - var choice = arguments.length <= 0 || arguments[0] === undefined ? this.activeChoice : arguments[0]; - - // Add the mention - this.mentions.push(choice); - - // Replace the search with the label - ngModel.$setViewValue(this.replace(choice)); - - // Close choices panel - this.cancel(); - - // Update the textarea - ngModel.$render(); - }; - - /** - * $mention.up() - * - * Moves this.activeChoice up the this.choices collection - */ - this.up = function () { - var index = this.choices.indexOf(this.activeChoice); - if (index > 0) { - this.activeChoice = this.choices[index - 1]; - } else { - this.activeChoice = this.choices[this.choices.length - 1]; - } - }; - - /** - * $mention.down() - * - * Moves this.activeChoice down the this.choices collection - */ - this.down = function () { - var index = this.choices.indexOf(this.activeChoice); - if (index < this.choices.length - 1) { - this.activeChoice = this.choices[index + 1]; - } else { - this.activeChoice = this.choices[0]; - } - }; - - /** - * $mention.search() - * - * Searches for a list of mention choices and populates - * $mention.choices and $mention.activeChoice - * - * @param {regex.exec()} match The trigger-text regex match object - * @todo Try to avoid using a regex match object - */ - this.search = function (match) { - var _this3 = this; - - this.searching = match; - - return $q.when(this.findChoices(match, this.mentions)).then(function (choices) { - _this3.choices = choices; - _this3.activeChoice = choices[0]; - return choices; - }); - }; - - /** - * $mention.findChoices() - * - * @param {regex.exec()} match The trigger-text regex match object - * @todo Try to avoid using a regex match object - * @todo Make it easier to override this - * @return {array[choice]|Promise} The list of possible choices - */ - this.findChoices = function (match, mentions) { - return []; - }; - - /** - * $mention.cancel() - * - * Clears the choices dropdown info and stops searching - */ - this.cancel = function () { - this.choices = []; - this.searching = null; - }; - - this.autogrow = function () { - $element[0].style.height = 0; // autoshrink - need accurate scrollHeight - var style = getComputedStyle($element[0]); - if (style.boxSizing == 'border-box') $element[0].style.height = $element[0].scrollHeight + 'px'; - }; - - // Interactions to trigger searching - $element.on('keyup click focus', function (event) { - // If event is fired AFTER activeChoice move is performed - if (_this2.moved) return _this2.moved = false; - // Don't trigger on selection - if ($element[0].selectionStart != $element[0].selectionEnd) return; - var text = $element.val(); - // text to left of cursor ends with `@sometext` - var match = _this2.pattern.exec(text.substr(0, $element[0].selectionStart)); - if (match) { - _this2.search(match); - } else { - _this2.cancel(); - } + } + }; +}); +'use strict'; - $scope.$apply(); +angular.module('ui.mention').controller('uiMentionController', function ($element, $scope, $attrs, $q, $timeout, $document) { + var _this2 = this; + + // Beginning of input or preceeded by spaces: @sometext + this.pattern = this.pattern || /(?:\s+|^)@(\w+(?: \w+)?)$/; + this.$element = $element; + this.choices = []; + this.mentions = []; + var ngModel; + + /** + * $mention.init() + * + * Initializes the plugin by setting up the ngModelController properties + * + * @param {ngModelController} model + */ + this.init = function (model) { + var _this = this; + + // Leading whitespace shows up in the textarea but not the preview + $attrs.ngTrim = 'false'; + + ngModel = model; + + ngModel.$parsers.push(function (value) { + // Removes any mentions that aren't used + _this.mentions = _this.mentions.filter(function (mention) { + if (~value.indexOf(_this.label(mention))) return value = value.replace(_this.label(mention), _this.encode(mention)); }); - $element.on('keydown', function (event) { - if (!_this2.searching) return; - - switch (event.keyCode) { - case 13: - // return - _this2.select(); - break; - case 38: - // up - _this2.up(); - break; - case 40: - // down - _this2.down(); - break; - default: - // Exit function - return; - } + _this.render(value); + + return value; + }); - _this2.moved = true; - event.preventDefault(); + ngModel.$formatters.push(function () { + var value = arguments.length <= 0 || arguments[0] === undefined ? '' : arguments[0]; - $scope.$apply(); + // In case the value is a different primitive + value = value.toString(); + + // Removes any mentions that aren't used + _this.mentions = _this.mentions.filter(function (mention) { + if (~value.indexOf(_this.encode(mention))) { + value = value.replace(_this.encode(mention), _this.label(mention)); + return true; + } else { + return false; + } }); - this.onMouseup = (function (event) { - var _this4 = this; + return value; + }); - if (event.target == $element[0]) return; + ngModel.$render = function () { + $element.val(ngModel.$viewValue || ''); + _this.render(); + }; + }; - $document.off('mouseup', this.onMouseup); + /** + * $mention.render() + * + * Renders the syntax-encoded version to an HTML element for 'highlighting' effect + * + * @param {string} [text] syntax encoded string (default: ngModel.$modelValue) + * @return {string} HTML string + */ + this.render = function () { + var html = arguments.length <= 0 || arguments[0] === undefined ? ngModel.$modelValue : arguments[0]; + + html = (html || '').toString(); + _this2.mentions.forEach(function (mention) { + html = html.replace(_this2.encode(mention), _this2.highlight(mention)); + }); + $element.next().html(html); + return html; + }; - if (!this.searching) return; + /** + * $mention.highlight() + * + * Returns a choice in HTML highlight formatting + * + * @param {mixed|object} choice The choice to be highlighted + * @return {string} HTML highlighted version of the choice + */ + this.highlight = function (choice) { + return '' + this.label(choice) + ''; + }; - // Let ngClick fire first - $scope.$evalAsync(function () { - _this4.cancel(); - }); - }).bind(this); + /** + * $mention.decode() + * + * @note NOT CURRENTLY USED + * @param {string} [text] syntax encoded string (default: ngModel.$modelValue) + * @return {string} plaintext string with encoded labels used + */ + this.decode = function () { + var value = arguments.length <= 0 || arguments[0] === undefined ? ngModel.$modelValue : arguments[0]; + + return value ? value.replace(/@\[([\s\w]+):[0-9a-z-]+\]/gi, '$1') : ''; + }; - $element.on('focus', function (event) { - $document.on('mouseup', _this2.onMouseup); - }); + /** + * $mention.label() + * + * Converts a choice object to a human-readable string + * + * @param {mixed|object} choice The choice to be rendered + * @return {string} Human-readable string version of choice + */ + this.label = function (choice) { + return choice.first + ' ' + choice.last; + }; + + /** + * $mention.encode() + * + * Converts a choice object to a syntax-encoded string + * + * @param {mixed|object} choice The choice to be encoded + * @return {string} Syntax-encoded string version of choice + */ + this.encode = function (choice) { + return '@[' + this.label(choice) + ':' + choice.id + ']'; + }; + + /** + * $mention.replace() + * + * Replaces the trigger-text with the mention label + * + * @param {mixed|object} mention The choice to replace with + * @param {regex.exec()} [search] A regex search result for the trigger-text (default: this.searching) + * @param {string} [text] String to perform the replacement on (default: ngModel.$viewValue) + * @return {string} Human-readable string + */ + this.replace = function (mention) { + var search = arguments.length <= 1 || arguments[1] === undefined ? this.searching : arguments[1]; + var text = arguments.length <= 2 || arguments[2] === undefined ? ngModel.$viewValue : arguments[2]; + + // TODO: come up with a better way to detect what to remove + // TODO: consider alternative to using regex match + text = text.substr(0, search.index + search[0].indexOf('@')) + this.label(mention) + ' ' + text.substr(search.index + search[0].length); + return text; + }; + + /** + * $mention.select() + * + * Adds a choice to this.mentions collection and updates the view + * + * @param {mixed|object} [choice] The selected choice (default: activeChoice) + */ + this.select = function () { + var choice = arguments.length <= 0 || arguments[0] === undefined ? this.activeChoice : arguments[0]; + + // Add the mention + this.mentions.push(choice); + + // Replace the search with the label + ngModel.$setViewValue(this.replace(choice)); - // Autogrow is mandatory beacuse the textarea scrolls away from highlights - $element.on('input', this.autogrow); - // Initialize autogrow height - $timeout(this.autogrow, true); + // Close choices panel + this.cancel(); + + // Update the textarea + ngModel.$render(); + }; + + /** + * $mention.up() + * + * Moves this.activeChoice up the this.choices collection + */ + this.up = function () { + var index = this.choices.indexOf(this.activeChoice); + if (index > 0) { + this.activeChoice = this.choices[index - 1]; + } else { + this.activeChoice = this.choices[this.choices.length - 1]; } }; + + /** + * $mention.down() + * + * Moves this.activeChoice down the this.choices collection + */ + this.down = function () { + var index = this.choices.indexOf(this.activeChoice); + if (index < this.choices.length - 1) { + this.activeChoice = this.choices[index + 1]; + } else { + this.activeChoice = this.choices[0]; + } + }; + + /** + * $mention.search() + * + * Searches for a list of mention choices and populates + * $mention.choices and $mention.activeChoice + * + * @param {regex.exec()} match The trigger-text regex match object + * @todo Try to avoid using a regex match object + */ + this.search = function (match) { + var _this3 = this; + + this.searching = match; + + return $q.when(this.findChoices(match, this.mentions)).then(function (choices) { + _this3.choices = choices; + _this3.activeChoice = choices[0]; + return choices; + }); + }; + + /** + * $mention.findChoices() + * + * @param {regex.exec()} match The trigger-text regex match object + * @todo Try to avoid using a regex match object + * @todo Make it easier to override this + * @return {array[choice]|Promise} The list of possible choices + */ + this.findChoices = function (match, mentions) { + return []; + }; + + /** + * $mention.cancel() + * + * Clears the choices dropdown info and stops searching + */ + this.cancel = function () { + this.choices = []; + this.searching = null; + }; + + this.autogrow = function () { + $element[0].style.height = 0; // autoshrink - need accurate scrollHeight + var style = getComputedStyle($element[0]); + if (style.boxSizing == 'border-box') $element[0].style.height = $element[0].scrollHeight + 'px'; + }; + + // Interactions to trigger searching + $element.on('keyup click focus', function (event) { + // If event is fired AFTER activeChoice move is performed + if (_this2.moved) return _this2.moved = false; + // Don't trigger on selection + if ($element[0].selectionStart != $element[0].selectionEnd) return; + var text = $element.val(); + // text to left of cursor ends with `@sometext` + var match = _this2.pattern.exec(text.substr(0, $element[0].selectionStart)); + if (match) { + _this2.search(match); + } else { + _this2.cancel(); + } + + $scope.$apply(); + }); + + $element.on('keydown', function (event) { + if (!_this2.searching) return; + + switch (event.keyCode) { + case 13: + // return + _this2.select(); + break; + case 38: + // up + _this2.up(); + break; + case 40: + // down + _this2.down(); + break; + default: + // Exit function + return; + } + + _this2.moved = true; + event.preventDefault(); + + $scope.$apply(); + }); + + this.onMouseup = (function (event) { + var _this4 = this; + + if (event.target == $element[0]) return; + + $document.off('mouseup', this.onMouseup); + + if (!this.searching) return; + + // Let ngClick fire first + $scope.$evalAsync(function () { + _this4.cancel(); + }); + }).bind(this); + + $element.on('focus', function (event) { + $document.on('mouseup', _this2.onMouseup); + }); + + // Autogrow is mandatory beacuse the textarea scrolls away from highlights + $element.on('input', this.autogrow); + // Initialize autogrow height + $timeout(this.autogrow, true); }); \ No newline at end of file diff --git a/dist/mention.min.js b/dist/mention.min.js index 8cd634f..f4d9bdb 100644 --- a/dist/mention.min.js +++ b/dist/mention.min.js @@ -1 +1 @@ -"use strict";var _slicedToArray=function(){function sliceIterator(arr,i){var _arr=[],_n=!0,_d=!1,_e=void 0;try{for(var _s,_i=arr[Symbol.iterator]();!(_n=(_s=_i.next()).done)&&(_arr.push(_s.value),!i||_arr.length!==i);_n=!0);}catch(err){_d=!0,_e=err}finally{try{!_n&&_i["return"]&&_i["return"]()}finally{if(_d)throw _e}}return _arr}return function(arr,i){if(Array.isArray(arr))return arr;if(Symbol.iterator in Object(arr))return sliceIterator(arr,i);throw new TypeError("Invalid attempt to destructure non-iterable instance")}}();angular.module("ui.mention",[]).directive("uiMention",function($q,$timeout,$document){return{require:["ngModel","uiMention"],controllerAs:"$mention",link:function($scope,$element,$attrs,_ref){var _ref2=_slicedToArray(_ref,2),ngModel=_ref2[0],uiMention=_ref2[1];uiMention.init(ngModel)},controller:function($element,$scope,$attrs){var _this2=this;this.pattern=this.pattern||/(?:\s+|^)@(\w+(?: \w+)?)$/,this.$element=$element,this.choices=[],this.mentions=[];var ngModel;this.init=function(model){var _this=this;$attrs.ngTrim="false",ngModel=model,ngModel.$parsers.push(function(value){return _this.mentions=_this.mentions.filter(function(mention){return~value.indexOf(_this.label(mention))?value=value.replace(_this.label(mention),_this.encode(mention)):void 0}),_this.render(value),value}),ngModel.$formatters.push(function(){var value=arguments.length<=0||void 0===arguments[0]?"":arguments[0];return value=value.toString(),_this.mentions=_this.mentions.filter(function(mention){return~value.indexOf(_this.encode(mention))?(value=value.replace(_this.encode(mention),_this.label(mention)),!0):!1}),value}),ngModel.$render=function(){$element.val(ngModel.$viewValue||""),_this.render()}},this.render=function(){var html=arguments.length<=0||void 0===arguments[0]?ngModel.$modelValue:arguments[0];return html=(html||"").toString(),_this2.mentions.forEach(function(mention){html=html.replace(_this2.encode(mention),_this2.highlight(mention))}),$element.next().html(html),html},this.highlight=function(choice){return""+this.label(choice)+""},this.decode=function(){var value=arguments.length<=0||void 0===arguments[0]?ngModel.$modelValue:arguments[0];return value?value.replace(/@\[([\s\w]+):[0-9a-z-]+\]/gi,"$1"):""},this.label=function(choice){return choice.first+" "+choice.last},this.encode=function(choice){return"@["+this.label(choice)+":"+choice.id+"]"},this.replace=function(mention){var search=arguments.length<=1||void 0===arguments[1]?this.searching:arguments[1],text=arguments.length<=2||void 0===arguments[2]?ngModel.$viewValue:arguments[2];return text=text.substr(0,search.index+search[0].indexOf("@"))+this.label(mention)+" "+text.substr(search.index+search[0].length)},this.select=function(){var choice=arguments.length<=0||void 0===arguments[0]?this.activeChoice:arguments[0];this.mentions.push(choice),ngModel.$setViewValue(this.replace(choice)),this.cancel(),ngModel.$render()},this.up=function(){var index=this.choices.indexOf(this.activeChoice);this.activeChoice=index>0?this.choices[index-1]:this.choices[this.choices.length-1]},this.down=function(){var index=this.choices.indexOf(this.activeChoice);this.activeChoice=index"+this.label(choice)+""},this.decode=function(){var value=arguments.length<=0||void 0===arguments[0]?ngModel.$modelValue:arguments[0];return value?value.replace(/@\[([\s\w]+):[0-9a-z-]+\]/gi,"$1"):""},this.label=function(choice){return choice.first+" "+choice.last},this.encode=function(choice){return"@["+this.label(choice)+":"+choice.id+"]"},this.replace=function(mention){var search=arguments.length<=1||void 0===arguments[1]?this.searching:arguments[1],text=arguments.length<=2||void 0===arguments[2]?ngModel.$viewValue:arguments[2];return text=text.substr(0,search.index+search[0].indexOf("@"))+this.label(mention)+" "+text.substr(search.index+search[0].length)},this.select=function(){var choice=arguments.length<=0||void 0===arguments[0]?this.activeChoice:arguments[0];this.mentions.push(choice),ngModel.$setViewValue(this.replace(choice)),this.cancel(),ngModel.$render()},this.up=function(){var index=this.choices.indexOf(this.activeChoice);this.activeChoice=index>0?this.choices[index-1]:this.choices[this.choices.length-1]},this.down=function(){var index=this.choices.indexOf(this.activeChoice);this.activeChoice=index { - // Removes any mentions that aren't used - this.mentions = this.mentions.filter( mention => { - if (~value.indexOf(this.label(mention))) - return value = value.replace(this.label(mention), this.encode(mention)); - }); - - this.render(value); - - return value; - }); - - ngModel.$formatters.push( (value = '') => { - // In case the value is a different primitive - value = value.toString(); - - // Removes any mentions that aren't used - this.mentions = this.mentions.filter( mention => { - if (~value.indexOf(this.encode(mention))) { - value = value.replace(this.encode(mention), this.label(mention)); - return true; - } else { - return false; - } - }); - - return value; - }); - - ngModel.$render = () => { - $element.val(ngModel.$viewValue || ''); - this.render(); - }; - }; - - /** - * $mention.render() - * - * Renders the syntax-encoded version to an HTML element for 'highlighting' effect - * - * @param {string} [text] syntax encoded string (default: ngModel.$modelValue) - * @return {string} HTML string - */ - this.render = (html = ngModel.$modelValue) => { - html = (html || '').toString(); - this.mentions.forEach( mention => { - html = html.replace(this.encode(mention), this.highlight(mention)); - }); - $element.next().html(html); - return html; - }; - - /** - * $mention.highlight() - * - * Returns a choice in HTML highlight formatting - * - * @param {mixed|object} choice The choice to be highlighted - * @return {string} HTML highlighted version of the choice - */ - this.highlight = function(choice) { - return `${this.label(choice)}`; - }; - - /** - * $mention.decode() - * - * @note NOT CURRENTLY USED - * @param {string} [text] syntax encoded string (default: ngModel.$modelValue) - * @return {string} plaintext string with encoded labels used - */ - this.decode = function(value = ngModel.$modelValue) { - return value ? value.replace(/@\[([\s\w]+):[0-9a-z-]+\]/gi, '$1') : ''; - }; - - /** - * $mention.label() - * - * Converts a choice object to a human-readable string - * - * @param {mixed|object} choice The choice to be rendered - * @return {string} Human-readable string version of choice - */ - this.label = function(choice) { - return `${choice.first} ${choice.last}`; - }; - - /** - * $mention.encode() - * - * Converts a choice object to a syntax-encoded string - * - * @param {mixed|object} choice The choice to be encoded - * @return {string} Syntax-encoded string version of choice - */ - this.encode = function(choice) { - return `@[${this.label(choice)}:${choice.id}]`; - }; - - /** - * $mention.replace() - * - * Replaces the trigger-text with the mention label - * - * @param {mixed|object} mention The choice to replace with - * @param {regex.exec()} [search] A regex search result for the trigger-text (default: this.searching) - * @param {string} [text] String to perform the replacement on (default: ngModel.$viewValue) - * @return {string} Human-readable string - */ - this.replace = function(mention, search = this.searching, text = ngModel.$viewValue) { - // TODO: come up with a better way to detect what to remove - // TODO: consider alternative to using regex match - text = text.substr(0, search.index + search[0].indexOf('@')) + - this.label(mention) + ' ' + - text.substr(search.index + search[0].length); - return text; - }; - - /** - * $mention.select() - * - * Adds a choice to this.mentions collection and updates the view - * - * @param {mixed|object} [choice] The selected choice (default: activeChoice) - */ - this.select = function(choice = this.activeChoice) { - // Add the mention - this.mentions.push(choice); - - // Replace the search with the label - ngModel.$setViewValue(this.replace(choice)); - - // Close choices panel - this.cancel(); - - // Update the textarea - ngModel.$render(); - }; - - /** - * $mention.up() - * - * Moves this.activeChoice up the this.choices collection - */ - this.up = function() { - let index = this.choices.indexOf(this.activeChoice); - if (index > 0) { - this.activeChoice = this.choices[index - 1]; - } else { - this.activeChoice = this.choices[this.choices.length - 1]; - } - }; - - /** - * $mention.down() - * - * Moves this.activeChoice down the this.choices collection - */ - this.down = function() { - let index = this.choices.indexOf(this.activeChoice); - if (index < this.choices.length - 1) { - this.activeChoice = this.choices[index + 1]; - } else { - this.activeChoice = this.choices[0]; - } - }; - - /** - * $mention.search() - * - * Searches for a list of mention choices and populates - * $mention.choices and $mention.activeChoice - * - * @param {regex.exec()} match The trigger-text regex match object - * @todo Try to avoid using a regex match object - */ - this.search = function(match) { - this.searching = match; - - return $q.when( this.findChoices(match, this.mentions) ) - .then( choices => { - this.choices = choices; - this.activeChoice = choices[0]; - return choices; - }); - }; - - /** - * $mention.findChoices() - * - * @param {regex.exec()} match The trigger-text regex match object - * @todo Try to avoid using a regex match object - * @todo Make it easier to override this - * @return {array[choice]|Promise} The list of possible choices - */ - this.findChoices = function(match, mentions) { - return []; - }; - - /** - * $mention.cancel() - * - * Clears the choices dropdown info and stops searching - */ - this.cancel = function() { - this.choices = []; - this.searching = null; - }; - - this.autogrow = function() { - $element[0].style.height = 0; // autoshrink - need accurate scrollHeight - let style = getComputedStyle($element[0]); - if (style.boxSizing == 'border-box') - $element[0].style.height = $element[0].scrollHeight + 'px'; - }; - - // Interactions to trigger searching - $element.on('keyup click focus', event => { - // If event is fired AFTER activeChoice move is performed - if (this.moved) - return this.moved = false; - // Don't trigger on selection - if ($element[0].selectionStart != $element[0].selectionEnd) - return; - let text = $element.val(); - // text to left of cursor ends with `@sometext` - let match = this.pattern.exec(text.substr(0, $element[0].selectionStart)); - if (match) { - this.search(match); - } else { - this.cancel(); - } - - $scope.$apply(); - }); - - $element.on('keydown', event => { - if (!this.searching) - return; - - switch (event.keyCode) { - case 13: // return - this.select(); - break; - case 38: // up - this.up(); - break; - case 40: // down - this.down(); - break; - default: - // Exit function - return; - } - - this.moved = true; - event.preventDefault(); - - $scope.$apply(); - }); - - - - this.onMouseup = (function(event) { - if (event.target == $element[0]) - return - - $document.off('mouseup', this.onMouseup); - - if (!this.searching) - return; - - // Let ngClick fire first - $scope.$evalAsync( () => { - this.cancel(); - }); - }).bind(this); - - $element.on('focus', event => { - $document.on('mouseup', this.onMouseup); - }); - - // Autogrow is mandatory beacuse the textarea scrolls away from highlights - $element.on('input', this.autogrow); - // Initialize autogrow height - $timeout(this.autogrow, true); } }; }); diff --git a/src/mentionController.es6.js b/src/mentionController.es6.js new file mode 100644 index 0000000..2c1450f --- /dev/null +++ b/src/mentionController.es6.js @@ -0,0 +1,312 @@ +angular.module('ui.mention') +.controller('uiMentionController', function ( + $element, $scope, $attrs, $q, $timeout, $document + ) { + + // Beginning of input or preceeded by spaces: @sometext + this.pattern = this.pattern || /(?:\s+|^)@(\w+(?: \w+)?)$/; + this.$element = $element; + this.choices = []; + this.mentions = []; + var ngModel; + + /** + * $mention.init() + * + * Initializes the plugin by setting up the ngModelController properties + * + * @param {ngModelController} model + */ + this.init = function(model) { + // Leading whitespace shows up in the textarea but not the preview + $attrs.ngTrim = 'false'; + + ngModel = model; + + ngModel.$parsers.push( value => { + // Removes any mentions that aren't used + this.mentions = this.mentions.filter( mention => { + if (~value.indexOf(this.label(mention))) + return value = value.replace(this.label(mention), this.encode(mention)); + }); + + this.render(value); + + return value; + }); + + ngModel.$formatters.push( (value = '') => { + // In case the value is a different primitive + value = value.toString(); + + // Removes any mentions that aren't used + this.mentions = this.mentions.filter( mention => { + if (~value.indexOf(this.encode(mention))) { + value = value.replace(this.encode(mention), this.label(mention)); + return true; + } else { + return false; + } + }); + + return value; + }); + + ngModel.$render = () => { + $element.val(ngModel.$viewValue || ''); + this.render(); + }; + }; + + /** + * $mention.render() + * + * Renders the syntax-encoded version to an HTML element for 'highlighting' effect + * + * @param {string} [text] syntax encoded string (default: ngModel.$modelValue) + * @return {string} HTML string + */ + this.render = (html = ngModel.$modelValue) => { + html = (html || '').toString(); + this.mentions.forEach( mention => { + html = html.replace(this.encode(mention), this.highlight(mention)); + }); + $element.next().html(html); + return html; + }; + + /** + * $mention.highlight() + * + * Returns a choice in HTML highlight formatting + * + * @param {mixed|object} choice The choice to be highlighted + * @return {string} HTML highlighted version of the choice + */ + this.highlight = function(choice) { + return `${this.label(choice)}`; + }; + + /** + * $mention.decode() + * + * @note NOT CURRENTLY USED + * @param {string} [text] syntax encoded string (default: ngModel.$modelValue) + * @return {string} plaintext string with encoded labels used + */ + this.decode = function(value = ngModel.$modelValue) { + return value ? value.replace(/@\[([\s\w]+):[0-9a-z-]+\]/gi, '$1') : ''; + }; + + /** + * $mention.label() + * + * Converts a choice object to a human-readable string + * + * @param {mixed|object} choice The choice to be rendered + * @return {string} Human-readable string version of choice + */ + this.label = function(choice) { + return `${choice.first} ${choice.last}`; + }; + + /** + * $mention.encode() + * + * Converts a choice object to a syntax-encoded string + * + * @param {mixed|object} choice The choice to be encoded + * @return {string} Syntax-encoded string version of choice + */ + this.encode = function(choice) { + return `@[${this.label(choice)}:${choice.id}]`; + }; + + /** + * $mention.replace() + * + * Replaces the trigger-text with the mention label + * + * @param {mixed|object} mention The choice to replace with + * @param {regex.exec()} [search] A regex search result for the trigger-text (default: this.searching) + * @param {string} [text] String to perform the replacement on (default: ngModel.$viewValue) + * @return {string} Human-readable string + */ + this.replace = function(mention, search = this.searching, text = ngModel.$viewValue) { + // TODO: come up with a better way to detect what to remove + // TODO: consider alternative to using regex match + text = text.substr(0, search.index + search[0].indexOf('@')) + + this.label(mention) + ' ' + + text.substr(search.index + search[0].length); + return text; + }; + + /** + * $mention.select() + * + * Adds a choice to this.mentions collection and updates the view + * + * @param {mixed|object} [choice] The selected choice (default: activeChoice) + */ + this.select = function(choice = this.activeChoice) { + // Add the mention + this.mentions.push(choice); + + // Replace the search with the label + ngModel.$setViewValue(this.replace(choice)); + + // Close choices panel + this.cancel(); + + // Update the textarea + ngModel.$render(); + }; + + /** + * $mention.up() + * + * Moves this.activeChoice up the this.choices collection + */ + this.up = function() { + let index = this.choices.indexOf(this.activeChoice); + if (index > 0) { + this.activeChoice = this.choices[index - 1]; + } else { + this.activeChoice = this.choices[this.choices.length - 1]; + } + }; + + /** + * $mention.down() + * + * Moves this.activeChoice down the this.choices collection + */ + this.down = function() { + let index = this.choices.indexOf(this.activeChoice); + if (index < this.choices.length - 1) { + this.activeChoice = this.choices[index + 1]; + } else { + this.activeChoice = this.choices[0]; + } + }; + + /** + * $mention.search() + * + * Searches for a list of mention choices and populates + * $mention.choices and $mention.activeChoice + * + * @param {regex.exec()} match The trigger-text regex match object + * @todo Try to avoid using a regex match object + */ + this.search = function(match) { + this.searching = match; + + return $q.when( this.findChoices(match, this.mentions) ) + .then( choices => { + this.choices = choices; + this.activeChoice = choices[0]; + return choices; + }); + }; + + /** + * $mention.findChoices() + * + * @param {regex.exec()} match The trigger-text regex match object + * @todo Try to avoid using a regex match object + * @todo Make it easier to override this + * @return {array[choice]|Promise} The list of possible choices + */ + this.findChoices = function(match, mentions) { + return []; + }; + + /** + * $mention.cancel() + * + * Clears the choices dropdown info and stops searching + */ + this.cancel = function() { + this.choices = []; + this.searching = null; + }; + + this.autogrow = function() { + $element[0].style.height = 0; // autoshrink - need accurate scrollHeight + let style = getComputedStyle($element[0]); + if (style.boxSizing == 'border-box') + $element[0].style.height = $element[0].scrollHeight + 'px'; + }; + + // Interactions to trigger searching + $element.on('keyup click focus', event => { + // If event is fired AFTER activeChoice move is performed + if (this.moved) + return this.moved = false; + // Don't trigger on selection + if ($element[0].selectionStart != $element[0].selectionEnd) + return; + let text = $element.val(); + // text to left of cursor ends with `@sometext` + let match = this.pattern.exec(text.substr(0, $element[0].selectionStart)); + if (match) { + this.search(match); + } else { + this.cancel(); + } + + $scope.$apply(); + }); + + $element.on('keydown', event => { + if (!this.searching) + return; + + switch (event.keyCode) { + case 13: // return + this.select(); + break; + case 38: // up + this.up(); + break; + case 40: // down + this.down(); + break; + default: + // Exit function + return; + } + + this.moved = true; + event.preventDefault(); + + $scope.$apply(); + }); + + + + this.onMouseup = (function(event) { + if (event.target == $element[0]) + return + + $document.off('mouseup', this.onMouseup); + + if (!this.searching) + return; + + // Let ngClick fire first + $scope.$evalAsync( () => { + this.cancel(); + }); + }).bind(this); + + $element.on('focus', event => { + $document.on('mouseup', this.onMouseup); + }); + + // Autogrow is mandatory beacuse the textarea scrolls away from highlights + $element.on('input', this.autogrow); + // Initialize autogrow height + $timeout(this.autogrow, true); +}); diff --git a/test/uiMentionController.spec.js b/test/uiMentionController.spec.js new file mode 100644 index 0000000..b9e0238 --- /dev/null +++ b/test/uiMentionController.spec.js @@ -0,0 +1,503 @@ +describe('uiMentionController', () => { + let $scope, $attrs, $q, $timeout, $document, createController, ngModelController; + + beforeEach(() => { + module('ui.mention'); + + inject(($injector, $controller) => { + $scope = $injector.get('$rootScope').$new(); + $q = $injector.get('$q'); + $timeout = $injector.get('$timeout'); + $document = $injector.get('$document'); + $attrs = {}; + + createController = (el) => { + return $controller('uiMentionController', { + $scope: $scope, + $attrs: $attrs, + $element: el, + $q: $q, + $timeout: $timeout, + $document: $document + }); + }; + }); + }); + + context('on invocation', () => { + let ctrlInstance, $element; + + beforeEach(() => { + $element = angular.element(''); + ctrlInstance = createController($element); + }); + + it('exposes a pattern', () => { + expect(ctrlInstance.pattern).to.eql(/(?:\s+|^)@(\w+(?: \w+)?)$/); + }); + + it('exposes the given $element', () => { + expect(ctrlInstance.$element).to.eq($element); + }); + + it('exposes an array of choices', () => { + expect(ctrlInstance.choices).to.eql([]); + }); + + it('exposes an array of mentions', () => { + expect(ctrlInstance.mentions).to.eql([]); + }); + }); + + context('public API', function () { + let ctrlInstance, $element; + + beforeEach(function () { + $scope.model = 'bar'; + $element = angular.element(''); + ctrlInstance = createController($element); + $scope.$digest(); + }); + + [ + 'init', 'render', 'highlight', 'decode', 'label', 'encode', 'replace', + 'select', 'up', 'down', 'search', 'findChoices', 'cancel', 'autogrow' + ].forEach((fn) => { + it(fn + ' is a public API method on ' + ctrlInstance, () => { + expect(ctrlInstance).to.have.property(fn).that.is.a('function'); + }); + }); + + context('.init()', () => { + let ngModel, mentions; + + beforeEach(() => { + ngModel = { $parsers: [], $formatters: [] }; + mentions = [{ id: 1, first: 'foo', last: 'bar' }, { id: 2, first: 'k', last: 'v' }]; + ctrlInstance.init(ngModel); + ctrlInstance.mentions = mentions; + }); + + it('sets $attrs.ngTrim to false', () => { + expect($attrs.ngTrim).to.eq('false'); + }); + + context('ngModel.$parsers', () => { + let $parsers, mentionParser; + + beforeEach(() => { + $parsers = ngModel.$parsers; + mentionParser = $parsers[0]; + }); + + it('received a new $parser', () => { + expect($parsers.length).to.eq(1); + }); + + it('sets up a mentions property on the controller instance', () => { + mentionParser(''); + expect(ctrlInstance).to.have.property('mentions').that.is.an('array'); + }); + + it('returns the given value', () => { + expect(mentionParser('foo bar')).to.eq('@[foo bar:1]'); + }); + + it('filters out non matching mentions', () => { + mentionParser('foo bar'); + expect(ctrlInstance.mentions).to.eql(mentions.slice(0,1)); + }); + + it('updates the HTML content of the adjacent DOM element', () => { + mentionParser('foo bar'); + expect($element.next().html()).to.eq('foo bar'); + }); + }); + + context('ngModel.$formatters', () => { + let $formatters, formatter; + + beforeEach(() => { + $formatters = ngModel.$formatters; + formatter = $formatters[0]; + }); + + it('received a new $formatter', () => { + expect($formatters.length).to.eq(1); + }); + + it('returns an empty string by default', () => { + expect(formatter()).to.eq(''); + }); + + it('casts any non-string argument to a string', () => { + expect(formatter(123)).to.eq('123'); + expect(formatter(false)).to.eq('false'); + expect(formatter(true)).to.eq('true'); + expect(formatter({})).to.eq('[object Object]'); + expect(formatter([])).to.eq(''); + }); + + it('filters out non matching mentions', () => { + expect(ctrlInstance.mentions).to.include(mentions[1]); + formatter('@[foo bar:1]'); + expect(ctrlInstance.mentions).to.not.include(mentions[1]); + }); + + it('returns an encoded version of the passed value', () => { + expect(formatter('@[foo bar:1]')).to.eq('foo bar'); + }); + }); + + context('ngModel.$render', () => { + it('sets the val property of $element to ngModel.$viewValue', () => { + ngModel.$viewValue = 'wat'; + ngModel.$render(); + expect($element.val()).to.eq('wat'); + }); + + it('defaults to an empty string', () => { + ngModel.$render(); + expect($element.val()).to.eq(''); + }); + + it('updates the HTML content of the adjacent DOM element', () => { + ngModel.$modelValue = '@[foo bar:1]'; + ngModel.$render(); + expect($element.next().html()).to.eq('foo bar'); + }); + }); + }); + + context('.render()', () => { + let ngModel, mentions; + + beforeEach(() => { + ngModel = { $parsers: [], $formatters: [] }; + mentions = [{ id: 1, first: 'foo', last: 'bar' }, { id: 2, first: 'k', last: 'v' }]; + + $element = angular.element(''); + ctrlInstance = createController($element); + $scope.$digest(); + + ctrlInstance.init(ngModel); + ctrlInstance.mentions = mentions; + }); + + it('the default argument is ngModel.$modelValue if no other was passed', () => { + ngModel.$modelValue = 'nope'; + expect(ctrlInstance.render()).to.eq('nope'); + }); + + it('casts the given argument to a string', () => { + expect(ctrlInstance.render(123)).to.eq('123'); + }); + + it('converts a syntax encoded string to HTML', () => { + ngModel.$modelValue = '@[foo bar:1] @[k v:2]'; + expect(ctrlInstance.render()).to.eq('foo bar k v'); + }); + + it('does not convert non-mentions', () => { + ngModel.$modelValue = '@[wat nope:123]'; + expect(ctrlInstance.render()).to.not.eq('wat nope'); + }); + + it('replaces the html of $element.next with the converted value', () => { + ngModel.$modelValue = '@[foo bar:1] @[k v:2]'; + ctrlInstance.render(); + expect($element.next().html()).to.eq('foo bar k v') + }); + }); + + context('.highlight()', () => { + let choice; + + beforeEach(() => { + choice = { first: 'x', last: 'y' }; + }); + + it('returns an HTML formatted version of the given argument', () => { + expect(ctrlInstance.highlight(choice)).to.eq('x y'); + }); + }); + + context.skip('.decode()', () => { + /** Untested - NOT CURRENTLY USED **/ + }); + + context('.label()', () => { + it('converts the given object to a readable string', () => { + expect(ctrlInstance.label({ first: 0, last: 1 })).to.eq('0 1'); + }); + }); + + context('.encode()', () => { + it('encodes the given object to a syntax encoded string', () => { + let choice = { first: 'x', last: 'y', id: 123 }; + expect(ctrlInstance.encode(choice)).to.eq('@[x y:123]'); + }); + }); + + context.skip('.replace()', () => { + /** Untested - marked with @TODO's **/ + }); + + context('.select()', () => { + let ngModel, mentions; + + beforeEach(() => { + ngModel = { $parsers: [], $formatters: [], $setViewValue: sinon.stub() }; + ctrlInstance.init(ngModel); + ctrlInstance.searching = ['']; + ngModel.$viewValue = 'foo'; + }); + + it('adds a mention to the current mentions', () => { + expect(ctrlInstance.mentions.length).to.eq(0); + ctrlInstance.select({ first: 'foo', last: 'bar' }); + expect(ctrlInstance.mentions[0]).to.eql({ first: 'foo', last: 'bar' }); + }); + + it('clears the controller choices', () => { + ctrlInstance.select({ first: 'foo', last: 'bar' }); + expect(ctrlInstance.choices).to.eql([]); + }); + + it('sets the searching regex to null', () => { + ctrlInstance.select({ first: 'foo', last: 'bar' }); + expect(ctrlInstance.searching).to.eq(null); + }); + + it('returns nothing', () => { + expect(ctrlInstance.select({ first: 'foo', last: 'bar' })).to.eq(undefined); + }); + }); + + context('.up()', () => { + it('moves the activeChoice up in the choices collection', () => { + let choices = [{ id: 1 }, { id: 2 }, { id: 3 }]; + ctrlInstance.choices = choices; + ctrlInstance.activeChoice = choices[1]; + + ctrlInstance.up(); + expect(ctrlInstance.activeChoice).to.eq(choices[0]); + + ctrlInstance.up(); + expect(ctrlInstance.activeChoice).to.eq(choices[2]); + + ctrlInstance.up(); + expect(ctrlInstance.activeChoice).to.eq(choices[1]); + }); + }); + + context('.down()', () => { + it('moves the activeChoice down in the choices collection', () => { + let choices = [{ id: 1 }, { id: 2 }, { id: 3 }]; + ctrlInstance.choices = choices; + ctrlInstance.activeChoice = choices[1]; + + ctrlInstance.down(); + expect(ctrlInstance.activeChoice).to.eq(choices[2]); + + ctrlInstance.down(); + expect(ctrlInstance.activeChoice).to.eq(choices[0]); + + ctrlInstance.down(); + expect(ctrlInstance.activeChoice).to.eq(choices[1]); + }); + }); + + context('.search()', () => { + it('sets the controller searching property to the passed argument', () => { + ctrlInstance.search('foo'); + expect(ctrlInstance.searching).to.eq('foo'); + }); + + it('returns a promise', () => { + expect(ctrlInstance.search('')).to.have.property('$$state'); + }); + + it('resolves with the possible choices', () => { + function fn () { + return ctrlInstance.search(''); + } + + fn().then(function (res) { + expect(res).to.be.an('array'); + }); + + $timeout.flush(); + }); + }); + + context('.findChoices', () => { + it('returns an array', () => { + expect(ctrlInstance.findChoices()).to.be.an('array'); + }); + }); + + context('.cancel()', () => { + it('clears the controller choices', () => { + ctrlInstance.choices = [{}, {}]; + ctrlInstance.cancel(); + expect(ctrlInstance.choices).to.eql([]); + }); + + it('sets the searching regex to null', () => { + ctrlInstance.searching = /x/.exec('y'); + ctrlInstance.cancel(); + expect(ctrlInstance.searching).to.eq(null); + }); + }); + + context('.autogrow()', () => { + it('sets the $element height to 0', () => { + ctrlInstance.autogrow(); + expect($element[0].style.height).to.eq('0px'); + }); + + it('sets the $element height to scrollHeight if box-sizing is borderBox', () => { + $element[0].style.boxSizing = 'border-box'; + ctrlInstance.autogrow(); + expect($element[0].style.height).to.eq($element[0].scrollHeight + 'px'); + }); + }); + }); + + context('DOM listeners', () => { + let ctrlInstance, $element; + + beforeEach(function () { + $scope.model = 'bar'; + $element = angular.element(''); + ctrlInstance = createController($element); + $scope.$digest(); + }); + + ['keyup', 'click', 'focus'].forEach((ev) => { + context('on ' + ev, () => { + it('sets moved to false if moved is truthy', () => { + ctrlInstance.moved = true; + trigger($element, ev); + expect(ctrlInstance.moved).to.eq(false); + }); + + it('does nothing if the selectionStart does not match selectionEnd', () => { + let spy = sinon.spy($scope, '$apply'); + $element[0].selectionStart = 0; + $element[0].selectionEnd = 1; + + trigger($element, ev); + + expect(spy).to.not.have.been.calledOnce; + }); + + it('searches if there is a match', () => { + let spy = sinon.spy(ctrlInstance, 'search'); + ctrlInstance.pattern = /foo/; + $element.val('@foo'); + $element[0].selectionStart = $element[0].selectionEnd = 4; + + trigger($element, ev); + + expect(spy).to.have.been.calledOnce; + }); + + it('cancels if there is no match', () => { + let spy = sinon.spy(ctrlInstance, 'cancel'); + ctrlInstance.pattern = /foo/; + $element.val('@bar'); + $element[0].selectionStart = $element[0].selectionEnd = 4; + + trigger($element, ev); + + expect(spy).to.have.been.calledOnce; + }); + + it('triggers scope.$apply regardless', () => { + let spy = sinon.spy($scope, '$apply'); + $element[0].selectionStart = $element[0].selectionEnd = 0; + $element.val(''); + + trigger($element, ev); + + expect(spy).to.have.been.calledOnce; + }); + }); + }); + + /** + * TODO: Get ev.keyCode working. + * QT5 ain't cool with KeyBoardEvent constructors. + */ + context.skip('on keydown', () => { + let ev = 'keydown'; + + it('does nothing if not searching', () => { + let spy = sinon.spy($scope, '$apply'); + trigger($element, ev); + expect(spy).to.not.have.been.calledOnce; + }); + + it('selects if keycode 13 (return)', () => { + let spy = sinon.spy(ctrlInstance, 'select'); + ctrlInstance.searching = true; + trigger($element, ev, 13); + expect(spy).to.have.been.calledOnce; + }); + + it('goes up if keycode 38 (up)', () => { + let spy = sinon.spy(ctrlInstance, 'up'); + ctrlInstance.searching = true; + trigger($element, ev, 38); + expect(spy).to.have.been.calledOnce; + }); + + it('goes down if keycode 40 (down)', () => { + let spy = sinon.spy(ctrlInstance, 'down'); + ctrlInstance.searching = true; + trigger($element, ev, 40); + expect(spy).to.have.been.calledOnce; + }); + + context('if keycode is either 13, 38 or 40', () => { + it('sets moved to true ', () => { + ctrlInstance.searching = true; + trigger($element, ev, 13); + expect(ctrlInstance.moved).to.eq(true); + }); + + it('cancels the default of event', () => { + ctrlInstance.searching = true; + let evt = trigger($element, ev, 13); + let spy = sinon.spy(evt, 'preventDefault'); + expect(spy).to.have.been.calledOnce; + }); + + it('triggers scope.$apply', () => { + ctrlInstance.searching = true; + trigger($element, ev, 13); + let spy = sinon.spy($scope, '$apply'); + expect(spy).to.have.been.calledOnce; + }); + }); + }); + + function trigger (el, ev, code) { + let evt; + + if (code) { + evt = $document[0].createEvent('KeyboardEvent'); + evt.initKeyboardEvent(ev, true, true); + evt.keyCode = code; + } else { + evt = new Event(ev); + } + + el[0].dispatchEvent(evt); + + return evt; + } + }); +}); diff --git a/test/uiMentionDirective.spec.js b/test/uiMentionDirective.spec.js new file mode 100644 index 0000000..d46c74f --- /dev/null +++ b/test/uiMentionDirective.spec.js @@ -0,0 +1,66 @@ +describe('uiMentionDirective', () => { + let Subject, $scope, compileDir, uiMentionController; + + beforeEach(() => { + uiMentionController = function () { + uiMentionController.init = this.init = sinon.stub(); + }; + + module('ui.mention', ($controllerProvider) => { + $controllerProvider.register('uiMentionController', uiMentionController); + }); + + inject(($injector) => { + Subject = $injector.get('uiMentionDirective'); + $scope = $injector.get('$rootScope').$new(); + + compileDir = (template) => { + return $injector.get('$compile')(template)($scope); + }; + }); + }); + + context('DDO', () => { + let DDO; + + beforeEach(() => { + DDO = Subject[0]; + }); + + it('is named uiMention', () => { + expect(DDO.name).to.eq('uiMention'); + }); + + it('has a priority of 0', () => { + expect(DDO.priority).to.eq(0); + }); + + it('requires ngModel', () => { + expect(DDO.require).to.include('ngModel'); + }); + + it('requires uiMention', () => { + expect(DDO.require).to.include('uiMention'); + }); + + it('exposes controllerAs $mention', () => { + expect(DDO.controllerAs).to.eq('$mention'); + }); + + it('is restricted to EA', () => { + expect(DDO.restrict).to.eq('EA'); + }); + }); + + context('.link()', () => { + it('calls the controller.init method with the given ngModel', () => { + $scope.model = 'wat'; + compileDir(''); + $scope.$digest(); + expect(uiMentionController.init).to.have.been.calledOnce.and.calledWithMatch({ + $modelValue: 'wat' + }); + }); + }); +}); +