Last active
February 4, 2016 17:32
-
-
Save jhartman86/79d776ed57c439fab8ef to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
angular.module('sstt.common'). | |
/** | |
* http://blog.thoughtram.io/angularjs/2015/01/02/exploring-angular-1.3-bindToController.html | |
* https://leanpub.com/recipes-with-angular-js/read#leanpub-auto-editing-text-in-place-using-html5-contenteditable | |
* http://jonathancreamer.com/working-with-all-the-different-kinds-of-scopes-in-angular/ | |
* http://radify.io/blog/understanding-ngmodelcontroller-by-example-part-1/ | |
* @todo: clean up key binding code; make configurable | |
* @todo: clean up/make more efficient the overlay list positioning code, and test | |
* across more browsers | |
* @todo: optimize transcludeFn to see if we can eliminate creating a new scope for every | |
* single iteration of the lists; cache list builds (maybe use documentFragments?) | |
* @todo: DESTRUCT everything (all event bindings, scopes, DOM nodes) | |
*/ | |
directive('selectorate', ['$window', function( $window ){ | |
var KEY_ARROW_DOWN = 40, | |
KEY_ARROW_UP = 38, | |
KEY_ESCAPE = 27, | |
KEY_TAB = 9, | |
KEY_SPACEBAR = 32, | |
KEY_ENTER = 13; | |
function _link( $scope, $elem, attrs, Controller, transcludeFn ){ | |
var $optsList = angular.element($elem[0].querySelector('.selectorate-opts')), | |
$textInput = angular.element($elem[0].querySelector('[selectorate-input]')), | |
_transcluded = []; | |
$textInput.on('focus', function(){ | |
$scope.$applyAsync(Controller.doListFilter); | |
}); | |
var holdBlur = false; | |
$textInput.on('blur', function(){ | |
if( holdBlur ){ return ;} | |
$scope.$applyAsync(Controller.clearList); | |
}); | |
$optsList.on('mouseenter', function(){ | |
holdBlur = true; | |
}); | |
$optsList.on('mouseleave', function(){ | |
holdBlur = false; | |
}); | |
$textInput.on('keydown', function( e ){ | |
var children = $optsList[0].children, | |
listLength = children.length, | |
current = $optsList[0].querySelector('.keyed'), | |
index = Array.prototype.slice.call(children).indexOf(current); | |
switch( e.keyCode ){ | |
case 40: // down arrow | |
if( index + 1 !== listLength ){ | |
angular.element(current).removeClass('keyed'); | |
angular.element(children[index + 1]).addClass('keyed'); | |
} | |
break; | |
case 38: // up arrow | |
if( (index - 1 < 0) !== true ){ | |
angular.element(current).removeClass('keyed'); | |
angular.element(children[index-1]).addClass('keyed'); | |
} | |
break; | |
case 27: // escape | |
$scope.$applyAsync(Controller.clearList); | |
break; | |
case 9: case 32: case 13: // tab, spacebar, enter | |
// If user has typed and narrowed the list down to just | |
// one value, then hits tab, we should automatically | |
// select it and move to the next element | |
if( listLength === 1 ){ | |
angular.element(children).triggerHandler('click'); | |
$scope.$applyAsync(Controller.clearList); | |
} | |
// If a selection/highlight has been made... | |
if( current ){ | |
angular.element(current).triggerHandler('click'); | |
$scope.$applyAsync(Controller.clearList); | |
} | |
break; | |
} | |
}); | |
function setPosition(){ | |
var rect = $elem[0].getBoundingClientRect(), | |
width = rect.width, | |
top = rect.top; | |
console.log('adjusting'); | |
$optsList.css({width:width + 'px',top:(rect.top+rect.height) + 'px'}); | |
} | |
/** | |
* On every change to the filteredList data, we need to clean up | |
* all previously generated DOM nodes and scopes to prevent memory | |
* leaks. | |
* @param function Callback - "after cleanup, then func()" | |
* @return void | |
*/ | |
function cleanup( _then ){ | |
var _item; | |
while(_item = _transcluded.pop()){ | |
_item[0].remove(); | |
_item[1].$destroy(); | |
} | |
_then(); | |
} | |
/** | |
* Transclude function, takes the inner template and renders it so | |
* list styling is super easy. | |
* @param {jqLite} $cloned DOM element wrapped in jqLite | |
* @param {object} $scope Newly bound scope | |
* @return void | |
*/ | |
function _transcluder( $cloned, $scope ){ | |
$optsList.append($cloned); | |
_transcluded.push([$cloned, $scope]); | |
} | |
/** | |
* Watch changes to the filteredList, and if it has changed, | |
* render. | |
*/ | |
$scope.$watchCollection('selectorate.filteredList', function( list, previous ){ | |
cleanup(function(){ | |
if( list && list !== previous && list.length ){ | |
for(var _i = 0, _len = list.length; _i < _len; _i++){ | |
var $newScope = $scope.$new(); | |
$newScope.opt = list[_i]; | |
transcludeFn($newScope, _transcluder); | |
} | |
} | |
}); | |
}); | |
angular.element($window).on('scroll resize', setPosition).triggerHandler('resize'); | |
} | |
/** | |
* Important: the value that gets set as _key on the controller is crucial, | |
* as it indicates to the ngModelController how to map back and forth | |
* between an object behind-the-scenes and a string for the field to | |
* display. | |
*/ | |
return { | |
restrict : 'AE', | |
transclude : true, | |
link : _link, | |
templateUrl : '/template/selectorate.html', | |
scope : {}, | |
controllerAs : 'selectorate', | |
bindToController : { | |
_value : '=selectorate', | |
_listData : '=list', | |
_key : '@key', | |
// Optional: to attach to a form, pass the name | |
_formName : '@formName', | |
// Optional: if set, the filter search becomes '$' | |
_deepSearch : '=deepSearch', | |
// Optional: if unset, placeholder is 'undefined' | |
_placeholder : '@placeholder', | |
// Optional: control's listData depends on another model selection | |
_dependent : '=dependent', | |
// Optional: (requires _dependent == true); enable/disable form element | |
// unless _enabledWith is a valid object | |
_enabledWith : '=enabledWith', | |
// Optional: when the ngModelController is composing an empty object, | |
// we can make it compose a $resource if desired | |
_composableType : '=composableType' | |
}, | |
controller : ['$scope', '$filter', function( $scope, $filter ){ | |
this.disabled = false; | |
/** | |
* Store reference to this (Controller). | |
* @type {object} | |
*/ | |
var self = this; | |
/** | |
* Filtered list results. | |
* @type {Array} | |
*/ | |
this.filteredList = []; | |
/** | |
* Used by doListFilter to generate the filter object we use for | |
* the search. Note, if _filterProp isn't defined (eg. wasn't | |
* passed as an attribute, or == "*"), then we set the special | |
* '$' prop in the object, which means look through _everything_. | |
* @param {object} v ngModel value, objectified | |
* @return {object} Returns an object {} for the search | |
*/ | |
function getFilterObj( v ){ | |
var filter = {}; | |
if( self._deepSearch ){ | |
filter['$'] = v[self._key]; | |
return filter; | |
} | |
filter[self._key] = v[self._key]; | |
return filter; | |
} | |
/** | |
* Filter the listData against the text input value | |
* @param {object} v Object passed in that contains a property | |
* {{{_key}}:'string'} we can use to filter. | |
* @return void | |
*/ | |
this.doListFilter = function( v ){ | |
if( v && typeof(v) === 'object' ){ | |
self.filteredList = $filter('filter')(self._listData, getFilterObj(v)); | |
return; | |
} | |
self.filteredList = self._listData; | |
}; | |
/** | |
* Clear the list | |
* @return {[type]} [description] | |
*/ | |
this.clearList = function(){ | |
self.filteredList = []; | |
}; | |
/** | |
* Select the item from the list. | |
* @param {object} opt List item to choose | |
* @return void | |
*/ | |
this.choose = function( opt ){ | |
self._value = opt; | |
self.clearList(); | |
}; | |
/** | |
* Because _listData property is a two-way data binding, we can | |
* set it up so that if th _listData changes, we "reset" this | |
* instance. Case in point being - if there are two instances and | |
* the second instance depends on the list data from the first | |
* model value. | |
*/ | |
if( this._dependent ){ | |
// If the listData this control is dependent *on* changes, | |
// set this to no value and reset UI state. | |
$scope.$watch('selectorate._listData', function( v, prev ){ | |
if( v !== prev ){ | |
self._value = null; | |
} | |
}); | |
// Don't enable the control until the model for _enabledWith | |
// becomes valid | |
$scope.$watch('selectorate._enabledWith', function( v ){ | |
self.disabled = v ? false : true; | |
}); | |
} | |
}] | |
}; | |
}]). | |
/** | |
* Responsible for translating an object behind the scenes to just some | |
* text display in the input field. | |
*/ | |
directive('selectorateInput', [function(){ | |
function _link( $scope, $elem, attrs, controllers ){ | |
var ngModel = controllers[0], | |
ctrlSelectorate = controllers[1]; | |
/** | |
* Formats the output. | |
*/ | |
ngModel.$formatters.push(function( mv ){ | |
if( mv && typeof(mv) === 'object' ){ | |
return mv[ctrlSelectorate._key]; | |
} | |
}); | |
/** | |
* Parses a receieved input into the correct object structure, | |
* which formatters can handle. | |
*/ | |
ngModel.$parsers.push(function( vv ){ | |
// Is it already an object? Then just return it. | |
if( vv && typeof(vv) === 'object' ){ | |
return vv; | |
} | |
// If here, we need to compose to an object. | |
var obj = {}; | |
obj[ctrlSelectorate._key] = vv; | |
// If a specific type of object to compose is defined (eg. a | |
// $resource), we can return that here. *Note* - it must be | |
// 'new'-able, as in have a constructor. | |
if( ctrlSelectorate._composableType ){ | |
return new ctrlSelectorate._composableType(obj); | |
} | |
// Otherwise just return a plainly composed object. | |
return obj; | |
}); | |
/** | |
* When a view change occurs, modify the list filter. | |
*/ | |
ngModel.$viewChangeListeners.push(function(){ | |
ctrlSelectorate.doListFilter(ngModel.$modelValue); | |
}); | |
} | |
return { | |
restrict : 'A', | |
scope : false, // SHARE PARENT DIRECTIVE'S SCOPE! | |
require : ['ngModel', '^selectorate'], | |
link : _link | |
}; | |
}]); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment