forked from etodanik/ion-google-place
-
Notifications
You must be signed in to change notification settings - Fork 88
/
ion-autocomplete.js
493 lines (417 loc) · 28 KB
/
ion-autocomplete.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
angular.module('ion-autocomplete', []).directive('ionAutocomplete', [
'$ionicBackdrop', '$ionicScrollDelegate', '$document', '$q', '$parse', '$interpolate', '$ionicPlatform', '$compile', '$templateRequest',
function ($ionicBackdrop, $ionicScrollDelegate, $document, $q, $parse, $interpolate, $ionicPlatform, $compile, $templateRequest) {
return {
require: ['ngModel', 'ionAutocomplete'],
restrict: 'A',
scope: {},
bindToController: {
ngModel: '=',
externalModel: '=',
templateData: '=',
maxSelectedItems: '=',
itemsMethod: '&',
itemsClickedMethod: '&',
itemsRemovedMethod: '&',
modelToItemMethod: '&',
cancelButtonClickedMethod: '&',
placeholder: '@',
cancelLabel: '@',
selectItemsLabel: '@',
selectedItemsLabel: '@',
templateUrl: '@',
itemValueKey: '@',
itemViewValueKey: '@'
},
controllerAs: 'viewModel',
controller: ['$attrs', '$timeout', '$scope', function ($attrs, $timeout, $scope) {
var valueOrDefault = function (value, defaultValue) {
return !value ? defaultValue : value;
};
var controller = this;
// set the default values of the one way binded attributes
$timeout(function () {
controller.placeholder = valueOrDefault(controller.placeholder, 'Click to enter a value...');
controller.cancelLabel = valueOrDefault(controller.cancelLabel, 'Done');
controller.selectItemsLabel = valueOrDefault(controller.selectItemsLabel, "Select an item...");
controller.selectedItemsLabel = valueOrDefault(controller.selectedItemsLabel, $interpolate("Selected items{{maxSelectedItems ? ' (max. ' + maxSelectedItems + ')' : ''}}:")(controller));
controller.templateUrl = valueOrDefault(controller.templateUrl, undefined);
controller.itemValueKey = valueOrDefault(controller.itemValueKey, undefined);
controller.itemViewValueKey = valueOrDefault(controller.itemViewValueKey, undefined);
});
// set the default values of the passed in attributes
this.itemsMethodValueKey = valueOrDefault($attrs.itemsMethodValueKey, undefined);
this.componentId = valueOrDefault($attrs.componentId, undefined);
this.loadingIcon = valueOrDefault($attrs.loadingIcon, undefined);
this.manageExternally = valueOrDefault($attrs.manageExternally, "false");
this.clearOnSelect = valueOrDefault($attrs.clearOnSelect, "true");
this.ngModelOptions = valueOrDefault($scope.$eval($attrs.ngModelOptions), {});
this.openClass = valueOrDefault($attrs.openClass, 'ion-autocomplete-open');
this.closeClass = valueOrDefault($attrs.closeClass, 'ion-autocomplete-close');
// loading flag if the items-method is a function
this.showLoadingIcon = false;
// the items, selected items and the query for the list
this.searchItems = [];
this.selectedItems = [];
this.searchQuery = undefined;
this.isArray = function (array) {
return angular.isArray(array);
};
}],
link: function (scope, element, attrs, controllers) {
// get the two needed controllers
var ngModelController = controllers[0];
var ionAutocompleteController = controllers[1];
// use a random css class to bind the modal to the component
ionAutocompleteController.randomCssClass = "ion-autocomplete-random-" + Math.floor((Math.random() * 1000) + 1);
var template = [
'<div class="ion-autocomplete-container ' + ionAutocompleteController.randomCssClass + ' modal ' + ionAutocompleteController.closeClass + ' " >',
'<div class="bar bar-header item-input-inset">',
'<label class="item-input-wrapper">',
'<i class="icon ion-search placeholder-icon"></i>',
'<input type="search" class="ion-autocomplete-search" ng-model="viewModel.searchQuery" ng-model-options="viewModel.ngModelOptions" placeholder="{{viewModel.placeholder}}"/>',
'</label>',
'<div class="ion-autocomplete-loading-icon" ng-if="viewModel.showLoadingIcon && viewModel.loadingIcon"><ion-spinner icon="{{viewModel.loadingIcon}}"></ion-spinner></div>',
'<button class="ion-autocomplete-cancel button button-clear button-dark" ng-click="viewModel.cancelClick()">{{viewModel.cancelLabel}}</button>',
'</div>',
'<ion-content class="has-header">',
'<ion-item class="item-divider">{{viewModel.selectedItemsLabel}}</ion-item>',
'<ion-item ng-if="viewModel.isArray(viewModel.selectedItems)" ng-repeat="selectedItem in viewModel.selectedItems track by $index" class="item-icon-left item-icon-right item-text-wrap">',
'<i class="icon ion-checkmark"></i>',
'{{viewModel.getItemValue(selectedItem, viewModel.itemViewValueKey)}}',
'<i class="icon ion-trash-a" style="cursor:pointer" ng-click="viewModel.removeItem($index)"></i>',
'</ion-item>',
'<ion-item ng-if="!viewModel.isArray(viewModel.selectedItems)" class="item-icon-left item-icon-right item-text-wrap">',
'<i class="icon ion-checkmark"></i>',
'{{viewModel.getItemValue(viewModel.selectedItems, viewModel.itemViewValueKey)}}',
'<i class="icon ion-trash-a" style="cursor:pointer" ng-click="viewModel.removeItem(0)"></i>',
'</ion-item>',
'<ion-item class="item-divider" ng-if="viewModel.searchItems.length > 0">{{viewModel.selectItemsLabel}}</ion-item>',
'<ion-item ng-repeat="item in viewModel.searchItems track by $index" item-height="55px" item-width="100%" ng-click="viewModel.selectItem(item)" class="item-text-wrap">',
'{{viewModel.getItemValue(item, viewModel.itemViewValueKey)}}',
'</ion-item>',
'</ion-content>',
'</div>'
].join('');
// load the template synchronously or asynchronously
$q.when().then(function () {
// first check if a template url is set and use this as template
if (ionAutocompleteController.templateUrl) {
return $templateRequest(ionAutocompleteController.templateUrl);
} else {
return template;
}
}).then(function (template) {
// compile the template
var searchInputElement = $compile(angular.element(template))(scope);
// append the template to body
$document.find('body').append(searchInputElement);
// returns the value of an item
ionAutocompleteController.getItemValue = function (item, key) {
// if it's an array, go through all items and add the values to a new array and return it
if (angular.isArray(item)) {
var items = [];
angular.forEach(item, function (itemValue) {
if (key && angular.isObject(item)) {
items.push($parse(key)(itemValue));
} else {
items.push(itemValue);
}
});
return items;
} else {
if (key && angular.isObject(item)) {
return $parse(key)(item);
}
}
return item;
};
// function which selects the item, hides the search container and the ionic backdrop if it has not maximum selected items attribute set
ionAutocompleteController.selectItem = function (item) {
// if the clear on select is true, clear the search query when an item is selected
if (ionAutocompleteController.clearOnSelect == "true") {
ionAutocompleteController.searchQuery = undefined;
}
// return if the max selected items is not equal to 1 and the maximum amount of selected items is reached
if (ionAutocompleteController.maxSelectedItems != "1" &&
angular.isArray(ionAutocompleteController.selectedItems) &&
ionAutocompleteController.maxSelectedItems <= ionAutocompleteController.selectedItems.length) {
return;
}
// store the selected items
if (!isKeyValueInObjectArray(ionAutocompleteController.selectedItems,
ionAutocompleteController.itemValueKey, ionAutocompleteController.getItemValue(item, ionAutocompleteController.itemValueKey))) {
// if it is a single select set the item directly
if (ionAutocompleteController.maxSelectedItems == "1") {
ionAutocompleteController.selectedItems = item;
} else {
// create a new array to update the model. See https://github.com/angular-ui/ui-select/issues/191#issuecomment-55471732
ionAutocompleteController.selectedItems = ionAutocompleteController.selectedItems.concat([item]);
}
}
// set the view value and render it
ngModelController.$setViewValue(ionAutocompleteController.selectedItems);
ngModelController.$render();
// hide the container and the ionic backdrop if it is a single select to enhance usability
if (ionAutocompleteController.maxSelectedItems == 1) {
ionAutocompleteController.hideModal();
}
// call items clicked callback
if (angular.isDefined(attrs.itemsClickedMethod)) {
ionAutocompleteController.itemsClickedMethod({
callback: {
item: item,
selectedItems: angular.isArray(ionAutocompleteController.selectedItems) ? ionAutocompleteController.selectedItems.slice() : ionAutocompleteController.selectedItems,
selectedItemsArray: angular.isArray(ionAutocompleteController.selectedItems) ? ionAutocompleteController.selectedItems.slice() : [ionAutocompleteController.selectedItems],
componentId: ionAutocompleteController.componentId
}
});
}
};
// function which removes the item from the selected items.
ionAutocompleteController.removeItem = function (index) {
// clear the selected items if just one item is selected
if (!angular.isArray(ionAutocompleteController.selectedItems)) {
ionAutocompleteController.selectedItems = [];
} else {
// remove the item from the selected items and create a copy of the array to update the model.
// See https://github.com/angular-ui/ui-select/issues/191#issuecomment-55471732
var removed = ionAutocompleteController.selectedItems.splice(index, 1)[0];
ionAutocompleteController.selectedItems = ionAutocompleteController.selectedItems.slice();
}
// set the view value and render it
ngModelController.$setViewValue(ionAutocompleteController.selectedItems);
ngModelController.$render();
// call items clicked callback
if (angular.isDefined(attrs.itemsRemovedMethod)) {
ionAutocompleteController.itemsRemovedMethod({
callback: {
item: removed,
selectedItems: angular.isArray(ionAutocompleteController.selectedItems) ? ionAutocompleteController.selectedItems.slice() : ionAutocompleteController.selectedItems,
selectedItemsArray: angular.isArray(ionAutocompleteController.selectedItems) ? ionAutocompleteController.selectedItems.slice() : [ionAutocompleteController.selectedItems],
componentId: ionAutocompleteController.componentId
}
});
}
};
// watcher on the search field model to update the list according to the input
scope.$watch('viewModel.searchQuery', function (query) {
ionAutocompleteController.fetchSearchQuery(query, false);
});
// watcher on the max selected items to update the selected items label
scope.$watch('viewModel.maxSelectedItems', function (maxSelectedItems) {
// only update the label if the value really changed
if (ionAutocompleteController.maxSelectedItems != maxSelectedItems) {
ionAutocompleteController.selectedItemsLabel = $interpolate("Selected items{{maxSelectedItems ? ' (max. ' + maxSelectedItems + ')' : ''}}:")(ionAutocompleteController);
}
});
// update the search items based on the returned value of the items-method
ionAutocompleteController.fetchSearchQuery = function (query, isInitializing) {
// right away return if the query is undefined to not call the items method for nothing
if (query === undefined) {
return;
}
if (angular.isDefined(attrs.itemsMethod)) {
// show the loading icon
ionAutocompleteController.showLoadingIcon = true;
var queryObject = {query: query, isInitializing: isInitializing};
// if the component id is set, then add it to the query object
if (ionAutocompleteController.componentId) {
queryObject = {
query: query,
isInitializing: isInitializing,
componentId: ionAutocompleteController.componentId
}
}
// convert the given function to a $q promise to support promises too
var promise = $q.when(ionAutocompleteController.itemsMethod(queryObject));
promise.then(function (promiseData) {
// if the promise data is not set do nothing
if (!promiseData) {
return;
}
// if the given promise data object has a data property use this for the further processing as the
// standard httpPromises from the $http functions store the response data in a data property
if (promiseData && promiseData.data) {
promiseData = promiseData.data;
}
// set the items which are returned by the items method
ionAutocompleteController.searchItems = ionAutocompleteController.getItemValue(promiseData,
ionAutocompleteController.itemsMethodValueKey);
// force the collection repeat to redraw itself as there were issues when the first items were added
$ionicScrollDelegate.resize();
}, function (error) {
// reject the error because we do not handle the error here
return $q.reject(error);
}).finally(function () {
// hide the loading icon
ionAutocompleteController.showLoadingIcon = false;
});
}
};
var searchContainerDisplayed = false;
ionAutocompleteController.showModal = function () {
if (searchContainerDisplayed) {
return;
}
// show the backdrop and the search container
$ionicBackdrop.retain();
var modal = angular.element($document[0].querySelector('div.ion-autocomplete-container.' + ionAutocompleteController.randomCssClass));
modal.addClass(this.openClass);
modal.removeClass(this.closeClass);
// hide the container if the back button is pressed
scope.$deregisterBackButton = $ionicPlatform.registerBackButtonAction(function () {
ionAutocompleteController.hideModal();
}, 300);
// get the compiled search field
var searchInputElement = angular.element($document[0].querySelector('div.ion-autocomplete-container.' + ionAutocompleteController.randomCssClass + ' input:not(.no-autofocus)'));
// focus on the search input field
if (searchInputElement.length > 0) {
searchInputElement[0].focus();
setTimeout(function () {
searchInputElement[0].focus();
}, 100);
}
// force the collection repeat to redraw itself as there were issues when the first items were added
$ionicScrollDelegate.resize();
searchContainerDisplayed = true;
};
ionAutocompleteController.hideModal = function () {
var modal = angular.element($document[0].querySelector('div.ion-autocomplete-container.' + ionAutocompleteController.randomCssClass));
modal.addClass(this.closeClass);
modal.removeClass(this.openClass);
ionAutocompleteController.searchQuery = undefined;
$ionicBackdrop.release();
scope.$deregisterBackButton && scope.$deregisterBackButton();
searchContainerDisplayed = false;
};
// object to store if the user moved the finger to prevent opening the modal
var scrolling = {
moved: false,
startX: 0,
startY: 0
};
// store the start coordinates of the touch start event
var onTouchStart = function (e) {
scrolling.moved = false;
// Use originalEvent when available, fix compatibility with jQuery
if (typeof(e.originalEvent) !== 'undefined') {
e = e.originalEvent;
}
scrolling.startX = e.touches[0].clientX;
scrolling.startY = e.touches[0].clientY;
};
// check if the finger moves more than 10px and set the moved flag to true
var onTouchMove = function (e) {
// Use originalEvent when available, fix compatibility with jQuery
if (typeof(e.originalEvent) !== 'undefined') {
e = e.originalEvent;
}
if (Math.abs(e.touches[0].clientX - scrolling.startX) > 10 ||
Math.abs(e.touches[0].clientY - scrolling.startY) > 10) {
scrolling.moved = true;
}
};
// click handler on the input field to show the search container
var onClick = function (event) {
// only open the dialog if was not touched at the beginning of a legitimate scroll event
if (scrolling.moved) {
return;
}
// prevent the default event and the propagation
event.preventDefault();
event.stopPropagation();
// call the fetch search query method once to be able to initialize it when the modal is shown
// use an empty string to signal that there is no change in the search query
ionAutocompleteController.fetchSearchQuery("", true);
// show the ionic backdrop and the search container
ionAutocompleteController.showModal();
};
var isKeyValueInObjectArray = function (objectArray, key, value) {
if (angular.isArray(objectArray)) {
for (var i = 0; i < objectArray.length; i++) {
if (ionAutocompleteController.getItemValue(objectArray[i], key) === value) {
return true;
}
}
}
return false;
};
// function to call the model to item method and select the item
var resolveAndSelectModelItem = function (modelValue) {
// convert the given function to a $q promise to support promises too
var promise = $q.when(ionAutocompleteController.modelToItemMethod({modelValue: modelValue}));
promise.then(function (promiseData) {
// select the item which are returned by the model to item method
ionAutocompleteController.selectItem(promiseData);
}, function (error) {
// reject the error because we do not handle the error here
return $q.reject(error);
});
};
// if the click is not handled externally, bind the handlers to the click and touch events of the input field
if (ionAutocompleteController.manageExternally == "false") {
element.bind('touchstart', onTouchStart);
element.bind('touchmove', onTouchMove);
element.bind('touchend click focus', onClick);
}
// cancel handler for the cancel button which clears the search input field model and hides the
// search container and the ionic backdrop and calls the cancel button clicked callback
ionAutocompleteController.cancelClick = function () {
ionAutocompleteController.hideModal();
// call cancel button clicked callback
if (angular.isDefined(attrs.cancelButtonClickedMethod)) {
ionAutocompleteController.cancelButtonClickedMethod({
callback: {
selectedItems: angular.isArray(ionAutocompleteController.selectedItems) ? ionAutocompleteController.selectedItems.slice() : ionAutocompleteController.selectedItems,
selectedItemsArray: angular.isArray(ionAutocompleteController.selectedItems) ? ionAutocompleteController.selectedItems.slice() : [ionAutocompleteController.selectedItems],
componentId: ionAutocompleteController.componentId
}
});
}
};
// watch the external model for changes and select the items inside the model
scope.$watch("viewModel.externalModel", function (newModel) {
if (angular.isArray(newModel) && newModel.length == 0) {
// clear the selected items and set the view value and render it
ionAutocompleteController.selectedItems = [];
ngModelController.$setViewValue(ionAutocompleteController.selectedItems);
ngModelController.$render();
return;
}
// prepopulate view and selected items if external model is already set
if (newModel && angular.isDefined(attrs.modelToItemMethod)) {
if (angular.isArray(newModel)) {
ionAutocompleteController.selectedItems = [];
angular.forEach(newModel, function (modelValue) {
resolveAndSelectModelItem(modelValue);
})
} else {
resolveAndSelectModelItem(newModel);
}
}
});
// remove the component from the dom when scope is getting destroyed
scope.$on('$destroy', function () {
$ionicBackdrop.release();
// angular takes care of cleaning all $watch's and listeners, but we still need to remove the modal
searchInputElement.remove();
});
// render the view value of the model
ngModelController.$render = function () {
element.val(ionAutocompleteController.getItemValue(ngModelController.$viewValue, ionAutocompleteController.itemViewValueKey));
};
// set the view value of the model
ngModelController.$formatters.push(function (modelValue) {
var viewValue = ionAutocompleteController.getItemValue(modelValue, ionAutocompleteController.itemViewValueKey);
return viewValue == undefined ? "" : viewValue;
});
// set the model value of the model
ngModelController.$parsers.push(function (viewValue) {
return ionAutocompleteController.getItemValue(viewValue, ionAutocompleteController.itemValueKey);
});
});
}
};
}
]);