visualsearch-rails 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
+ })();