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.
- 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
|
+
})();
|