visualsearch-rails 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. data/.gitignore +7 -0
  2. data/.travis.yml +4 -0
  3. data/Changelog.md +4 -0
  4. data/Gemfile +2 -0
  5. data/Rakefile +5 -0
  6. data/Readme.md +24 -0
  7. data/app/assets/css/icons.css +19 -0
  8. data/app/assets/css/reset.css +30 -0
  9. data/app/assets/css/workspace.css +290 -0
  10. data/app/assets/images/cancel_search.png +0 -0
  11. data/app/assets/images/search_glyph.png +0 -0
  12. data/app/assets/javascripts/backbone-0.9.10.js +1498 -0
  13. data/app/assets/javascripts/dependencies.js +14843 -0
  14. data/app/assets/javascripts/jquery.ui.autocomplete.js +614 -0
  15. data/app/assets/javascripts/jquery.ui.core.js +324 -0
  16. data/app/assets/javascripts/jquery.ui.datepicker.js +5 -0
  17. data/app/assets/javascripts/jquery.ui.menu.js +621 -0
  18. data/app/assets/javascripts/jquery.ui.position.js +497 -0
  19. data/app/assets/javascripts/jquery.ui.widget.js +521 -0
  20. data/app/assets/javascripts/underscore-1.4.3.js +1221 -0
  21. data/app/assets/javascripts/visualsearch/js/models/search_facets.js +67 -0
  22. data/app/assets/javascripts/visualsearch/js/models/search_query.js +70 -0
  23. data/app/assets/javascripts/visualsearch/js/templates/search_box.jst +8 -0
  24. data/app/assets/javascripts/visualsearch/js/templates/search_facet.jst +9 -0
  25. data/app/assets/javascripts/visualsearch/js/templates/search_input.jst +1 -0
  26. data/app/assets/javascripts/visualsearch/js/templates/templates.js +7 -0
  27. data/app/assets/javascripts/visualsearch/js/utils/backbone_extensions.js +17 -0
  28. data/app/assets/javascripts/visualsearch/js/utils/hotkeys.js +99 -0
  29. data/app/assets/javascripts/visualsearch/js/utils/inflector.js +21 -0
  30. data/app/assets/javascripts/visualsearch/js/utils/jquery_extensions.js +197 -0
  31. data/app/assets/javascripts/visualsearch/js/utils/search_parser.js +87 -0
  32. data/app/assets/javascripts/visualsearch/js/views/search_box.js +447 -0
  33. data/app/assets/javascripts/visualsearch/js/views/search_facet.js +444 -0
  34. data/app/assets/javascripts/visualsearch/js/views/search_input.js +409 -0
  35. data/app/assets/javascripts/visualsearch/js/visualsearch.js +77 -0
  36. data/lib/generators/visual_search_install.rb +30 -0
  37. data/lib/visualsearch-rails.rb +2 -0
  38. data/lib/visualsearch/rails.rb +6 -0
  39. data/lib/visualsearch/version.rb +3 -0
  40. data/visualsearch-rails.gemspec +26 -0
  41. metadata +165 -0
@@ -0,0 +1,444 @@
1
+ (function() {
2
+
3
+ var $ = jQuery; // Handle namespaced jQuery
4
+
5
+ // This is the visual search facet that holds the category and its autocompleted
6
+ // input field.
7
+ VS.ui.SearchFacet = Backbone.View.extend({
8
+
9
+ type : 'facet',
10
+
11
+ className : 'search_facet',
12
+
13
+ events : {
14
+ 'click .category' : 'selectFacet',
15
+ 'keydown input' : 'keydown',
16
+ 'mousedown input' : 'enableEdit',
17
+ 'mouseover .VS-icon-cancel' : 'showDelete',
18
+ 'mouseout .VS-icon-cancel' : 'hideDelete',
19
+ 'click .VS-icon-cancel' : 'remove'
20
+ },
21
+
22
+ initialize : function(options) {
23
+ this.flags = {
24
+ canClose : false
25
+ };
26
+ _.bindAll(this, 'set', 'keydown', 'deselectFacet', 'deferDisableEdit');
27
+ },
28
+
29
+ // Rendering the facet sets up autocompletion, events on blur, and populates
30
+ // the facet's input with its starting value.
31
+ render : function() {
32
+ $(this.el).html(JST['search_facet']({
33
+ model : this.model
34
+ }));
35
+
36
+ this.setMode('not', 'editing');
37
+ this.setMode('not', 'selected');
38
+ this.box = this.$('input');
39
+ this.box.val(this.model.label());
40
+ this.box.bind('blur', this.deferDisableEdit);
41
+ // Handle paste events with `propertychange`
42
+ this.box.bind('input propertychange', this.keydown);
43
+ this.setupAutocomplete();
44
+
45
+ return this;
46
+ },
47
+
48
+ // This method is used to setup the facet's input to auto-grow.
49
+ // This is defered in the searchBox so it can be attached to the
50
+ // DOM to get the correct font-size.
51
+ calculateSize : function() {
52
+ this.box.autoGrowInput();
53
+ this.box.unbind('updated.autogrow');
54
+ this.box.bind('updated.autogrow', _.bind(this.moveAutocomplete, this));
55
+ },
56
+
57
+ // Forces a recalculation of this facet's input field's value. Called when
58
+ // the facet is focused, removed, or otherwise modified.
59
+ resize : function(e) {
60
+ this.box.trigger('resize.autogrow', e);
61
+ },
62
+
63
+ // Watches the facet's input field to see if it matches the beginnings of
64
+ // words in `autocompleteValues`, which is different for every category.
65
+ // If the value, when selected from the autocompletion menu, is different
66
+ // than what it was, commit the facet and search for it.
67
+ setupAutocomplete : function() {
68
+ this.box.autocomplete({
69
+ source : _.bind(this.autocompleteValues, this),
70
+ minLength : 0,
71
+ delay : 0,
72
+ autoFocus : true,
73
+ position : {offset : "0 5"},
74
+ create : _.bind(function(e, ui) {
75
+ $(this.el).find('.ui-autocomplete-input').css('z-index','auto');
76
+ }, this),
77
+ select : _.bind(function(e, ui) {
78
+ e.preventDefault();
79
+ var originalValue = this.model.get('value');
80
+ if(originalValue === ""){
81
+ this.set(ui.item.value);
82
+ }
83
+ else
84
+ {
85
+ this.set(ui.item.value + ' OR ' + originalValue);
86
+ }
87
+
88
+ // if (originalValue != ui.item.value || this.box.val() != ui.item.value) {
89
+ // if (this.options.app.options.autosearch) {
90
+ this.search(e);
91
+ // } else {
92
+ // this.options.app.searchBox.renderFacets();
93
+ // this.options.app.searchBox.focusNextFacet(this, 1, {viewPosition: this.options.order});
94
+ // }
95
+ // }
96
+ return false;
97
+ }, this),
98
+ open : _.bind(function(e, ui) {
99
+ var box = this.box;
100
+ this.box.autocomplete('widget').find('.ui-menu-item').each(function() {
101
+ //console.log($(this));
102
+ var $value = $(this),
103
+ autoCompleteData = $value.data('item.autocomplete') || $value.data('ui-autocomplete-item');
104
+
105
+ if (autoCompleteData['value'] == box.val() && box.data('uiAutocomplete').menu.activate) {
106
+ box.data('uiAutocomplete').menu.activate(new $.Event("mouseover"), $value);
107
+ }
108
+ });
109
+ }, this)
110
+ });
111
+
112
+ this.box.autocomplete('widget').addClass('VS-interface');
113
+ },
114
+
115
+ // As the facet's input field grows, it may move to the next line in the
116
+ // search box. `autoGrowInput` triggers an `updated` event on the input
117
+ // field, which is bound to this method to move the autocomplete menu.
118
+ moveAutocomplete : function() {
119
+ var autocomplete = this.box.data('uiAutocomplete');
120
+ if (autocomplete) {
121
+ autocomplete.menu.element.position({
122
+ my : "left top",
123
+ at : "left bottom",
124
+ of : this.box.data('uiAutocomplete').element,
125
+ collision : "flip",
126
+ offset : "0 5"
127
+ });
128
+ }
129
+ },
130
+
131
+ // When a user enters a facet and it is being edited, immediately show
132
+ // the autocomplete menu and size it to match the contents.
133
+ searchAutocomplete : function(e) {
134
+ var autocomplete = this.box.data('uiAutocomplete');
135
+ if (autocomplete) {
136
+ var menu = autocomplete.menu.element;
137
+ autocomplete.search();
138
+
139
+ // Resize the menu based on the correctly measured width of what's bigger:
140
+ // the menu's original size or the menu items' new size.
141
+ menu.outerWidth(Math.max(
142
+ menu.width('').outerWidth(),
143
+ autocomplete.element.outerWidth()
144
+ ));
145
+ }
146
+ },
147
+
148
+ // Closes the autocomplete menu. Called on disabling, selecting, deselecting,
149
+ // and anything else that takes focus out of the facet's input field.
150
+ closeAutocomplete : function() {
151
+ var autocomplete = this.box.data('uiAutocomplete');
152
+ if (autocomplete) autocomplete.close();
153
+ },
154
+
155
+ // Search terms used in the autocomplete menu. These are specific to the facet,
156
+ // and only match for the facet's category. The values are then matched on the
157
+ // first letter of any word in matches, and finally sorted according to the
158
+ // value's own category. You can pass `preserveOrder` as an option in the
159
+ // `facetMatches` callback to skip any further ordering done client-side.
160
+ autocompleteValues : function(req, resp) {
161
+ var category = this.model.get('category');
162
+ var value = this.model.get('value');
163
+ var searchTerm = req.term;
164
+
165
+ this.options.app.options.callbacks.valueMatches(category, searchTerm, function(matches, options) {
166
+ options = options || {};
167
+ matches = matches || [];
168
+
169
+ if (searchTerm && value != searchTerm) {
170
+ if (options.preserveMatches) {
171
+ resp(matches);
172
+ } else {
173
+ var re = VS.utils.inflector.escapeRegExp(searchTerm || '');
174
+ var matcher = new RegExp('\\b' + re, 'i');
175
+ matches = $.grep(matches, function(item) {
176
+ return matcher.test(item) ||
177
+ matcher.test(item.value) ||
178
+ matcher.test(item.label);
179
+ });
180
+ }
181
+ }
182
+
183
+ if (options.preserveOrder) {
184
+ resp(matches);
185
+ } else {
186
+ resp(_.sortBy(matches, function(match) {
187
+ if (match == value || match.value == value) return '';
188
+ else return match;
189
+ }));
190
+ }
191
+ });
192
+
193
+ },
194
+
195
+ // Sets the facet's model's value.
196
+ set : function(value) {
197
+ if (!value) return;
198
+ this.model.set({'value': value});
199
+ },
200
+
201
+ // Before the searchBox performs a search, we need to close the
202
+ // autocomplete menu.
203
+ search : function(e, direction) {
204
+ if (!direction) direction = 1;
205
+ this.closeAutocomplete();
206
+ this.options.app.searchBox.searchEvent(e);
207
+ _.defer(_.bind(function() {
208
+ this.options.app.searchBox.focusNextFacet(this, direction, {viewPosition: this.options.order});
209
+ }, this));
210
+ },
211
+
212
+ // Begin editing the facet's input. This is called when the user enters
213
+ // the input either from another facet or directly clicking on it.
214
+ //
215
+ // This method tells all other facets and inputs to disable so it can have
216
+ // the sole focus. It also prepares the autocompletion menu.
217
+ enableEdit : function() {
218
+ if (this.modes.editing != 'is') {
219
+ this.setMode('is', 'editing');
220
+ this.deselectFacet();
221
+ if (this.box.val() == '') {
222
+ this.box.val(this.model.get('value'));
223
+ }
224
+ }
225
+
226
+ this.flags.canClose = false;
227
+ this.options.app.searchBox.disableFacets(this);
228
+ this.options.app.searchBox.addFocus();
229
+ _.defer(_.bind(function() {
230
+ this.options.app.searchBox.addFocus();
231
+ }, this));
232
+ this.resize();
233
+ this.searchAutocomplete();
234
+ this.box.focus();
235
+ },
236
+
237
+ // When the user blurs the input, they may either be going to another input
238
+ // or off the search box entirely. If they go to another input, this facet
239
+ // will be instantly disabled, and the canClose flag will be turned back off.
240
+ //
241
+ // However, if the user clicks elsewhere on the page, this method starts a timer
242
+ // that checks if any of the other inputs are selected or are being edited. If
243
+ // not, then it can finally close itself and its autocomplete menu.
244
+ deferDisableEdit : function() {
245
+ this.flags.canClose = true;
246
+ _.delay(_.bind(function() {
247
+ if (this.flags.canClose && !this.box.is(':focus') &&
248
+ this.modes.editing == 'is' && this.modes.selected != 'is') {
249
+ this.disableEdit();
250
+ }
251
+ }, this), 250);
252
+ },
253
+
254
+ // Called either by other facets receiving focus or by the timer in `deferDisableEdit`,
255
+ // this method will turn off the facet, remove any text selection, and close
256
+ // the autocomplete menu.
257
+ disableEdit : function() {
258
+ var newFacetQuery = VS.utils.inflector.trim(this.box.val());
259
+ if (newFacetQuery != this.model.get('value')) {
260
+ this.set(newFacetQuery);
261
+ }
262
+ this.flags.canClose = false;
263
+ this.box.selectRange(0, 0);
264
+ this.box.blur();
265
+ this.setMode('not', 'editing');
266
+ this.closeAutocomplete();
267
+ this.options.app.searchBox.removeFocus();
268
+ },
269
+
270
+ // Selects the facet, which blurs the facet's input and highlights the facet.
271
+ // If this is the only facet being selected (and not part of a select all event),
272
+ // we attach a mouse/keyboard watcher to check if the next action by the user
273
+ // should delete this facet or just deselect it.
274
+ selectFacet : function(e) {
275
+ if (e) e.preventDefault();
276
+ var allSelected = this.options.app.searchBox.allSelected();
277
+ if (this.modes.selected == 'is') return;
278
+
279
+ if (this.box.is(':focus')) {
280
+ this.box.setCursorPosition(0);
281
+ this.box.blur();
282
+ }
283
+
284
+ this.flags.canClose = false;
285
+ this.closeAutocomplete();
286
+ this.setMode('is', 'selected');
287
+ this.setMode('not', 'editing');
288
+ if (!allSelected || e) {
289
+ $(document).unbind('keydown.facet', this.keydown);
290
+ $(document).unbind('click.facet', this.deselectFacet);
291
+ _.defer(_.bind(function() {
292
+ $(document).unbind('keydown.facet').bind('keydown.facet', this.keydown);
293
+ $(document).unbind('click.facet').one('click.facet', this.deselectFacet);
294
+ }, this));
295
+ this.options.app.searchBox.disableFacets(this);
296
+ this.options.app.searchBox.addFocus();
297
+ }
298
+ return false;
299
+ },
300
+
301
+ // Turns off highlighting on the facet. Called in a variety of ways, this
302
+ // only deselects the facet if it is selected, and then cleans up the
303
+ // keyboard/mouse watchers that were created when the facet was first
304
+ // selected.
305
+ deselectFacet : function(e) {
306
+ if (e) e.preventDefault();
307
+ if (this.modes.selected == 'is') {
308
+ this.setMode('not', 'selected');
309
+ this.closeAutocomplete();
310
+ this.options.app.searchBox.removeFocus();
311
+ }
312
+ $(document).unbind('keydown.facet', this.keydown);
313
+ $(document).unbind('click.facet', this.deselectFacet);
314
+ return false;
315
+ },
316
+
317
+ // Is the user currently focused in this facet's input field?
318
+ isFocused : function() {
319
+ return this.box.is(':focus');
320
+ },
321
+
322
+ // Hovering over the delete button styles the facet so the user knows that
323
+ // the delete button will kill the entire facet.
324
+ showDelete : function() {
325
+ $(this.el).addClass('search_facet_maybe_delete');
326
+ },
327
+
328
+ // On `mouseout`, the user is no longer hovering on the delete button.
329
+ hideDelete : function() {
330
+ $(this.el).removeClass('search_facet_maybe_delete');
331
+ },
332
+
333
+ // When switching between facets, depending on the direction the cursor is
334
+ // coming from, the cursor in this facet's input field should match the original
335
+ // direction.
336
+ setCursorAtEnd : function(direction) {
337
+ if (direction == -1) {
338
+ this.box.setCursorPosition(this.box.val().length);
339
+ } else {
340
+ this.box.setCursorPosition(0);
341
+ }
342
+ },
343
+
344
+ // Deletes the facet and sends the cursor over to the nearest input field.
345
+ remove : function(e) {
346
+ var committed = this.model.get('value');
347
+ this.deselectFacet();
348
+ this.disableEdit();
349
+ this.options.app.searchQuery.remove(this.model);
350
+ if (committed && this.options.app.options.autosearch) {
351
+ this.search(e, -1);
352
+ } else {
353
+ this.options.app.searchBox.renderFacets();
354
+ this.options.app.searchBox.focusNextFacet(this, -1, {viewPosition: this.options.order});
355
+ }
356
+ },
357
+
358
+ // Selects the text in the facet's input field. When the user tabs between
359
+ // facets, convention is to highlight the entire field.
360
+ selectText: function() {
361
+ this.box.selectRange(0, this.box.val().length);
362
+ },
363
+
364
+ // Handles all keyboard inputs when in the facet's input field. This checks
365
+ // for movement between facets and inputs, entering a new value that needs
366
+ // to be autocompleted, as well as the removal of this facet.
367
+ keydown : function(e) {
368
+ var key = VS.app.hotkeys.key(e);
369
+
370
+ if (key == 'enter' && this.box.val()) {
371
+ this.disableEdit();
372
+ this.search(e);
373
+ } else if (key == 'left') {
374
+ if (this.modes.selected == 'is') {
375
+ this.deselectFacet();
376
+ this.options.app.searchBox.focusNextFacet(this, -1, {startAtEnd: -1});
377
+ } else if (this.box.getCursorPosition() == 0 && !this.box.getSelection().length) {
378
+ this.selectFacet();
379
+ }
380
+ } else if (key == 'right') {
381
+ if (this.modes.selected == 'is') {
382
+ e.preventDefault();
383
+ this.deselectFacet();
384
+ this.setCursorAtEnd(0);
385
+ this.enableEdit();
386
+ } else if (this.box.getCursorPosition() == this.box.val().length) {
387
+ e.preventDefault();
388
+ this.disableEdit();
389
+ this.options.app.searchBox.focusNextFacet(this, 1);
390
+ }
391
+ } else if (VS.app.hotkeys.shift && key == 'tab') {
392
+ e.preventDefault();
393
+ this.options.app.searchBox.focusNextFacet(this, -1, {
394
+ startAtEnd : -1,
395
+ skipToFacet : true,
396
+ selectText : true
397
+ });
398
+ } else if (key == 'tab') {
399
+ e.preventDefault();
400
+ this.options.app.searchBox.focusNextFacet(this, 1, {
401
+ skipToFacet : true,
402
+ selectText : true
403
+ });
404
+ } else if (VS.app.hotkeys.command && (e.which == 97 || e.which == 65)) {
405
+ e.preventDefault();
406
+ this.options.app.searchBox.selectAllFacets();
407
+ return false;
408
+ } else if (VS.app.hotkeys.printable(e) && this.modes.selected == 'is') {
409
+ this.options.app.searchBox.focusNextFacet(this, -1, {startAtEnd: -1});
410
+ this.remove(e);
411
+ } else if (key == 'backspace') {
412
+ $(document).on('keydown.backspace', function(e) {
413
+ if (VS.app.hotkeys.key(e) === 'backspace') {
414
+ e.preventDefault();
415
+ }
416
+ });
417
+
418
+ $(document).on('keyup.backspace', function(e) {
419
+ $(document).off('.backspace');
420
+ });
421
+
422
+ if (this.modes.selected == 'is') {
423
+ e.preventDefault();
424
+ this.remove(e);
425
+ } else if (this.box.getCursorPosition() == 0 &&
426
+ !this.box.getSelection().length) {
427
+ e.preventDefault();
428
+ this.selectFacet();
429
+ }
430
+ e.stopPropagation();
431
+ }
432
+
433
+ // Handle paste events
434
+ if (e.which == null) {
435
+ // this.searchAutocomplete(e);
436
+ _.defer(_.bind(this.resize, this, e));
437
+ } else {
438
+ this.resize(e);
439
+ }
440
+ }
441
+
442
+ });
443
+
444
+ })();
@@ -0,0 +1,409 @@
1
+ (function() {
2
+
3
+ var $ = jQuery; // Handle namespaced jQuery
4
+
5
+ // This is the visual search input that is responsible for creating new facets.
6
+ // There is one input placed in between all facets.
7
+ VS.ui.SearchInput = Backbone.View.extend({
8
+
9
+ type : 'text',
10
+
11
+ className : 'search_input ui-menu',
12
+
13
+ events : {
14
+ 'keypress input' : 'keypress',
15
+ 'keydown input' : 'keydown',
16
+ 'click input' : 'maybeTripleClick',
17
+ 'dblclick input' : 'startTripleClickTimer'
18
+ },
19
+
20
+ initialize : function() {
21
+ this.app = this.options.app;
22
+ this.flags = {
23
+ canClose : false
24
+ };
25
+ _.bindAll(this, 'removeFocus', 'addFocus', 'moveAutocomplete', 'deferDisableEdit');
26
+ },
27
+
28
+ // Rendering the input sets up autocomplete, events on focusing and blurring
29
+ // the input, and the auto-grow of the input.
30
+ render : function() {
31
+ $(this.el).html(JST['search_input']({}));
32
+
33
+ this.setMode('not', 'editing');
34
+ this.setMode('not', 'selected');
35
+ this.box = this.$('input');
36
+ this.box.autoGrowInput();
37
+ this.box.bind('updated.autogrow', this.moveAutocomplete);
38
+ this.box.bind('blur', this.deferDisableEdit);
39
+ this.box.bind('focus', this.addFocus);
40
+ this.setupAutocomplete();
41
+
42
+ return this;
43
+ },
44
+
45
+ // Watches the input and presents an autocompleted menu, taking the
46
+ // remainder of the input field and adding a separate facet for it.
47
+ //
48
+ // See `addTextFacetRemainder` for explanation on how the remainder works.
49
+ setupAutocomplete : function() {
50
+ this.box.autocomplete({
51
+ minLength : this.options.showFacets ? 0 : 1,
52
+ delay : 50,
53
+ autoFocus : true,
54
+ position : {offset : "0 -1"},
55
+ source : _.bind(this.autocompleteValues, this),
56
+ // Prevent changing the input value on focus of an option
57
+ focus : function() { return false; },
58
+ create : _.bind(function(e, ui) {
59
+ $(this.el).find('.ui-autocomplete-input').css('z-index','auto');
60
+ }, this),
61
+ select : _.bind(function(e, ui) {
62
+ e.preventDefault();
63
+ // stopPropogation does weird things in jquery-ui 1.9
64
+ // e.stopPropagation();
65
+ var remainder = this.addTextFacetRemainder(ui.item.value);
66
+ var position = this.options.position + (remainder ? 1 : 0);
67
+ this.app.searchBox.addFacet(ui.item instanceof String ? ui.item : ui.item.value, '', position);
68
+ return false;
69
+ }, this)
70
+ });
71
+
72
+ // Renders the results grouped by the categories they belong to.
73
+ this.box.data('uiAutocomplete')._renderMenu = function(ul, items) {
74
+ var category = '';
75
+ _.each(items, _.bind(function(item, i) {
76
+ if (item.category && item.category != category) {
77
+ ul.append('<li class="ui-autocomplete-category">'+item.category+'</li>');
78
+ category = item.category;
79
+ }
80
+
81
+ if(this._renderItemData) {
82
+ this._renderItemData(ul, item);
83
+ } else {
84
+ this._renderItem(ul, item);
85
+ }
86
+
87
+ }, this));
88
+ };
89
+
90
+ this.box.autocomplete('widget').addClass('VS-interface');
91
+ },
92
+
93
+ // Search terms used in the autocomplete menu. The values are matched on the
94
+ // first letter of any word in matches, and finally sorted according to the
95
+ // value's own category. You can pass `preserveOrder` as an option in the
96
+ // `facetMatches` callback to skip any further ordering done client-side.
97
+ autocompleteValues : function(req, resp) {
98
+ var searchTerm = req.term;
99
+ var lastWord = searchTerm.match(/\w+\*?$/); // Autocomplete only last word.
100
+ var re = VS.utils.inflector.escapeRegExp(lastWord && lastWord[0] || '');
101
+ this.app.options.callbacks.facetMatches(function(prefixes, options) {
102
+ options = options || {};
103
+ prefixes = prefixes || [];
104
+
105
+ // Only match from the beginning of the word.
106
+ var matcher = new RegExp('^' + re, 'i');
107
+ var matches = $.grep(prefixes, function(item) {
108
+ return item && matcher.test(item.label || item);
109
+ });
110
+
111
+ if (options.preserveOrder) {
112
+ resp(matches);
113
+ } else {
114
+ resp(_.sortBy(matches, function(match) {
115
+ if (match.label) return match.category + '-' + match.label;
116
+ else return match;
117
+ }));
118
+ }
119
+ });
120
+
121
+ },
122
+
123
+ // Closes the autocomplete menu. Called on disabling, selecting, deselecting,
124
+ // and anything else that takes focus out of the facet's input field.
125
+ closeAutocomplete : function() {
126
+ var autocomplete = this.box.data('uiAutocomplete');
127
+ if (autocomplete) autocomplete.close();
128
+ },
129
+
130
+ // As the input field grows, it may move to the next line in the
131
+ // search box. `autoGrowInput` triggers an `updated` event on the input
132
+ // field, which is bound to this method to move the autocomplete menu.
133
+ moveAutocomplete : function() {
134
+ var autocomplete = this.box.data('uiAutocomplete');
135
+ if (autocomplete) {
136
+ autocomplete.menu.element.position({
137
+ my : "left top",
138
+ at : "left bottom",
139
+ of : this.box.data('uiAutocomplete').element,
140
+ collision : "none",
141
+ offset : '0 -1'
142
+ });
143
+ }
144
+ },
145
+
146
+ // When a user enters a facet and it is being edited, immediately show
147
+ // the autocomplete menu and size it to match the contents.
148
+ searchAutocomplete : function(e) {
149
+ var autocomplete = this.box.data('uiAutocomplete');
150
+ if (autocomplete) {
151
+ var menu = autocomplete.menu.element;
152
+ autocomplete.search();
153
+
154
+ // Resize the menu based on the correctly measured width of what's bigger:
155
+ // the menu's original size or the menu items' new size.
156
+ menu.outerWidth(Math.max(
157
+ menu.width('').outerWidth(),
158
+ autocomplete.element.outerWidth()
159
+ ));
160
+ }
161
+ },
162
+
163
+ // If a user searches for "word word category", the category would be
164
+ // matched and autocompleted, and when selected, the "word word" would
165
+ // also be caught as the remainder and then added in its own facet.
166
+ addTextFacetRemainder : function(facetValue) {
167
+ var boxValue = this.box.val();
168
+ var lastWord = boxValue.match(/\b(\w+)$/);
169
+
170
+ if (!lastWord) {
171
+ return '';
172
+ }
173
+
174
+ var matcher = new RegExp(lastWord[0], "i");
175
+ if (facetValue.search(matcher) == 0) {
176
+ boxValue = boxValue.replace(/\b(\w+)$/, '');
177
+ }
178
+ boxValue = boxValue.replace('^\s+|\s+$', '');
179
+
180
+ if (boxValue) {
181
+ this.app.searchBox.addFacet(this.app.options.remainder, boxValue, this.options.position);
182
+ }
183
+
184
+ return boxValue;
185
+ },
186
+
187
+ // Directly called to focus the input. This is different from `addFocus`
188
+ // because this is not called by a focus event. This instead calls a
189
+ // focus event causing the input to become focused.
190
+ enableEdit : function(selectText) {
191
+ this.addFocus();
192
+ if (selectText) {
193
+ this.selectText();
194
+ }
195
+ this.box.focus();
196
+ },
197
+
198
+ // Event called on user focus on the input. Tells all other input and facets
199
+ // to give up focus, and starts revving the autocomplete.
200
+ addFocus : function() {
201
+ this.flags.canClose = false;
202
+ if (!this.app.searchBox.allSelected()) {
203
+ this.app.searchBox.disableFacets(this);
204
+ }
205
+ this.app.searchBox.addFocus();
206
+ this.setMode('is', 'editing');
207
+ this.setMode('not', 'selected');
208
+ if (!this.app.searchBox.allSelected()) {
209
+ this.searchAutocomplete();
210
+ }
211
+ },
212
+
213
+ // Directly called to blur the input. This is different from `removeFocus`
214
+ // because this is not called by a blur event.
215
+ disableEdit : function() {
216
+ this.box.blur();
217
+ this.removeFocus();
218
+ },
219
+
220
+ // Event called when user blur's the input, either through the keyboard tabbing
221
+ // away or the mouse clicking off. Cleans up
222
+ removeFocus : function() {
223
+ this.flags.canClose = false;
224
+ this.app.searchBox.removeFocus();
225
+ this.setMode('not', 'editing');
226
+ this.setMode('not', 'selected');
227
+ this.closeAutocomplete();
228
+ },
229
+
230
+ // When the user blurs the input, they may either be going to another input
231
+ // or off the search box entirely. If they go to another input, this facet
232
+ // will be instantly disabled, and the canClose flag will be turned back off.
233
+ //
234
+ // However, if the user clicks elsewhere on the page, this method starts a timer
235
+ // that checks if any of the other inputs are selected or are being edited. If
236
+ // not, then it can finally close itself and its autocomplete menu.
237
+ deferDisableEdit : function() {
238
+ this.flags.canClose = true;
239
+ _.delay(_.bind(function() {
240
+ if (this.flags.canClose &&
241
+ !this.box.is(':focus') &&
242
+ this.modes.editing == 'is') {
243
+ this.disableEdit();
244
+ }
245
+ }, this), 250);
246
+ },
247
+
248
+ // Starts a timer that will cause a triple-click, which highlights all facets.
249
+ startTripleClickTimer : function() {
250
+ this.tripleClickTimer = setTimeout(_.bind(function() {
251
+ this.tripleClickTimer = null;
252
+ }, this), 500);
253
+ },
254
+
255
+ // Event on click that checks if a triple click is in play. The
256
+ // `tripleClickTimer` is counting down, ready to be engaged and intercept
257
+ // the click event to force a select all instead.
258
+ maybeTripleClick : function(e) {
259
+ if (!!this.tripleClickTimer) {
260
+ e.preventDefault();
261
+ this.app.searchBox.selectAllFacets();
262
+ return false;
263
+ }
264
+ },
265
+
266
+ // Is the user currently focused in the input field?
267
+ isFocused : function() {
268
+ return this.box.is(':focus');
269
+ },
270
+
271
+ // When serializing the facets, the inputs need to also have their values represented,
272
+ // in case they contain text that is not yet faceted (but will be once the search is
273
+ // completed).
274
+ value : function() {
275
+ return this.box.val();
276
+ },
277
+
278
+ // When switching between facets and inputs, depending on the direction the cursor
279
+ // is coming from, the cursor in this facet's input field should match the original
280
+ // direction.
281
+ setCursorAtEnd : function(direction) {
282
+ if (direction == -1) {
283
+ this.box.setCursorPosition(this.box.val().length);
284
+ } else {
285
+ this.box.setCursorPosition(0);
286
+ }
287
+ },
288
+
289
+ // Selects the entire range of text in the input. Useful when tabbing between inputs
290
+ // and facets.
291
+ selectText : function() {
292
+ this.box.selectRange(0, this.box.val().length);
293
+ if (!this.app.searchBox.allSelected()) {
294
+ this.box.focus();
295
+ } else {
296
+ this.setMode('is', 'selected');
297
+ }
298
+ },
299
+
300
+ // Before the searchBox performs a search, we need to close the
301
+ // autocomplete menu.
302
+ search : function(e, direction) {
303
+ if (!direction) direction = 0;
304
+ this.closeAutocomplete();
305
+ this.app.searchBox.searchEvent(e);
306
+ _.defer(_.bind(function() {
307
+ this.app.searchBox.focusNextFacet(this, direction);
308
+ }, this));
309
+ },
310
+
311
+ // Callback fired on key press in the search box. We search when they hit return.
312
+ keypress : function(e) {
313
+ var key = VS.app.hotkeys.key(e);
314
+
315
+ if (key == 'enter') {
316
+ return this.search(e, 100);
317
+ } else if (VS.app.hotkeys.colon(e)) {
318
+ this.box.trigger('resize.autogrow', e);
319
+ var query = this.box.val();
320
+ var prefixes = [];
321
+ if (this.app.options.callbacks.facetMatches) {
322
+ this.app.options.callbacks.facetMatches(function(p) {
323
+ prefixes = p;
324
+ });
325
+ }
326
+ var labels = _.map(prefixes, function(prefix) {
327
+ if (prefix.label) return prefix.label;
328
+ else return prefix;
329
+ });
330
+ if (_.contains(labels, query)) {
331
+ e.preventDefault();
332
+ var remainder = this.addTextFacetRemainder(query);
333
+ var position = this.options.position + (remainder?1:0);
334
+ this.app.searchBox.addFacet(query, '', position);
335
+ return false;
336
+ }
337
+ } else if (key == 'backspace') {
338
+ if (this.box.getCursorPosition() == 0 && !this.box.getSelection().length) {
339
+ e.preventDefault();
340
+ e.stopPropagation();
341
+ e.stopImmediatePropagation();
342
+ this.app.searchBox.resizeFacets();
343
+ return false;
344
+ }
345
+ }
346
+ },
347
+
348
+ // Handles all keyboard inputs when in the input field. This checks
349
+ // for movement between facets and inputs, entering a new value that needs
350
+ // to be autocompleted, as well as stepping between facets with backspace.
351
+ keydown : function(e) {
352
+ var key = VS.app.hotkeys.key(e);
353
+
354
+ if (key == 'left') {
355
+ if (this.box.getCursorPosition() == 0) {
356
+ e.preventDefault();
357
+ this.app.searchBox.focusNextFacet(this, -1, {startAtEnd: -1});
358
+ }
359
+ } else if (key == 'right') {
360
+ if (this.box.getCursorPosition() == this.box.val().length) {
361
+ e.preventDefault();
362
+ this.app.searchBox.focusNextFacet(this, 1, {selectFacet: true});
363
+ }
364
+ } else if (VS.app.hotkeys.shift && key == 'tab') {
365
+ e.preventDefault();
366
+ this.app.searchBox.focusNextFacet(this, -1, {selectText: true});
367
+ } else if (key == 'tab') {
368
+ var value = this.box.val();
369
+ if (value.length) {
370
+ e.preventDefault();
371
+ var remainder = this.addTextFacetRemainder(value);
372
+ var position = this.options.position + (remainder?1:0);
373
+ if (value != remainder) {
374
+ this.app.searchBox.addFacet(value, '', position);
375
+ }
376
+ } else {
377
+ var foundFacet = this.app.searchBox.focusNextFacet(this, 0, {
378
+ skipToFacet: true,
379
+ selectText: true
380
+ });
381
+ if (foundFacet) {
382
+ e.preventDefault();
383
+ }
384
+ }
385
+ } else if (VS.app.hotkeys.command &&
386
+ String.fromCharCode(e.which).toLowerCase() == 'a') {
387
+ e.preventDefault();
388
+ this.app.searchBox.selectAllFacets();
389
+ return false;
390
+ } else if (key == 'backspace' && !this.app.searchBox.allSelected()) {
391
+ if (this.box.getCursorPosition() == 0 && !this.box.getSelection().length) {
392
+ e.preventDefault();
393
+ this.app.searchBox.focusNextFacet(this, -1, {backspace: true});
394
+ return false;
395
+ }
396
+ } else if (key == 'end') {
397
+ var view = this.app.searchBox.inputViews[this.app.searchBox.inputViews.length-1];
398
+ view.setCursorAtEnd(-1);
399
+ } else if (key == 'home') {
400
+ var view = this.app.searchBox.inputViews[0];
401
+ view.setCursorAtEnd(-1);
402
+ }
403
+
404
+ this.box.trigger('resize.autogrow', e);
405
+ }
406
+
407
+ });
408
+
409
+ })();