visualsearch-rails 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +7 -0
- data/.travis.yml +4 -0
- data/Changelog.md +4 -0
- data/Gemfile +2 -0
- data/Rakefile +5 -0
- data/Readme.md +24 -0
- data/app/assets/css/icons.css +19 -0
- data/app/assets/css/reset.css +30 -0
- data/app/assets/css/workspace.css +290 -0
- data/app/assets/images/cancel_search.png +0 -0
- data/app/assets/images/search_glyph.png +0 -0
- data/app/assets/javascripts/backbone-0.9.10.js +1498 -0
- data/app/assets/javascripts/dependencies.js +14843 -0
- data/app/assets/javascripts/jquery.ui.autocomplete.js +614 -0
- data/app/assets/javascripts/jquery.ui.core.js +324 -0
- data/app/assets/javascripts/jquery.ui.datepicker.js +5 -0
- data/app/assets/javascripts/jquery.ui.menu.js +621 -0
- data/app/assets/javascripts/jquery.ui.position.js +497 -0
- data/app/assets/javascripts/jquery.ui.widget.js +521 -0
- data/app/assets/javascripts/underscore-1.4.3.js +1221 -0
- data/app/assets/javascripts/visualsearch/js/models/search_facets.js +67 -0
- data/app/assets/javascripts/visualsearch/js/models/search_query.js +70 -0
- data/app/assets/javascripts/visualsearch/js/templates/search_box.jst +8 -0
- data/app/assets/javascripts/visualsearch/js/templates/search_facet.jst +9 -0
- data/app/assets/javascripts/visualsearch/js/templates/search_input.jst +1 -0
- data/app/assets/javascripts/visualsearch/js/templates/templates.js +7 -0
- data/app/assets/javascripts/visualsearch/js/utils/backbone_extensions.js +17 -0
- data/app/assets/javascripts/visualsearch/js/utils/hotkeys.js +99 -0
- data/app/assets/javascripts/visualsearch/js/utils/inflector.js +21 -0
- data/app/assets/javascripts/visualsearch/js/utils/jquery_extensions.js +197 -0
- data/app/assets/javascripts/visualsearch/js/utils/search_parser.js +87 -0
- data/app/assets/javascripts/visualsearch/js/views/search_box.js +447 -0
- data/app/assets/javascripts/visualsearch/js/views/search_facet.js +444 -0
- data/app/assets/javascripts/visualsearch/js/views/search_input.js +409 -0
- data/app/assets/javascripts/visualsearch/js/visualsearch.js +77 -0
- data/lib/generators/visual_search_install.rb +30 -0
- data/lib/visualsearch-rails.rb +2 -0
- data/lib/visualsearch/rails.rb +6 -0
- data/lib/visualsearch/version.rb +3 -0
- data/visualsearch-rails.gemspec +26 -0
- 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
|
+
})();
|