UNPKG

angular-listview

Version:

simple, flexible, angular list view -- select, add, edit, remove

510 lines (445 loc) 15.2 kB
(function(angular) { 'use strict'; /** * @typedef {Error} ListViewMinErr */ var listViewMinErr = angular.$$minErr('listview'); angular.module('listview', ['ngAnimate']) // this is pretty much straight from ngRepeat. .factory('listViewParser', ['$parse', function($parse) { // jscs:disable maximumLineLength var LIST_REGEXP = /^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?\s*$/; var ITEM_REGEXP = /^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/; // jscs:enable return { parse: function(expression) { var match = expression.match(LIST_REGEXP); if (!match) { throw listViewMinErr('iexp', "Expected expression in form of '_item_ in _collection_ (track by " + "_id_)?' but got '{0}'.", expression); } var collectionIdentifier = match[2]; var lhs = match[1]; match = lhs.match(ITEM_REGEXP); if (!match) { throw listViewMinErr('iidexp', "'_item_' in '_item_ in " + "_collection_' should be an identifier or '(_key_, _value_)' " + "expression, but got '{0}'.", lhs); } return { collection: $parse(collectionIdentifier), key: $parse(match[2]), item: $parse(match[1]) }; } }; }]) .controller('ListViewCtrl', ['$animate', 'listViewParser', function($animate, listViewParser) { /** * @ngdoc property * @name listview.ListViewCtrl#$element * * @description * The root (i.e. list-view) element of the list. */ this.$element = null; /** * @ngdoc property * @name listview.ListViewCtrl#expression * * @description * The list expression (i.e. ng-repeat expression) defined as the "list" * attribute of {@link listview.ListViewCtrl#$element}. */ this.expression = ''; /** * @ngdoc property * @name listview.ListViewCtrl#selectMode * * @description * A string describing how selection should work on this list. * * May be one of the following values: * - **none** Do not allow selection. * - **single** Only one list item may be selected at a time. * - **active** Same as single, but many list items may be active. * - **multi** Any number of list items may be selected at a time. */ this.selectMode = 'none'; var selectElements = []; var editMode = false; var parse; /** * @ngdoc method * @name listview.ListViewCtrl#registerSelectElement * @kind function * * @description * The listItem directive uses this to register its element for selection. * * @param {object} $element A jqLite-wrapped element to select/deselect. * @returns {function()} Call this function to deregister the element. */ this.registerSelectElement = function registerSelectElement($element) { selectElements.push($element); return function() { var index = selectElements.indexOf($element); if (index > -1) selectElements.splice(index, 1); }; }; /** * @ngdoc method * @name listview.ListViewCtrl#select * @kind function * * @description * Select a given **element**, using the controller's **selectMode**. * A selected element will have the "selected" class, an active one the * "active" class. * * - A selectMode of "none" prevents selection. * - "single" or "active" allows only one element to be selected at a time. * - "active" also allows many elements to be active. * - "multi" allows many elements to be selected (no active elements). * * @param {obj} $element The jqLite element to select. */ this.select = function select($element) { if (this.selectMode == 'none') return; if (!~selectElements.indexOf($element)) return; if (~['single', 'active'].indexOf(this.selectMode)) { for (var i = 0, len = selectElements.length; i < len; i++) { $animate.removeClass(selectElements[i], 'selected'); } } $animate.addClass($element, 'selected'); if (this.selectMode == 'active') $animate.addClass($element, 'active'); }; /** * @ngdoc method * @name listview.ListViewCtrl#deselect * @kind function * * @description * Deselects a given element by removing the "selected" (and potentially the * "active") class. * * @param {obj} $element The jqLite element to deselect. */ this.deselect = function deselect($element) { $animate.removeClass($element, 'active'); $animate.removeClass($element, 'selected'); }; /** * @ngdoc method * @name listview.ListViewCtrl#toggleEditMode * @kind function * * @description * Toggles the list into/out of edit mode by adding/removing the * "list-view-edit" class on ListViewCtrl#$element. */ this.toggleEditMode = function toggleEditMode() { editMode = !editMode; $animate[(editMode) ? 'addClass' : 'removeClass' ](this.$element, 'list-view-edit'); return editMode; }; /** * @ngdoc method * @name listview.ListViewCtrl#add * * @description * Add a given item to a collection in the given scope. Uses the result of * parsing ListViewCtrl#expression to determine that collection. When the * collection is an object, a **key** is required. * * @param {*} item An item to add to the collection. * @param {string=} key Key to use when the collection is an object. * @param {object} scope The scope containing the collection. * @throws {ListViewMinErr} Collection is an object, but no key is given. */ this.add = function add(item, key, scope) { if (!parse) parse = listViewParser.parse(this.expression); if (!scope) { scope = key; key = null; } var collection = parse.collection(scope); if (Array.isArray(collection)) collection.push(item); else if (key) collection[key] = item; else { throw listViewMinErr('nokey', "Argument 'key' is required when list " + 'is an object'); } }; /** * @ngdoc method * @name listview.ListViewCtrl#remove * * @description * Add an item from a collection in the given scope. Uses the result of * parsing ListViewCtrl#expression to determine both the collection and the * item. This is meant to be used with a scope such as that created by using * an ng-repeat directive on the collection. * * Specifically, the given scope should contain an "item" property, while * inheriting the collection. * * @param {object} scope The scope containing the item and the collection. * @throws {ListViewMinErr} Collection is an object, but no key can be parsed. */ this.remove = function remove(scope) { if (!parse) parse = listViewParser.parse(this.expression); var collection = parse.collection(scope); var key = parse.key(scope); var item = (key) ? collection[key] : parse.item(scope); if (Array.isArray(collection)) { collection.splice(collection.indexOf(item), 1); } else if (key) delete collection[key]; else { throw listViewMinErr('nokey', 'The expression used to iterate over an ' + "object must specify (_key_, _value_), but got '{0}'", this.expression); } }; }]) /** * @ngdoc directive * @name listView * @restrict EA * * @description * Creates a simple list, capable of adding/removing/editing its items. * * Filtering and sorting are available by using ngRepeat internally. To that * end, the `list` attribute supports ngRepeat expressions. * * @param {string} list A valid ngRepeat expression used to iterate over a * collection of items. * @param {string} selectMode See {@link listview.ListViewCtrl#selectMode} */ .directive('listView', function() { var SELECT_MODES = { single: 'single', multi: 'multi', active: 'active', none: 'none' }; function isListItem(node) { return node.tagName && ( node.hasAttribute('list-item') || node.hasAttribute('data-list-item') || node.tagName.toLowerCase() === 'list-item' || node.tagName.toLowerCase() === 'data-list-item' ); } return { restrict: 'EA', controller: 'ListViewCtrl', scope: true, compile: function($element, attrs) { var $contents = $element.contents(); var $item; for (var i = 0, len = $contents.length; i < len; i++) { if (isListItem($contents[i])) { $item = $contents.eq(i); break; } } // let's support ng-repeat expressions without re-implementing ng-repeat. $item.attr('ng-repeat', attrs.list); return function(scope, $element, attrs, ctrl) { // the controller will arbitrate selection - it needs to know the mode. ctrl.selectMode = SELECT_MODES[attrs.selectMode] || 'none'; // for things like removing items, the controller needs to have access // to the `attrs.list` expression. ctrl.expression = attrs.list || ''; // the controller needs a reference to the $element to do things like // toggling edit mode. ctrl.$element = $element; }; } }; }) /** * @ngdoc directive * @name listEditToggle * @restrict EA * * @description * Toggles the list into/out of edit mode -- i.e. toggled the "list-view-edit" * class on {@link listview.ListViewCtrl#$element}. * * An expression may be given to `listEditToggle` which will be evaluated before * each toggle. It may return `false` to prevent the toggling. Expressions which * return promises are also supported. The expression will be provided with two * local variables: * - **$event** The triggering event. * - **$toEditMode** A boolean, `true` when transitioning *to* edit mode. * * @param {string} [listEditToggle] An expression. * @param {string} [toggleOn=click] Set the event which triggers a toggle. */ .directive('listEditToggle', ['$q', function($q) { return { restrict: 'EA', require: '^listView', link: function(scope, $element, attrs, ctrl) { var handler = attrs.toggleIf || attrs.listEditToggle || true; var eventName = attrs.toggleOn || 'click'; $element.on(eventName, function(event) { event.stopPropagation(); var toEditMode = !ctrl.$element.hasClass('list-view-edit'); $q.when( scope.$eval(handler, {$event: event, $toEditMode: toEditMode}) ).then(function(toggle) { if (toggle === false) return; scope.$editMode = ctrl.toggleEditMode(); }); }); } }; }]) /** * @ngdoc directive * @name listAdd * @restrict EA * * @description * Add a new item to the list. * * An expression must be given to `listAdd` which should return the new item. * The expression may also return a promise, resolving to the new item. When the * list's collection is an object, the expression should return an object with a * `$key` property - the value of which will become the new item's key in the * collection. * @param {string} listAdd An expression. * @param {string} [addOn=click] Set the event which calls the expression. */ .directive('listAdd', ['$q', function($q) { return { restrict: 'EA', require: '^listView', link: function(scope, $element, attrs, ctrl) { var handler = attrs.add || attrs.listAdd; var eventName = attrs.addOn || 'click'; // we can't do anything without a handler to return the new item. if (!handler) return; $element.on(eventName, function(event) { event.stopPropagation(); $q.when( scope.$eval(handler, {$event: event}) ).then(function(item) { if (!item) return; var key = item.$key; delete item.$key; ctrl.add(item, key, scope); }); }); } }; }]) /** * @ngdoc directive * @name listItem * @restrict EA * * @description * When creating a list, use `listItem` to define the item template which will * be repeated for each item. This directive also controls item selection by * toggling the "selected" (and, sometimes, the "active") class on its element. * * An expression may be given to `listItem` in the `selectIf` attribute. When * this expression evaluates to `false` (or returns a promise which is rejected * or resolves to `false), the selection will be canceled. * * @example <example> <list-view list="foo in collection" select-mode="single"> <div>This could be the list's header</div> <list-item select-if="someFunction($event, foo)"> {{ foo | json}} </list-item> </list-view> <example> * * @param {string} [selectIf] An expression. * @param {string} [selectOn=click] Set the event which calls the expression. */ .directive('listItem', ['$q', '$timeout', function($q, $timeout) { return { restrict: 'EA', require: '^listView', link: function(scope, $element, attrs, ctrl) { var eventName = (ctrl.selectMode == 'none') ? null : attrs.selectOn || 'click'; var handler = attrs.selectIf || true; var timer = null; // if we can't select items, there's nothing to do here. if (!eventName) return; // register the element -- returns a function to deregister the element // when the scope is destroyed. scope.$on('$destroy', ctrl.registerSelectElement($element)); $element.on(eventName, function(event) { var callFunction = true; event.stopPropagation(); // to provide compatibility with other directives, click events are // debounced so we only select once per double-click (we don't // completely separate click from dblclick, because there's no good way // to do so without causing a delay between click and selection). if (eventName == 'click') { callFunction = !timer; $timeout.cancel(timer); timer = $timeout(function() { timer = null; }, 300); } if (callFunction) { if ($element.hasClass('selected')) return ctrl.deselect($element); $q.when(scope.$eval(handler, {$event: event})).then(function(select) { if (select === false) return; ctrl.select($element); }); } }); } }; }]) /** * @ngdoc directive * @name listItemEdit * @restrict EA * * @description * Edit a list item. Give an expression to `listItemEdit` which will alter the * item. Use "remove" as a convenient shortcut to remove the item. * * @param {string} listItemEdit An expression OR "remove" * @param {string} [editOn=click] Set the event which calls the expression. */ .directive('listItemEdit', function() { return { restrict: 'EA', require: '^listView', link: function(scope, $element, attrs, ctrl) { var handler = attrs.edit || attrs.listItemEdit; var eventName = attrs.editOn || 'click'; // we can't do anything without a handler. if (!handler) return; $element.on(eventName, function(event) { event.stopPropagation(); scope.$apply(function() { if (handler == 'remove') return ctrl.remove(scope); scope.$eval(handler, {$event: event}); }); }); } }; }); })(angular);