15 | -
16 | Jump To …
17 | +
18 |
19 |
82 |
83 |
84 |
85 |
86 |
87 | -
88 |
89 |
hotkeys.js
90 |
91 |
92 |
93 |
94 |
95 | -
96 |
103 |
104 |
(function() {
105 |
106 | var $ = jQuery;
107 |
108 |
109 |
110 |
111 | -
112 |
113 |
114 |
117 |
DocumentCloud workspace hotkeys. To tell if a key is currently being pressed,
118 | just ask VS.app.hotkeys.[key] on keypress, or ask VS.app.hotkeys.key(e)
119 | on keydown.
120 |
For the most headache-free way to use this utility, check modifier keys,
121 | like shift and command, with VS.app.hotkeys.shift, and check every other
122 | key with VS.app.hotkeys.key(e) == 'key_name'.
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 | -
132 |
133 |
134 |
137 |
Keys that will be mapped to the hotkeys namespace.
138 |
139 |
140 |
141 | KEYS: {
142 | '16': 'shift',
143 | '17': 'command',
144 | '91': 'command',
145 | '93': 'command',
146 | '224': 'command',
147 | '13': 'enter',
148 | '37': 'left',
149 | '38': 'upArrow',
150 | '39': 'right',
151 | '40': 'downArrow',
152 | '46': 'delete',
153 | '8': 'backspace',
154 | '35': 'end',
155 | '36': 'home',
156 | '9': 'tab',
157 | '188': 'comma'
158 | },
159 |
160 |
161 |
162 |
163 | -
164 |
165 |
166 |
169 |
Binds global keydown and keyup events to listen for keys that match this.KEYS.
170 |
171 |
172 |
173 | initialize : function() {
174 | _.bindAll(this, 'down', 'up', 'blur');
175 | $(document).bind('keydown', this.down);
176 | $(document).bind('keyup', this.up);
177 | $(window).bind('blur', this.blur);
178 | },
179 |
180 |
181 |
182 |
183 | -
184 |
185 |
186 |
189 |
On keydown, turn on all keys that match.
190 |
191 |
192 |
193 | down : function(e) {
194 | var key = this.KEYS[e.which];
195 | if (key) this[key] = true;
196 | },
197 |
198 |
199 |
200 |
201 | -
202 |
203 |
204 |
207 |
On keyup, turn off all keys that match.
208 |
209 |
210 |
211 | up : function(e) {
212 | var key = this.KEYS[e.which];
213 | if (key) this[key] = false;
214 | },
215 |
216 |
217 |
218 |
219 | -
220 |
221 |
222 |
225 |
If an input is blurred, all keys need to be turned off, since they are no longer
226 | able to modify the document.
227 |
228 |
229 |
230 | blur : function(e) {
231 | for (var key in this.KEYS) this[this.KEYS[key]] = false;
232 | },
233 |
234 |
235 |
236 |
237 | -
238 |
239 |
240 |
243 |
Check a key from an event and return the common english name.
244 |
245 |
246 |
247 | key : function(e) {
248 | return this.KEYS[e.which];
249 | },
250 |
251 |
252 |
253 |
254 | -
255 |
256 |
257 |
260 |
Colon is special, since the value is different between browsers.
261 |
262 |
263 |
264 | colon : function(e) {
265 | var charCode = e.which;
266 | return charCode && String.fromCharCode(charCode) == ":";
267 | },
268 |
269 |
270 |
271 |
272 | -
273 |
274 |
275 |
278 |
Check a key from an event and match it against any known characters.
279 | The keyCode is different depending on the event type: keydown vs. keypress.
280 |
These were determined by looping through every keyCode and charCode that
281 | resulted from keydown and keypress events and counting what was printable.
282 |
283 |
284 |
285 | printable : function(e) {
286 | var code = e.which;
287 | if (e.type == 'keydown') {
288 | if (code == 32 ||
289 | (code >= 48 && code <= 90) ||
290 | (code >= 96 && code <= 111) ||
291 | (code >= 186 && code <= 192) ||
292 | (code >= 219 && code <= 222)) {
293 | return true;
294 | }
295 | } else {
296 |
297 |
298 |
299 |
300 | -
301 |
302 |
303 |
306 |
[space]!”#$%&’()*+,-.0-9:;<=>?@A-Z[]^_`a-z{|} and unicode characters
307 |
308 |
309 |
310 | if ((code >= 32 && code <= 126) ||
311 | (code >= 160 && code <= 500) ||
312 | (String.fromCharCode(code) == ":")) {
313 | return true;
314 | }
315 | }
316 | return false;
317 | }
318 |
319 | };
320 |
321 | })();
322 |
323 |
324 |
325 |
326 |
327 |
328 |
329 |
--------------------------------------------------------------------------------
/lib/js/views/search_input.js:
--------------------------------------------------------------------------------
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 | 'keyup input' : 'keyup',
17 | 'click input' : 'maybeTripleClick',
18 | 'dblclick input' : 'startTripleClickTimer'
19 | },
20 |
21 | initialize : function(options) {
22 | this.options = _.extend({}, this.options, options);
23 |
24 | this.app = this.options.app;
25 | this.flags = {
26 | canClose : false
27 | };
28 | _.bindAll(this, 'removeFocus', 'addFocus', 'moveAutocomplete', 'deferDisableEdit');
29 | },
30 |
31 | // Rendering the input sets up autocomplete, events on focusing and blurring
32 | // the input, and the auto-grow of the input.
33 | render : function() {
34 | $(this.el).html(JST['search_input']({
35 | readOnly: this.app.options.readOnly
36 | }));
37 |
38 | this.setMode('not', 'editing');
39 | this.setMode('not', 'selected');
40 | this.box = this.$('input');
41 | this.box.autoGrowInput();
42 | this.box.bind('updated.autogrow', this.moveAutocomplete);
43 | this.box.bind('blur', this.deferDisableEdit);
44 | this.box.bind('focus', this.addFocus);
45 | this.setupAutocomplete();
46 |
47 | return this;
48 | },
49 |
50 | // Watches the input and presents an autocompleted menu, taking the
51 | // remainder of the input field and adding a separate facet for it.
52 | //
53 | // See `addTextFacetRemainder` for explanation on how the remainder works.
54 | setupAutocomplete : function() {
55 | this.box.autocomplete({
56 | minLength : this.options.showFacets ? 0 : 1,
57 | delay : 50,
58 | autoFocus : true,
59 | position : {offset : "0 -1"},
60 | source : _.bind(this.autocompleteValues, this),
61 | // Prevent changing the input value on focus of an option
62 | focus : function() { return false; },
63 | create : _.bind(function(e, ui) {
64 | $(this.el).find('.ui-autocomplete-input').css('z-index','auto');
65 | }, this),
66 | select : _.bind(function(e, ui) {
67 | e.preventDefault();
68 | // stopPropogation does weird things in jquery-ui 1.9
69 | // e.stopPropagation();
70 | var remainder = this.addTextFacetRemainder(ui.item.label || ui.item.value);
71 | var position = this.options.position + (remainder ? 1 : 0);
72 | this.app.searchBox.addFacet(ui.item instanceof String ? ui.item : ui.item.value, '', position);
73 | return false;
74 | }, this)
75 | });
76 |
77 | // Renders the results grouped by the categories they belong to.
78 | this.box.data('ui-autocomplete')._renderMenu = function(ul, items) {
79 | var category = '';
80 | _.each(items, _.bind(function(item, i) {
81 | if (item.category && item.category != category) {
82 | ul.append(' - '+item.category+'
');
83 | category = item.category;
84 | }
85 |
86 | if(this._renderItemData) {
87 | this._renderItemData(ul, item);
88 | } else {
89 | this._renderItem(ul, item);
90 | }
91 |
92 | }, this));
93 | };
94 |
95 | this.box.autocomplete('widget').addClass('VS-interface');
96 | },
97 |
98 | // Search terms used in the autocomplete menu. The values are matched on the
99 | // first letter of any word in matches, and finally sorted according to the
100 | // value's own category. You can pass `preserveOrder` as an option in the
101 | // `facetMatches` callback to skip any further ordering done client-side.
102 | autocompleteValues : function(req, resp) {
103 | var searchTerm = req.term;
104 | var lastWord = searchTerm.match(/\w+\*?$/); // Autocomplete only last word.
105 | var re = VS.utils.inflector.escapeRegExp(lastWord && lastWord[0] || '');
106 | this.app.options.callbacks.facetMatches(function(prefixes, options) {
107 | options = options || {};
108 | prefixes = prefixes || [];
109 |
110 | // Only match from the beginning of the word.
111 | var matcher = new RegExp('^' + re, 'i');
112 | var matches = $.grep(prefixes, function(item) {
113 | return item && matcher.test(item.label || item);
114 | });
115 |
116 | if (options.preserveOrder) {
117 | resp(matches);
118 | } else {
119 | resp(_.sortBy(matches, function(match) {
120 | if (match.label) return match.category + '-' + match.label;
121 | else return match;
122 | }));
123 | }
124 | });
125 |
126 | },
127 |
128 | // Closes the autocomplete menu. Called on disabling, selecting, deselecting,
129 | // and anything else that takes focus out of the facet's input field.
130 | closeAutocomplete : function() {
131 | var autocomplete = this.box.data('ui-autocomplete');
132 | if (autocomplete) autocomplete.close();
133 | },
134 |
135 | // As the input field grows, it may move to the next line in the
136 | // search box. `autoGrowInput` triggers an `updated` event on the input
137 | // field, which is bound to this method to move the autocomplete menu.
138 | moveAutocomplete : function() {
139 | var autocomplete = this.box.data('ui-autocomplete');
140 | if (autocomplete) {
141 | autocomplete.menu.element.position({
142 | my : "left top",
143 | at : "left bottom",
144 | of : this.box.data('ui-autocomplete').element,
145 | collision : "none",
146 | offset : '0 -1'
147 | });
148 | }
149 | },
150 |
151 | // When a user enters a facet and it is being edited, immediately show
152 | // the autocomplete menu and size it to match the contents.
153 | searchAutocomplete : function(e) {
154 | var autocomplete = this.box.data('ui-autocomplete');
155 | if (autocomplete) {
156 | var menu = autocomplete.menu.element;
157 | autocomplete.search();
158 |
159 | // Resize the menu based on the correctly measured width of what's bigger:
160 | // the menu's original size or the menu items' new size.
161 | menu.outerWidth(Math.max(
162 | menu.width('').outerWidth(),
163 | autocomplete.element.outerWidth()
164 | ));
165 | }
166 | },
167 |
168 | // If a user searches for "word word category", the category would be
169 | // matched and autocompleted, and when selected, the "word word" would
170 | // also be caught as the remainder and then added in its own facet.
171 | addTextFacetRemainder : function(facetValue) {
172 | var boxValue = this.box.val();
173 | var lastWord = boxValue.match(/\b(\w+)$/);
174 |
175 | if (!lastWord) {
176 | return '';
177 | }
178 |
179 | var matcher = new RegExp(lastWord[0], "i");
180 | if (facetValue.search(matcher) == 0) {
181 | boxValue = boxValue.replace(/\b(\w+)$/, '');
182 | }
183 | boxValue = boxValue.replace('^\s+|\s+$', '');
184 |
185 | if (boxValue) {
186 | this.app.searchBox.addFacet(this.app.options.remainder, boxValue, this.options.position);
187 | }
188 |
189 | return boxValue;
190 | },
191 |
192 | // Directly called to focus the input. This is different from `addFocus`
193 | // because this is not called by a focus event. This instead calls a
194 | // focus event causing the input to become focused.
195 | enableEdit : function(selectText) {
196 | this.addFocus();
197 | if (selectText) {
198 | this.selectText();
199 | }
200 | this.box.focus();
201 | },
202 |
203 | // Event called on user focus on the input. Tells all other input and facets
204 | // to give up focus, and starts revving the autocomplete.
205 | addFocus : function() {
206 | this.flags.canClose = false;
207 | if (!this.app.searchBox.allSelected()) {
208 | this.app.searchBox.disableFacets(this);
209 | }
210 | this.app.searchBox.addFocus();
211 | this.setMode('is', 'editing');
212 | this.setMode('not', 'selected');
213 | if (!this.app.searchBox.allSelected()) {
214 | this.searchAutocomplete();
215 | }
216 | },
217 |
218 | // Directly called to blur the input. This is different from `removeFocus`
219 | // because this is not called by a blur event.
220 | disableEdit : function() {
221 | this.box.blur();
222 | this.removeFocus();
223 | },
224 |
225 | // Event called when user blur's the input, either through the keyboard tabbing
226 | // away or the mouse clicking off. Cleans up
227 | removeFocus : function() {
228 | this.flags.canClose = false;
229 | this.app.searchBox.removeFocus();
230 | this.setMode('not', 'editing');
231 | this.setMode('not', 'selected');
232 | this.closeAutocomplete();
233 | },
234 |
235 | // When the user blurs the input, they may either be going to another input
236 | // or off the search box entirely. If they go to another input, this facet
237 | // will be instantly disabled, and the canClose flag will be turned back off.
238 | //
239 | // However, if the user clicks elsewhere on the page, this method starts a timer
240 | // that checks if any of the other inputs are selected or are being edited. If
241 | // not, then it can finally close itself and its autocomplete menu.
242 | deferDisableEdit : function() {
243 | this.flags.canClose = true;
244 | _.delay(_.bind(function() {
245 | if (this.flags.canClose &&
246 | !this.box.is(':focus') &&
247 | this.modes.editing == 'is') {
248 | this.disableEdit();
249 | }
250 | }, this), 250);
251 | },
252 |
253 | // Starts a timer that will cause a triple-click, which highlights all facets.
254 | startTripleClickTimer : function() {
255 | this.tripleClickTimer = setTimeout(_.bind(function() {
256 | this.tripleClickTimer = null;
257 | }, this), 500);
258 | },
259 |
260 | // Event on click that checks if a triple click is in play. The
261 | // `tripleClickTimer` is counting down, ready to be engaged and intercept
262 | // the click event to force a select all instead.
263 | maybeTripleClick : function(e) {
264 | if (this.app.options.readOnly) return;
265 | if (!!this.tripleClickTimer) {
266 | e.preventDefault();
267 | this.app.searchBox.selectAllFacets();
268 | return false;
269 | }
270 | },
271 |
272 | // Is the user currently focused in the input field?
273 | isFocused : function() {
274 | return this.box.is(':focus');
275 | },
276 |
277 | // When serializing the facets, the inputs need to also have their values represented,
278 | // in case they contain text that is not yet faceted (but will be once the search is
279 | // completed).
280 | value : function() {
281 | return this.box.val();
282 | },
283 |
284 | // When switching between facets and inputs, depending on the direction the cursor
285 | // is coming from, the cursor in this facet's input field should match the original
286 | // direction.
287 | setCursorAtEnd : function(direction) {
288 | if (direction == -1) {
289 | this.box.setCursorPosition(this.box.val().length);
290 | } else {
291 | this.box.setCursorPosition(0);
292 | }
293 | },
294 |
295 | // Selects the entire range of text in the input. Useful when tabbing between inputs
296 | // and facets.
297 | selectText : function() {
298 | this.box.selectRange(0, this.box.val().length);
299 | if (!this.app.searchBox.allSelected()) {
300 | this.box.focus();
301 | } else {
302 | this.setMode('is', 'selected');
303 | }
304 | },
305 |
306 | // Before the searchBox performs a search, we need to close the
307 | // autocomplete menu.
308 | search : function(e, direction) {
309 | if (!direction) direction = 0;
310 | this.closeAutocomplete();
311 | this.app.searchBox.searchEvent(e);
312 | _.defer(_.bind(function() {
313 | this.app.searchBox.focusNextFacet(this, direction);
314 | }, this));
315 | },
316 |
317 | // Callback fired on key press in the search box. We search when they hit return.
318 | keypress : function(e) {
319 | var key = VS.app.hotkeys.key(e);
320 |
321 | if (key == 'enter') {
322 | return this.search(e, 100);
323 | } else if (VS.app.hotkeys.colon(e)) {
324 | this.box.trigger('resize.autogrow', e);
325 | var query = this.box.val();
326 | var prefixes = [];
327 | this.app.options.callbacks.facetMatches(function(p) {
328 | prefixes = p;
329 | });
330 | var labels = _.map(prefixes, function(prefix) {
331 | if (prefix.label) return prefix.label;
332 | else return prefix;
333 | });
334 | if (_.contains(labels, query)) {
335 | e.preventDefault();
336 | var remainder = this.addTextFacetRemainder(query);
337 | var position = this.options.position + (remainder?1:0);
338 | this.app.searchBox.addFacet(query, '', position);
339 | return false;
340 | }
341 | } else if (key == 'backspace') {
342 | if (this.box.getCursorPosition() == 0 && !this.box.getSelection().length) {
343 | e.preventDefault();
344 | e.stopPropagation();
345 | e.stopImmediatePropagation();
346 | this.app.searchBox.resizeFacets();
347 | return false;
348 | }
349 | }
350 | },
351 |
352 | // Handles all keyboard inputs when in the input field. This checks
353 | // for movement between facets and inputs, entering a new value that needs
354 | // to be autocompleted, as well as stepping between facets with backspace.
355 | keydown : function(e) {
356 | var key = VS.app.hotkeys.key(e);
357 |
358 | if (key == 'left') {
359 | if (this.box.getCursorPosition() == 0) {
360 | e.preventDefault();
361 | this.app.searchBox.focusNextFacet(this, -1, {startAtEnd: -1});
362 | }
363 | } else if (key == 'right') {
364 | if (this.box.getCursorPosition() == this.box.val().length) {
365 | e.preventDefault();
366 | this.app.searchBox.focusNextFacet(this, 1, {selectFacet: true});
367 | }
368 | } else if (VS.app.hotkeys.shift && key == 'tab') {
369 | e.preventDefault();
370 | this.app.searchBox.focusNextFacet(this, -1, {selectText: true});
371 | } else if (key == 'tab') {
372 | var value = this.box.val();
373 | if (value.length) {
374 | e.preventDefault();
375 | var remainder = this.addTextFacetRemainder(value);
376 | var position = this.options.position + (remainder?1:0);
377 | if (value != remainder) {
378 | this.app.searchBox.addFacet(value, '', position);
379 | }
380 | } else {
381 | var foundFacet = this.app.searchBox.focusNextFacet(this, 0, {
382 | skipToFacet: true,
383 | selectText: true
384 | });
385 | if (foundFacet) {
386 | e.preventDefault();
387 | }
388 | }
389 | } else if (VS.app.hotkeys.command &&
390 | String.fromCharCode(e.which).toLowerCase() == 'a') {
391 | e.preventDefault();
392 | this.app.searchBox.selectAllFacets();
393 | return false;
394 | } else if (key == 'backspace' && !this.app.searchBox.allSelected()) {
395 | if (this.box.getCursorPosition() == 0 && !this.box.getSelection().length) {
396 | e.preventDefault();
397 | this.app.searchBox.focusNextFacet(this, -1, {backspace: true});
398 | return false;
399 | }
400 | } else if (key == 'end') {
401 | var view = this.app.searchBox.inputViews[this.app.searchBox.inputViews.length-1];
402 | view.setCursorAtEnd(-1);
403 | } else if (key == 'home') {
404 | var view = this.app.searchBox.inputViews[0];
405 | view.setCursorAtEnd(-1);
406 | }
407 |
408 | },
409 |
410 | // We should get the value of an input should be done
411 | // on keyup since keydown gets the previous value and not the current one
412 | keyup : function(e) {
413 | this.box.trigger('resize.autogrow', e);
414 | }
415 |
416 | });
417 |
418 | })();
419 |
--------------------------------------------------------------------------------
/lib/js/views/search_box.js:
--------------------------------------------------------------------------------
1 | (function() {
2 |
3 | var $ = jQuery; // Handle namespaced jQuery
4 |
5 | // The search box is responsible for managing the many facet views and input views.
6 | VS.ui.SearchBox = Backbone.View.extend({
7 |
8 | id : 'search',
9 |
10 | events : {
11 | 'click .VS-cancel-search-box' : 'clearSearch',
12 | 'mousedown .VS-search-box' : 'maybeFocusSearch',
13 | 'dblclick .VS-search-box' : 'highlightSearch',
14 | 'click .VS-search-box' : 'maybeTripleClick'
15 | },
16 |
17 | // Creating a new SearchBox registers handlers for re-rendering facets when necessary,
18 | // as well as handling typing when a facet is selected.
19 | initialize : function(options) {
20 | this.options = _.extend({}, this.options, options);
21 |
22 | this.app = this.options.app;
23 | this.flags = {
24 | allSelected : false
25 | };
26 | this.facetViews = [];
27 | this.inputViews = [];
28 | _.bindAll(this, 'renderFacets', '_maybeDisableFacets', 'disableFacets',
29 | 'deselectAllFacets', 'addedFacet', 'removedFacet', 'changedFacet');
30 | this.app.searchQuery
31 | .bind('reset', this.renderFacets)
32 | .bind('add', this.addedFacet)
33 | .bind('remove', this.removedFacet)
34 | .bind('change', this.changedFacet);
35 | $(document).bind('keydown', this._maybeDisableFacets);
36 | },
37 |
38 | // Renders the search box, but requires placement on the page through `this.el`.
39 | render : function() {
40 | $(this.el).append(JST['search_box']({
41 | readOnly: this.app.options.readOnly
42 | }));
43 | $(document.body).setMode('no', 'search');
44 |
45 | return this;
46 | },
47 |
48 | // # Querying Facets #
49 |
50 | // Either gets a serialized query string or sets the faceted query from a query string.
51 | value : function(query) {
52 | if (query == null) return this.serialize();
53 | return this.setQuery(query);
54 | },
55 |
56 | // Uses the VS.app.searchQuery collection to serialize the current query from the various
57 | // facets that are in the search box.
58 | serialize : function() {
59 | var query = [];
60 | var inputViewsCount = this.inputViews.length;
61 |
62 | this.app.searchQuery.each(_.bind(function(facet, i) {
63 | query.push(this.inputViews[i].value());
64 | query.push(facet.serialize());
65 | }, this));
66 |
67 | if (inputViewsCount) {
68 | query.push(this.inputViews[inputViewsCount-1].value());
69 | }
70 |
71 | return _.compact(query).join(' ');
72 | },
73 |
74 | // Returns any facet views that are currently selected. Useful for changing the value
75 | // callbacks based on what else is in the search box and which facet is being edited.
76 | selected: function() {
77 | return _.select(this.facetViews, function(view) {
78 | return view.modes.editing == 'is' || view.modes.selected == 'is';
79 | });
80 | },
81 |
82 | // Similar to `this.selected`, returns any facet models that are currently selected.
83 | selectedModels: function() {
84 | return _.pluck(this.selected(), 'model');
85 | },
86 |
87 | // Takes a query string and uses the SearchParser to parse and render it. Note that
88 | // `VS.app.SearchParser` refreshes the `VS.app.searchQuery` collection, which is bound
89 | // here to call `this.renderFacets`.
90 | setQuery : function(query) {
91 | this.currentQuery = query;
92 | VS.app.SearchParser.parse(this.app, query);
93 | },
94 |
95 | // Returns the position of a facet/input view. Useful when moving between facets.
96 | viewPosition : function(view) {
97 | var views = view.type == 'facet' ? this.facetViews : this.inputViews;
98 | var position = _.indexOf(views, view);
99 | if (position == -1) position = 0;
100 | return position;
101 | },
102 |
103 | // Used to launch a search. Hitting enter or clicking the search button.
104 | searchEvent : function(e) {
105 | var query = this.value();
106 | this.focusSearch(e);
107 | this.value(query);
108 | this.app.options.callbacks.search(query, this.app.searchQuery);
109 | },
110 |
111 | // # Rendering Facets #
112 |
113 | // Add a new facet. Facet will be focused and ready to accept a value. Can also
114 | // specify position, in the case of adding facets from an inbetween input.
115 | addFacet : function(category, initialQuery, position) {
116 | category = VS.utils.inflector.trim(category);
117 | initialQuery = VS.utils.inflector.trim(initialQuery || '');
118 | if (!category) return;
119 |
120 | var model = new VS.model.SearchFacet({
121 | category : category,
122 | value : initialQuery || '',
123 | app : this.app
124 | });
125 | this.app.searchQuery.add(model, {at: position});
126 | },
127 |
128 | // Renders a newly added facet, and selects it.
129 | addedFacet : function (model) {
130 | this.renderFacets();
131 | var facetView = _.detect(this.facetViews, function(view) {
132 | if (view.model == model) return true;
133 | });
134 |
135 | _.defer(function() {
136 | facetView.enableEdit();
137 | });
138 | },
139 |
140 | // Changing a facet programmatically re-renders it.
141 | changedFacet: function () {
142 | this.renderFacets();
143 | },
144 |
145 | // When removing a facet, potentially do something. For now, the adjacent
146 | // remaining facet is selected, but this is handled by the facet's view,
147 | // since its position is unknown by the time the collection triggers this
148 | // remove callback.
149 | removedFacet : function (facet, query, options) {
150 | this.app.options.callbacks.removedFacet(facet, query, options);
151 | },
152 |
153 | // Renders each facet as a searchFacet view.
154 | renderFacets : function() {
155 | this.facetViews = [];
156 | this.inputViews = [];
157 |
158 | this.$('.VS-search-inner').empty();
159 |
160 | this.app.searchQuery.each(_.bind(this.renderFacet, this));
161 |
162 | // Add on an n+1 empty search input on the very end.
163 | this.renderSearchInput();
164 | this.renderPlaceholder();
165 | },
166 |
167 | // Render a single facet, using its category and query value.
168 | renderFacet : function(facet, position) {
169 | var view = new VS.ui.SearchFacet({
170 | app : this.app,
171 | model : facet,
172 | order : position
173 | });
174 |
175 | // Input first, facet second.
176 | this.renderSearchInput();
177 | this.facetViews.push(view);
178 | this.$('.VS-search-inner').children().eq(position*2).after(view.render().el);
179 |
180 | view.calculateSize();
181 | _.defer(_.bind(view.calculateSize, view));
182 |
183 | return view;
184 | },
185 |
186 | // Render a single input, used to create and autocomplete facets
187 | renderSearchInput : function() {
188 | var input = new VS.ui.SearchInput({
189 | position: this.inputViews.length,
190 | app: this.app,
191 | showFacets: this.options.showFacets
192 | });
193 | this.$('.VS-search-inner').append(input.render().el);
194 | this.inputViews.push(input);
195 | },
196 |
197 | // Handles showing/hiding the placeholder text
198 | renderPlaceholder : function() {
199 | var $placeholder = this.$('.VS-placeholder');
200 | if (this.app.searchQuery.length) {
201 | $placeholder.addClass("VS-hidden");
202 | } else {
203 | $placeholder.removeClass("VS-hidden")
204 | .text(this.app.options.placeholder);
205 | }
206 | },
207 |
208 | // # Modifying Facets #
209 |
210 | // Clears out the search box. Command+A + delete can trigger this, as can a cancel button.
211 | //
212 | // If a `clearSearch` callback was provided, the callback is invoked and
213 | // provided with a function performs the actual removal of the data. This
214 | // allows third-party developers to either clear data asynchronously, or
215 | // prior to performing their custom "clear" logic.
216 | clearSearch : function(e) {
217 | if (this.app.options.readOnly) return;
218 | var actualClearSearch = _.bind(function() {
219 | this.disableFacets();
220 | this.value('');
221 | this.flags.allSelected = false;
222 | this.searchEvent(e);
223 | this.focusSearch(e);
224 | }, this);
225 |
226 | if (this.app.options.callbacks.clearSearch != $.noop) {
227 | this.app.options.callbacks.clearSearch(actualClearSearch);
228 | } else {
229 | actualClearSearch();
230 | }
231 | },
232 |
233 | // Command+A selects all facets.
234 | selectAllFacets : function() {
235 | this.flags.allSelected = true;
236 |
237 | $(document).one('click.selectAllFacets', this.deselectAllFacets);
238 |
239 | _.each(this.facetViews, function(facetView, i) {
240 | facetView.selectFacet();
241 | });
242 | _.each(this.inputViews, function(inputView, i) {
243 | inputView.selectText();
244 | });
245 | },
246 |
247 | // Used by facets and input to see if all facets are currently selected.
248 | allSelected : function(deselect) {
249 | if (deselect) this.flags.allSelected = false;
250 | return this.flags.allSelected;
251 | },
252 |
253 | // After `selectAllFacets` is engaged, this method is bound to the entire document.
254 | // This immediate disables and deselects all facets, but it also checks if the user
255 | // has clicked on either a facet or an input, and properly selects the view.
256 | deselectAllFacets : function(e) {
257 | this.disableFacets();
258 |
259 | if (this.$(e.target).is('.category,input')) {
260 | var el = $(e.target).closest('.search_facet,.search_input');
261 | var view = _.detect(this.facetViews.concat(this.inputViews), function(v) {
262 | return v.el == el[0];
263 | });
264 | if (view.type == 'facet') {
265 | view.selectFacet();
266 | } else if (view.type == 'input') {
267 | _.defer(function() {
268 | view.enableEdit(true);
269 | });
270 | }
271 | }
272 | },
273 |
274 | // Disables all facets except for the passed in view. Used when switching between
275 | // facets, so as not to have to keep state of active facets.
276 | disableFacets : function(keepView) {
277 | _.each(this.inputViews, function(view) {
278 | if (view && view != keepView &&
279 | (view.modes.editing == 'is' || view.modes.selected == 'is')) {
280 | view.disableEdit();
281 | }
282 | });
283 | _.each(this.facetViews, function(view) {
284 | if (view && view != keepView &&
285 | (view.modes.editing == 'is' || view.modes.selected == 'is')) {
286 | view.disableEdit();
287 | view.deselectFacet();
288 | }
289 | });
290 |
291 | this.flags.allSelected = false;
292 | this.removeFocus();
293 | $(document).unbind('click.selectAllFacets');
294 | },
295 |
296 | // Resize all inputs to account for extra keystrokes which may be changing the facet
297 | // width incorrectly. This is a safety check to ensure inputs are correctly sized.
298 | resizeFacets : function(view) {
299 | _.each(this.facetViews, function(facetView, i) {
300 | if (!view || facetView == view) {
301 | facetView.resize();
302 | }
303 | });
304 | },
305 |
306 | // Handles keydown events on the document. Used to complete the Cmd+A deletion, and
307 | // blurring focus.
308 | _maybeDisableFacets : function(e) {
309 | if (this.flags.allSelected && VS.app.hotkeys.key(e) == 'backspace') {
310 | e.preventDefault();
311 | this.clearSearch(e);
312 | return false;
313 | } else if (this.flags.allSelected && VS.app.hotkeys.printable(e)) {
314 | this.clearSearch(e);
315 | }
316 | },
317 |
318 | // # Focusing Facets #
319 |
320 | // Move focus between facets and inputs. Takes a direction as well as many options
321 | // for skipping over inputs and only to facets, placement of cursor position in facet
322 | // (i.e. at the end), and selecting the text in the input/facet.
323 | focusNextFacet : function(currentView, direction, options) {
324 | options = options || {};
325 | var viewCount = this.facetViews.length;
326 | var viewPosition = options.viewPosition || this.viewPosition(currentView);
327 |
328 | if (!options.skipToFacet) {
329 | // Correct for bouncing between matching text and facet arrays.
330 | if (currentView.type == 'text' && direction > 0) direction -= 1;
331 | if (currentView.type == 'facet' && direction < 0) direction += 1;
332 | } else if (options.skipToFacet && currentView.type == 'text' &&
333 | viewCount == viewPosition && direction >= 0) {
334 | // Special case of looping around to a facet from the last search input box.
335 | return false;
336 | }
337 | var view, next = Math.min(viewCount, viewPosition + direction);
338 |
339 | if (currentView.type == 'text') {
340 | if (next >= 0 && next < viewCount) {
341 | view = this.facetViews[next];
342 | } else if (next == viewCount) {
343 | view = this.inputViews[this.inputViews.length-1];
344 | }
345 | if (view && options.selectFacet && view.type == 'facet') {
346 | view.selectFacet();
347 | } else if (view) {
348 | view.enableEdit();
349 | view.setCursorAtEnd(direction || options.startAtEnd);
350 | }
351 | } else if (currentView.type == 'facet') {
352 | if (options.skipToFacet) {
353 | if (next >= viewCount || next < 0) {
354 | view = _.last(this.inputViews);
355 | view.enableEdit();
356 | } else {
357 | view = this.facetViews[next];
358 | view.enableEdit();
359 | view.setCursorAtEnd(direction || options.startAtEnd);
360 | }
361 | } else {
362 | view = this.inputViews[next];
363 | view.enableEdit();
364 | }
365 | }
366 | if (options.selectText) view.selectText();
367 | this.resizeFacets();
368 |
369 | return true;
370 | },
371 |
372 | maybeFocusSearch : function(e) {
373 | if (this.app.options.readOnly) return;
374 | if ($(e.target).is('.VS-search-box') ||
375 | $(e.target).is('.VS-search-inner') ||
376 | e.type == 'keydown') {
377 | this.focusSearch(e);
378 | }
379 | },
380 |
381 | // Bring focus to last input field.
382 | focusSearch : function(e, selectText) {
383 | if (this.app.options.readOnly) return;
384 | var view = this.inputViews[this.inputViews.length-1];
385 | view.enableEdit(selectText);
386 | if (!selectText) view.setCursorAtEnd(-1);
387 | if (e.type == 'keydown') {
388 | view.keydown(e);
389 | view.box.trigger('keydown');
390 | }
391 | _.defer(_.bind(function() {
392 | if (!this.$('input:focus').length) {
393 | view.enableEdit(selectText);
394 | }
395 | }, this));
396 | },
397 |
398 | // Double-clicking on the search wrapper should select the existing text in
399 | // the last search input. Also start the triple-click timer.
400 | highlightSearch : function(e) {
401 | if (this.app.options.readOnly) return;
402 | if ($(e.target).is('.VS-search-box') ||
403 | $(e.target).is('.VS-search-inner') ||
404 | e.type == 'keydown') {
405 | var lastinput = this.inputViews[this.inputViews.length-1];
406 | lastinput.startTripleClickTimer();
407 | this.focusSearch(e, true);
408 | }
409 | },
410 |
411 | maybeTripleClick : function(e) {
412 | var lastinput = this.inputViews[this.inputViews.length-1];
413 | return lastinput.maybeTripleClick(e);
414 | },
415 |
416 | // Used to show the user is focused on some input inside the search box.
417 | addFocus : function() {
418 | if (this.app.options.readOnly) return;
419 | this.app.options.callbacks.focus();
420 | this.$('.VS-search-box').addClass('VS-focus');
421 | },
422 |
423 | // User is no longer focused on anything in the search box.
424 | removeFocus : function() {
425 | this.app.options.callbacks.blur();
426 | var focus = _.any(this.facetViews.concat(this.inputViews), function(view) {
427 | return view.isFocused();
428 | });
429 | if (!focus) this.$('.VS-search-box').removeClass('VS-focus');
430 | },
431 |
432 | // Show a menu which adds pre-defined facets to the search box. This is unused for now.
433 | showFacetCategoryMenu : function(e) {
434 | e.preventDefault();
435 | e.stopPropagation();
436 | if (this.facetCategoryMenu && this.facetCategoryMenu.modes.open == 'is') {
437 | return this.facetCategoryMenu.close();
438 | }
439 |
440 | var items = [
441 | {title: 'Account', onClick: _.bind(this.addFacet, this, 'account', '')},
442 | {title: 'Project', onClick: _.bind(this.addFacet, this, 'project', '')},
443 | {title: 'Filter', onClick: _.bind(this.addFacet, this, 'filter', '')},
444 | {title: 'Access', onClick: _.bind(this.addFacet, this, 'access', '')}
445 | ];
446 |
447 | var menu = this.facetCategoryMenu || (this.facetCategoryMenu = new dc.ui.Menu({
448 | items : items,
449 | standalone : true
450 | }));
451 |
452 | this.$('.VS-icon-search').after(menu.render().open().content);
453 | return false;
454 | }
455 |
456 | });
457 |
458 | })();
459 |
--------------------------------------------------------------------------------
/lib/js/views/search_facet.js:
--------------------------------------------------------------------------------
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.options = _.extend({}, this.options, options);
24 |
25 | this.flags = {
26 | canClose : false
27 | };
28 | _.bindAll(this, 'set', 'keydown', 'deselectFacet', 'deferDisableEdit');
29 | this.app = this.options.app;
30 | },
31 |
32 | // Rendering the facet sets up autocompletion, events on blur, and populates
33 | // the facet's input with its starting value.
34 | render : function() {
35 | $(this.el).html(JST['search_facet']({
36 | model : this.model,
37 | readOnly: this.app.options.readOnly
38 | }));
39 |
40 | this.setMode('not', 'editing');
41 | this.setMode('not', 'selected');
42 | this.box = this.$('input');
43 | this.box.val(this.model.label());
44 | this.box.bind('blur', this.deferDisableEdit);
45 | // Handle paste events with `propertychange`
46 | this.box.bind('input propertychange', this.keydown);
47 | this.setupAutocomplete();
48 |
49 | return this;
50 | },
51 |
52 | // This method is used to setup the facet's input to auto-grow.
53 | // This is defered in the searchBox so it can be attached to the
54 | // DOM to get the correct font-size.
55 | calculateSize : function() {
56 | this.box.autoGrowInput();
57 | this.box.unbind('updated.autogrow');
58 | this.box.bind('updated.autogrow', _.bind(this.moveAutocomplete, this));
59 | },
60 |
61 | // Forces a recalculation of this facet's input field's value. Called when
62 | // the facet is focused, removed, or otherwise modified.
63 | resize : function(e) {
64 | this.box.trigger('resize.autogrow', e);
65 | },
66 |
67 | // Watches the facet's input field to see if it matches the beginnings of
68 | // words in `autocompleteValues`, which is different for every category.
69 | // If the value, when selected from the autocompletion menu, is different
70 | // than what it was, commit the facet and search for it.
71 | setupAutocomplete : function() {
72 | this.box.autocomplete({
73 | source : _.bind(this.autocompleteValues, this),
74 | minLength : 0,
75 | delay : 0,
76 | autoFocus : true,
77 | position : {offset : "0 5"},
78 | create : _.bind(function(e, ui) {
79 | $(this.el).find('.ui-autocomplete-input').css('z-index','auto');
80 | }, this),
81 | select : _.bind(function(e, ui) {
82 | e.preventDefault();
83 | var originalValue = this.model.get('value');
84 | this.set(ui.item.value);
85 | if (originalValue != ui.item.value || this.box.val() != ui.item.value) {
86 | if (this.app.options.autosearch) {
87 | this.search(e);
88 | } else {
89 | this.app.searchBox.renderFacets();
90 | this.app.searchBox.focusNextFacet(this, 1, {viewPosition: this.options.order});
91 | }
92 | }
93 | return false;
94 | }, this),
95 | open : _.bind(function(e, ui) {
96 | var box = this.box;
97 | this.box.autocomplete('widget').find('.ui-menu-item').each(function() {
98 | var $value = $(this),
99 | autoCompleteData = $value.data('item.autocomplete') || $value.data('ui-autocomplete-item');
100 |
101 | if (autoCompleteData['value'] == box.val() && box.data('ui-autocomplete').menu.activate) {
102 | box.data('ui-autocomplete').menu.activate(new $.Event("mouseover"), $value);
103 | }
104 | });
105 | }, this)
106 | });
107 |
108 | this.box.autocomplete('widget').addClass('VS-interface');
109 | },
110 |
111 | // As the facet's input field grows, it may move to the next line in the
112 | // search box. `autoGrowInput` triggers an `updated` event on the input
113 | // field, which is bound to this method to move the autocomplete menu.
114 | moveAutocomplete : function() {
115 | var autocomplete = this.box.data('ui-autocomplete');
116 | if (autocomplete) {
117 | autocomplete.menu.element.position({
118 | my : "left top",
119 | at : "left bottom",
120 | of : this.box.data('ui-autocomplete').element,
121 | collision : "flip",
122 | offset : "0 5"
123 | });
124 | }
125 | },
126 |
127 | // When a user enters a facet and it is being edited, immediately show
128 | // the autocomplete menu and size it to match the contents.
129 | searchAutocomplete : function(e) {
130 | var autocomplete = this.box.data('ui-autocomplete');
131 | if (autocomplete) {
132 | var menu = autocomplete.menu.element;
133 | autocomplete.search();
134 |
135 | // Resize the menu based on the correctly measured width of what's bigger:
136 | // the menu's original size or the menu items' new size.
137 | menu.outerWidth(Math.max(
138 | menu.width('').outerWidth(),
139 | autocomplete.element.outerWidth()
140 | ));
141 | }
142 | },
143 |
144 | // Closes the autocomplete menu. Called on disabling, selecting, deselecting,
145 | // and anything else that takes focus out of the facet's input field.
146 | closeAutocomplete : function() {
147 | var autocomplete = this.box.data('ui-autocomplete');
148 | if (autocomplete) autocomplete.close();
149 | },
150 |
151 | // Search terms used in the autocomplete menu. These are specific to the facet,
152 | // and only match for the facet's category. The values are then matched on the
153 | // first letter of any word in matches, and finally sorted according to the
154 | // value's own category. You can pass `preserveOrder` as an option in the
155 | // `facetMatches` callback to skip any further ordering done client-side.
156 | autocompleteValues : function(req, resp) {
157 | var category = this.model.get('category');
158 | var value = this.model.get('value');
159 | var searchTerm = req.term;
160 |
161 | this.app.options.callbacks.valueMatches(category, searchTerm, function(matches, options) {
162 | options = options || {};
163 | matches = matches || [];
164 |
165 | if (searchTerm && value != searchTerm) {
166 | if (options.preserveMatches) {
167 | resp(matches);
168 | } else {
169 | var re = VS.utils.inflector.escapeRegExp(searchTerm || '');
170 | var matcher = new RegExp('\\b' + re, 'i');
171 | matches = $.grep(matches, function(item) {
172 | return matcher.test(item) ||
173 | matcher.test(item.value) ||
174 | matcher.test(item.label);
175 | });
176 | }
177 | }
178 |
179 | if (options.preserveOrder) {
180 | resp(matches);
181 | } else {
182 | resp(_.sortBy(matches, function(match) {
183 | if (match == value || match.value == value) return '';
184 | else return match;
185 | }));
186 | }
187 | });
188 |
189 | },
190 |
191 | // Sets the facet's model's value.
192 | set : function(value) {
193 | if (!value) return;
194 | this.model.set({'value': value});
195 | },
196 |
197 | // Before the searchBox performs a search, we need to close the
198 | // autocomplete menu.
199 | search : function(e, direction) {
200 | if (!direction) direction = 1;
201 | this.closeAutocomplete();
202 | this.app.searchBox.searchEvent(e);
203 | _.defer(_.bind(function() {
204 | this.app.searchBox.focusNextFacet(this, direction, {viewPosition: this.options.order});
205 | }, this));
206 | },
207 |
208 | // Begin editing the facet's input. This is called when the user enters
209 | // the input either from another facet or directly clicking on it.
210 | //
211 | // This method tells all other facets and inputs to disable so it can have
212 | // the sole focus. It also prepares the autocompletion menu.
213 | enableEdit : function() {
214 | if (this.app.options.readOnly) return;
215 | if (this.modes.editing != 'is') {
216 | this.setMode('is', 'editing');
217 | this.deselectFacet();
218 | if (this.box.val() == '') {
219 | this.box.val(this.model.get('value'));
220 | }
221 | }
222 |
223 | this.flags.canClose = false;
224 | this.app.searchBox.disableFacets(this);
225 | this.app.searchBox.addFocus();
226 | _.defer(_.bind(function() {
227 | this.app.searchBox.addFocus();
228 | }, this));
229 | this.resize();
230 | this.searchAutocomplete();
231 | this.box.focus();
232 | },
233 |
234 | // When the user blurs the input, they may either be going to another input
235 | // or off the search box entirely. If they go to another input, this facet
236 | // will be instantly disabled, and the canClose flag will be turned back off.
237 | //
238 | // However, if the user clicks elsewhere on the page, this method starts a timer
239 | // that checks if any of the other inputs are selected or are being edited. If
240 | // not, then it can finally close itself and its autocomplete menu.
241 | deferDisableEdit : function() {
242 | this.flags.canClose = true;
243 | _.delay(_.bind(function() {
244 | if (this.flags.canClose && !this.box.is(':focus') &&
245 | this.modes.editing == 'is' && this.modes.selected != 'is') {
246 | this.disableEdit();
247 | }
248 | }, this), 250);
249 | },
250 |
251 | // Called either by other facets receiving focus or by the timer in `deferDisableEdit`,
252 | // this method will turn off the facet, remove any text selection, and close
253 | // the autocomplete menu.
254 | disableEdit : function() {
255 | var newFacetQuery = VS.utils.inflector.trim(this.box.val());
256 | if (newFacetQuery != this.model.get('value')) {
257 | this.set(newFacetQuery);
258 | }
259 | this.flags.canClose = false;
260 | this.box.selectRange(0, 0);
261 | this.box.blur();
262 | this.setMode('not', 'editing');
263 | this.closeAutocomplete();
264 | this.app.searchBox.removeFocus();
265 | },
266 |
267 | // Selects the facet, which blurs the facet's input and highlights the facet.
268 | // If this is the only facet being selected (and not part of a select all event),
269 | // we attach a mouse/keyboard watcher to check if the next action by the user
270 | // should delete this facet or just deselect it.
271 | selectFacet : function(e) {
272 | if (e) e.preventDefault();
273 | if (this.app.options.readOnly) return;
274 | var allSelected = this.app.searchBox.allSelected();
275 | if (this.modes.selected == 'is') return;
276 |
277 | if (this.box.is(':focus')) {
278 | this.box.setCursorPosition(0);
279 | this.box.blur();
280 | }
281 |
282 | this.flags.canClose = false;
283 | this.closeAutocomplete();
284 | this.setMode('is', 'selected');
285 | this.setMode('not', 'editing');
286 | if (!allSelected || e) {
287 | $(document).unbind('keydown.facet', this.keydown);
288 | $(document).unbind('click.facet', this.deselectFacet);
289 | _.defer(_.bind(function() {
290 | $(document).unbind('keydown.facet').bind('keydown.facet', this.keydown);
291 | $(document).unbind('click.facet').one('click.facet', this.deselectFacet);
292 | }, this));
293 | this.app.searchBox.disableFacets(this);
294 | this.app.searchBox.addFocus();
295 | }
296 | return false;
297 | },
298 |
299 | // Turns off highlighting on the facet. Called in a variety of ways, this
300 | // only deselects the facet if it is selected, and then cleans up the
301 | // keyboard/mouse watchers that were created when the facet was first
302 | // selected.
303 | deselectFacet : function(e) {
304 | if (e) e.preventDefault();
305 | if (this.modes.selected == 'is') {
306 | this.setMode('not', 'selected');
307 | this.closeAutocomplete();
308 | this.app.searchBox.removeFocus();
309 | }
310 | $(document).unbind('keydown.facet', this.keydown);
311 | $(document).unbind('click.facet', this.deselectFacet);
312 | return false;
313 | },
314 |
315 | // Is the user currently focused in this facet's input field?
316 | isFocused : function() {
317 | return this.box.is(':focus');
318 | },
319 |
320 | // Hovering over the delete button styles the facet so the user knows that
321 | // the delete button will kill the entire facet.
322 | showDelete : function() {
323 | $(this.el).addClass('search_facet_maybe_delete');
324 | },
325 |
326 | // On `mouseout`, the user is no longer hovering on the delete button.
327 | hideDelete : function() {
328 | $(this.el).removeClass('search_facet_maybe_delete');
329 | },
330 |
331 | // When switching between facets, depending on the direction the cursor is
332 | // coming from, the cursor in this facet's input field should match the original
333 | // direction.
334 | setCursorAtEnd : function(direction) {
335 | if (direction == -1) {
336 | this.box.setCursorPosition(this.box.val().length);
337 | } else {
338 | this.box.setCursorPosition(0);
339 | }
340 | },
341 |
342 | // Deletes the facet and sends the cursor over to the nearest input field.
343 | remove : function(e) {
344 | var committed = this.model.get('value');
345 | this.deselectFacet();
346 | this.disableEdit();
347 | this.app.searchQuery.remove(this.model);
348 | if (committed && this.app.options.autosearch) {
349 | this.search(e, -1);
350 | } else {
351 | this.app.searchBox.renderFacets();
352 | this.app.searchBox.focusNextFacet(this, -1, {viewPosition: this.options.order});
353 | }
354 | },
355 |
356 | // Selects the text in the facet's input field. When the user tabs between
357 | // facets, convention is to highlight the entire field.
358 | selectText: function() {
359 | this.box.selectRange(0, this.box.val().length);
360 | },
361 |
362 | // Handles all keyboard inputs when in the facet's input field. This checks
363 | // for movement between facets and inputs, entering a new value that needs
364 | // to be autocompleted, as well as the removal of this facet.
365 | keydown : function(e) {
366 | var key = VS.app.hotkeys.key(e);
367 |
368 | if (key == 'enter' && this.box.val()) {
369 | this.disableEdit();
370 | this.search(e);
371 | } else if (key == 'left') {
372 | if (this.modes.selected == 'is') {
373 | this.deselectFacet();
374 | this.app.searchBox.focusNextFacet(this, -1, {startAtEnd: -1});
375 | } else if (this.box.getCursorPosition() == 0 && !this.box.getSelection().length) {
376 | this.selectFacet();
377 | }
378 | } else if (key == 'right') {
379 | if (this.modes.selected == 'is') {
380 | e.preventDefault();
381 | this.deselectFacet();
382 | this.setCursorAtEnd(0);
383 | this.enableEdit();
384 | } else if (this.box.getCursorPosition() == this.box.val().length) {
385 | e.preventDefault();
386 | this.disableEdit();
387 | this.app.searchBox.focusNextFacet(this, 1);
388 | }
389 | } else if (VS.app.hotkeys.shift && key == 'tab') {
390 | e.preventDefault();
391 | this.app.searchBox.focusNextFacet(this, -1, {
392 | startAtEnd : -1,
393 | skipToFacet : true,
394 | selectText : true
395 | });
396 | } else if (key == 'tab') {
397 | e.preventDefault();
398 | this.app.searchBox.focusNextFacet(this, 1, {
399 | skipToFacet : true,
400 | selectText : true
401 | });
402 | } else if (VS.app.hotkeys.command && (e.which == 97 || e.which == 65)) {
403 | e.preventDefault();
404 | this.app.searchBox.selectAllFacets();
405 | return false;
406 | } else if (VS.app.hotkeys.printable(e) && this.modes.selected == 'is') {
407 | this.app.searchBox.focusNextFacet(this, -1, {startAtEnd: -1});
408 | this.remove(e);
409 | } else if (key == 'backspace') {
410 | $(document).on('keydown.backspace', function(e) {
411 | if (VS.app.hotkeys.key(e) === 'backspace') {
412 | e.preventDefault();
413 | }
414 | });
415 |
416 | $(document).on('keyup.backspace', function(e) {
417 | $(document).off('.backspace');
418 | });
419 |
420 | if (this.modes.selected == 'is') {
421 | e.preventDefault();
422 | this.remove(e);
423 | } else if (this.box.getCursorPosition() == 0 &&
424 | !this.box.getSelection().length) {
425 | e.preventDefault();
426 | this.selectFacet();
427 | }
428 | e.stopPropagation();
429 | }
430 |
431 | // Handle paste events
432 | if (e.which == null) {
433 | // this.searchAutocomplete(e);
434 | _.defer(_.bind(this.resize, this, e));
435 | } else {
436 | this.resize(e);
437 | }
438 | }
439 |
440 | });
441 |
442 | })();
443 |
--------------------------------------------------------------------------------
/vendor/jquery.ui.widget.js:
--------------------------------------------------------------------------------
1 | /*!
2 | * jQuery UI Widget 1.10.4
3 | * http://jqueryui.com
4 | *
5 | * Copyright 2014 jQuery Foundation and other contributors
6 | * Released under the MIT license.
7 | * http://jquery.org/license
8 | *
9 | * http://api.jqueryui.com/jQuery.widget/
10 | */
11 | (function( $, undefined ) {
12 |
13 | var uuid = 0,
14 | slice = Array.prototype.slice,
15 | _cleanData = $.cleanData;
16 | $.cleanData = function( elems ) {
17 | for ( var i = 0, elem; (elem = elems[i]) != null; i++ ) {
18 | try {
19 | $( elem ).triggerHandler( "remove" );
20 | // http://bugs.jquery.com/ticket/8235
21 | } catch( e ) {}
22 | }
23 | _cleanData( elems );
24 | };
25 |
26 | $.widget = function( name, base, prototype ) {
27 | var fullName, existingConstructor, constructor, basePrototype,
28 | // proxiedPrototype allows the provided prototype to remain unmodified
29 | // so that it can be used as a mixin for multiple widgets (#8876)
30 | proxiedPrototype = {},
31 | namespace = name.split( "." )[ 0 ];
32 |
33 | name = name.split( "." )[ 1 ];
34 | fullName = namespace + "-" + name;
35 |
36 | if ( !prototype ) {
37 | prototype = base;
38 | base = $.Widget;
39 | }
40 |
41 | // create selector for plugin
42 | $.expr[ ":" ][ fullName.toLowerCase() ] = function( elem ) {
43 | return !!$.data( elem, fullName );
44 | };
45 |
46 | $[ namespace ] = $[ namespace ] || {};
47 | existingConstructor = $[ namespace ][ name ];
48 | constructor = $[ namespace ][ name ] = function( options, element ) {
49 | // allow instantiation without "new" keyword
50 | if ( !this._createWidget ) {
51 | return new constructor( options, element );
52 | }
53 |
54 | // allow instantiation without initializing for simple inheritance
55 | // must use "new" keyword (the code above always passes args)
56 | if ( arguments.length ) {
57 | this._createWidget( options, element );
58 | }
59 | };
60 | // extend with the existing constructor to carry over any static properties
61 | $.extend( constructor, existingConstructor, {
62 | version: prototype.version,
63 | // copy the object used to create the prototype in case we need to
64 | // redefine the widget later
65 | _proto: $.extend( {}, prototype ),
66 | // track widgets that inherit from this widget in case this widget is
67 | // redefined after a widget inherits from it
68 | _childConstructors: []
69 | });
70 |
71 | basePrototype = new base();
72 | // we need to make the options hash a property directly on the new instance
73 | // otherwise we'll modify the options hash on the prototype that we're
74 | // inheriting from
75 | basePrototype.options = $.widget.extend( {}, basePrototype.options );
76 | $.each( prototype, function( prop, value ) {
77 | if ( !$.isFunction( value ) ) {
78 | proxiedPrototype[ prop ] = value;
79 | return;
80 | }
81 | proxiedPrototype[ prop ] = (function() {
82 | var _super = function() {
83 | return base.prototype[ prop ].apply( this, arguments );
84 | },
85 | _superApply = function( args ) {
86 | return base.prototype[ prop ].apply( this, args );
87 | };
88 | return function() {
89 | var __super = this._super,
90 | __superApply = this._superApply,
91 | returnValue;
92 |
93 | this._super = _super;
94 | this._superApply = _superApply;
95 |
96 | returnValue = value.apply( this, arguments );
97 |
98 | this._super = __super;
99 | this._superApply = __superApply;
100 |
101 | return returnValue;
102 | };
103 | })();
104 | });
105 | constructor.prototype = $.widget.extend( basePrototype, {
106 | // TODO: remove support for widgetEventPrefix
107 | // always use the name + a colon as the prefix, e.g., draggable:start
108 | // don't prefix for widgets that aren't DOM-based
109 | widgetEventPrefix: existingConstructor ? (basePrototype.widgetEventPrefix || name) : name
110 | }, proxiedPrototype, {
111 | constructor: constructor,
112 | namespace: namespace,
113 | widgetName: name,
114 | widgetFullName: fullName
115 | });
116 |
117 | // If this widget is being redefined then we need to find all widgets that
118 | // are inheriting from it and redefine all of them so that they inherit from
119 | // the new version of this widget. We're essentially trying to replace one
120 | // level in the prototype chain.
121 | if ( existingConstructor ) {
122 | $.each( existingConstructor._childConstructors, function( i, child ) {
123 | var childPrototype = child.prototype;
124 |
125 | // redefine the child widget using the same prototype that was
126 | // originally used, but inherit from the new version of the base
127 | $.widget( childPrototype.namespace + "." + childPrototype.widgetName, constructor, child._proto );
128 | });
129 | // remove the list of existing child constructors from the old constructor
130 | // so the old child constructors can be garbage collected
131 | delete existingConstructor._childConstructors;
132 | } else {
133 | base._childConstructors.push( constructor );
134 | }
135 |
136 | $.widget.bridge( name, constructor );
137 | };
138 |
139 | $.widget.extend = function( target ) {
140 | var input = slice.call( arguments, 1 ),
141 | inputIndex = 0,
142 | inputLength = input.length,
143 | key,
144 | value;
145 | for ( ; inputIndex < inputLength; inputIndex++ ) {
146 | for ( key in input[ inputIndex ] ) {
147 | value = input[ inputIndex ][ key ];
148 | if ( input[ inputIndex ].hasOwnProperty( key ) && value !== undefined ) {
149 | // Clone objects
150 | if ( $.isPlainObject( value ) ) {
151 | target[ key ] = $.isPlainObject( target[ key ] ) ?
152 | $.widget.extend( {}, target[ key ], value ) :
153 | // Don't extend strings, arrays, etc. with objects
154 | $.widget.extend( {}, value );
155 | // Copy everything else by reference
156 | } else {
157 | target[ key ] = value;
158 | }
159 | }
160 | }
161 | }
162 | return target;
163 | };
164 |
165 | $.widget.bridge = function( name, object ) {
166 | var fullName = object.prototype.widgetFullName || name;
167 | $.fn[ name ] = function( options ) {
168 | var isMethodCall = typeof options === "string",
169 | args = slice.call( arguments, 1 ),
170 | returnValue = this;
171 |
172 | // allow multiple hashes to be passed on init
173 | options = !isMethodCall && args.length ?
174 | $.widget.extend.apply( null, [ options ].concat(args) ) :
175 | options;
176 |
177 | if ( isMethodCall ) {
178 | this.each(function() {
179 | var methodValue,
180 | instance = $.data( this, fullName );
181 | if ( !instance ) {
182 | return $.error( "cannot call methods on " + name + " prior to initialization; " +
183 | "attempted to call method '" + options + "'" );
184 | }
185 | if ( !$.isFunction( instance[options] ) || options.charAt( 0 ) === "_" ) {
186 | return $.error( "no such method '" + options + "' for " + name + " widget instance" );
187 | }
188 | methodValue = instance[ options ].apply( instance, args );
189 | if ( methodValue !== instance && methodValue !== undefined ) {
190 | returnValue = methodValue && methodValue.jquery ?
191 | returnValue.pushStack( methodValue.get() ) :
192 | methodValue;
193 | return false;
194 | }
195 | });
196 | } else {
197 | this.each(function() {
198 | var instance = $.data( this, fullName );
199 | if ( instance ) {
200 | instance.option( options || {} )._init();
201 | } else {
202 | $.data( this, fullName, new object( options, this ) );
203 | }
204 | });
205 | }
206 |
207 | return returnValue;
208 | };
209 | };
210 |
211 | $.Widget = function( /* options, element */ ) {};
212 | $.Widget._childConstructors = [];
213 |
214 | $.Widget.prototype = {
215 | widgetName: "widget",
216 | widgetEventPrefix: "",
217 | defaultElement: "",
218 | options: {
219 | disabled: false,
220 |
221 | // callbacks
222 | create: null
223 | },
224 | _createWidget: function( options, element ) {
225 | element = $( element || this.defaultElement || this )[ 0 ];
226 | this.element = $( element );
227 | this.uuid = uuid++;
228 | this.eventNamespace = "." + this.widgetName + this.uuid;
229 | this.options = $.widget.extend( {},
230 | this.options,
231 | this._getCreateOptions(),
232 | options );
233 |
234 | this.bindings = $();
235 | this.hoverable = $();
236 | this.focusable = $();
237 |
238 | if ( element !== this ) {
239 | $.data( element, this.widgetFullName, this );
240 | this._on( true, this.element, {
241 | remove: function( event ) {
242 | if ( event.target === element ) {
243 | this.destroy();
244 | }
245 | }
246 | });
247 | this.document = $( element.style ?
248 | // element within the document
249 | element.ownerDocument :
250 | // element is window or document
251 | element.document || element );
252 | this.window = $( this.document[0].defaultView || this.document[0].parentWindow );
253 | }
254 |
255 | this._create();
256 | this._trigger( "create", null, this._getCreateEventData() );
257 | this._init();
258 | },
259 | _getCreateOptions: $.noop,
260 | _getCreateEventData: $.noop,
261 | _create: $.noop,
262 | _init: $.noop,
263 |
264 | destroy: function() {
265 | this._destroy();
266 | // we can probably remove the unbind calls in 2.0
267 | // all event bindings should go through this._on()
268 | this.element
269 | .unbind( this.eventNamespace )
270 | // 1.9 BC for #7810
271 | // TODO remove dual storage
272 | .removeData( this.widgetName )
273 | .removeData( this.widgetFullName )
274 | // support: jquery <1.6.3
275 | // http://bugs.jquery.com/ticket/9413
276 | .removeData( $.camelCase( this.widgetFullName ) );
277 | this.widget()
278 | .unbind( this.eventNamespace )
279 | .removeAttr( "aria-disabled" )
280 | .removeClass(
281 | this.widgetFullName + "-disabled " +
282 | "ui-state-disabled" );
283 |
284 | // clean up events and states
285 | this.bindings.unbind( this.eventNamespace );
286 | this.hoverable.removeClass( "ui-state-hover" );
287 | this.focusable.removeClass( "ui-state-focus" );
288 | },
289 | _destroy: $.noop,
290 |
291 | widget: function() {
292 | return this.element;
293 | },
294 |
295 | option: function( key, value ) {
296 | var options = key,
297 | parts,
298 | curOption,
299 | i;
300 |
301 | if ( arguments.length === 0 ) {
302 | // don't return a reference to the internal hash
303 | return $.widget.extend( {}, this.options );
304 | }
305 |
306 | if ( typeof key === "string" ) {
307 | // handle nested keys, e.g., "foo.bar" => { foo: { bar: ___ } }
308 | options = {};
309 | parts = key.split( "." );
310 | key = parts.shift();
311 | if ( parts.length ) {
312 | curOption = options[ key ] = $.widget.extend( {}, this.options[ key ] );
313 | for ( i = 0; i < parts.length - 1; i++ ) {
314 | curOption[ parts[ i ] ] = curOption[ parts[ i ] ] || {};
315 | curOption = curOption[ parts[ i ] ];
316 | }
317 | key = parts.pop();
318 | if ( arguments.length === 1 ) {
319 | return curOption[ key ] === undefined ? null : curOption[ key ];
320 | }
321 | curOption[ key ] = value;
322 | } else {
323 | if ( arguments.length === 1 ) {
324 | return this.options[ key ] === undefined ? null : this.options[ key ];
325 | }
326 | options[ key ] = value;
327 | }
328 | }
329 |
330 | this._setOptions( options );
331 |
332 | return this;
333 | },
334 | _setOptions: function( options ) {
335 | var key;
336 |
337 | for ( key in options ) {
338 | this._setOption( key, options[ key ] );
339 | }
340 |
341 | return this;
342 | },
343 | _setOption: function( key, value ) {
344 | this.options[ key ] = value;
345 |
346 | if ( key === "disabled" ) {
347 | this.widget()
348 | .toggleClass( this.widgetFullName + "-disabled ui-state-disabled", !!value )
349 | .attr( "aria-disabled", value );
350 | this.hoverable.removeClass( "ui-state-hover" );
351 | this.focusable.removeClass( "ui-state-focus" );
352 | }
353 |
354 | return this;
355 | },
356 |
357 | enable: function() {
358 | return this._setOption( "disabled", false );
359 | },
360 | disable: function() {
361 | return this._setOption( "disabled", true );
362 | },
363 |
364 | _on: function( suppressDisabledCheck, element, handlers ) {
365 | var delegateElement,
366 | instance = this;
367 |
368 | // no suppressDisabledCheck flag, shuffle arguments
369 | if ( typeof suppressDisabledCheck !== "boolean" ) {
370 | handlers = element;
371 | element = suppressDisabledCheck;
372 | suppressDisabledCheck = false;
373 | }
374 |
375 | // no element argument, shuffle and use this.element
376 | if ( !handlers ) {
377 | handlers = element;
378 | element = this.element;
379 | delegateElement = this.widget();
380 | } else {
381 | // accept selectors, DOM elements
382 | element = delegateElement = $( element );
383 | this.bindings = this.bindings.add( element );
384 | }
385 |
386 | $.each( handlers, function( event, handler ) {
387 | function handlerProxy() {
388 | // allow widgets to customize the disabled handling
389 | // - disabled as an array instead of boolean
390 | // - disabled class as method for disabling individual parts
391 | if ( !suppressDisabledCheck &&
392 | ( instance.options.disabled === true ||
393 | $( this ).hasClass( "ui-state-disabled" ) ) ) {
394 | return;
395 | }
396 | return ( typeof handler === "string" ? instance[ handler ] : handler )
397 | .apply( instance, arguments );
398 | }
399 |
400 | // copy the guid so direct unbinding works
401 | if ( typeof handler !== "string" ) {
402 | handlerProxy.guid = handler.guid =
403 | handler.guid || handlerProxy.guid || $.guid++;
404 | }
405 |
406 | var match = event.match( /^(\w+)\s*(.*)$/ ),
407 | eventName = match[1] + instance.eventNamespace,
408 | selector = match[2];
409 | if ( selector ) {
410 | delegateElement.delegate( selector, eventName, handlerProxy );
411 | } else {
412 | element.bind( eventName, handlerProxy );
413 | }
414 | });
415 | },
416 |
417 | _off: function( element, eventName ) {
418 | eventName = (eventName || "").split( " " ).join( this.eventNamespace + " " ) + this.eventNamespace;
419 | element.unbind( eventName ).undelegate( eventName );
420 | },
421 |
422 | _delay: function( handler, delay ) {
423 | function handlerProxy() {
424 | return ( typeof handler === "string" ? instance[ handler ] : handler )
425 | .apply( instance, arguments );
426 | }
427 | var instance = this;
428 | return setTimeout( handlerProxy, delay || 0 );
429 | },
430 |
431 | _hoverable: function( element ) {
432 | this.hoverable = this.hoverable.add( element );
433 | this._on( element, {
434 | mouseenter: function( event ) {
435 | $( event.currentTarget ).addClass( "ui-state-hover" );
436 | },
437 | mouseleave: function( event ) {
438 | $( event.currentTarget ).removeClass( "ui-state-hover" );
439 | }
440 | });
441 | },
442 |
443 | _focusable: function( element ) {
444 | this.focusable = this.focusable.add( element );
445 | this._on( element, {
446 | focusin: function( event ) {
447 | $( event.currentTarget ).addClass( "ui-state-focus" );
448 | },
449 | focusout: function( event ) {
450 | $( event.currentTarget ).removeClass( "ui-state-focus" );
451 | }
452 | });
453 | },
454 |
455 | _trigger: function( type, event, data ) {
456 | var prop, orig,
457 | callback = this.options[ type ];
458 |
459 | data = data || {};
460 | event = $.Event( event );
461 | event.type = ( type === this.widgetEventPrefix ?
462 | type :
463 | this.widgetEventPrefix + type ).toLowerCase();
464 | // the original event may come from any element
465 | // so we need to reset the target on the new event
466 | event.target = this.element[ 0 ];
467 |
468 | // copy original event properties over to the new event
469 | orig = event.originalEvent;
470 | if ( orig ) {
471 | for ( prop in orig ) {
472 | if ( !( prop in event ) ) {
473 | event[ prop ] = orig[ prop ];
474 | }
475 | }
476 | }
477 |
478 | this.element.trigger( event, data );
479 | return !( $.isFunction( callback ) &&
480 | callback.apply( this.element[0], [ event ].concat( data ) ) === false ||
481 | event.isDefaultPrevented() );
482 | }
483 | };
484 |
485 | $.each( { show: "fadeIn", hide: "fadeOut" }, function( method, defaultEffect ) {
486 | $.Widget.prototype[ "_" + method ] = function( element, options, callback ) {
487 | if ( typeof options === "string" ) {
488 | options = { effect: options };
489 | }
490 | var hasOptions,
491 | effectName = !options ?
492 | method :
493 | options === true || typeof options === "number" ?
494 | defaultEffect :
495 | options.effect || defaultEffect;
496 | options = options || {};
497 | if ( typeof options === "number" ) {
498 | options = { duration: options };
499 | }
500 | hasOptions = !$.isEmptyObject( options );
501 | options.complete = callback;
502 | if ( options.delay ) {
503 | element.delay( options.delay );
504 | }
505 | if ( hasOptions && $.effects && $.effects.effect[ effectName ] ) {
506 | element[ method ]( options );
507 | } else if ( effectName !== method && element[ effectName ] ) {
508 | element[ effectName ]( options.duration, options.easing, callback );
509 | } else {
510 | element.queue(function( next ) {
511 | $( this )[ method ]();
512 | if ( callback ) {
513 | callback.call( element[ 0 ] );
514 | }
515 | next();
516 | });
517 | }
518 | };
519 | });
520 |
521 | })( jQuery );
522 |
--------------------------------------------------------------------------------