-
Notifications
You must be signed in to change notification settings - Fork 2
/
angular-listview.js
509 lines (445 loc) · 14.8 KB
/
angular-listview.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
(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);