// This is the annotated source code for // [VisualSearch.js](http://documentcloud.github.com/visualsearch/), // a rich search box for real data. // // The annotated source HTML is generated by // [Docco](http://jashkenas.github.com/docco/). /** @license VisualSearch.js 0.4.0 * (c) 2011 Samuel Clay, @samuelclay, DocumentCloud Inc. * VisualSearch.js may be freely distributed under the MIT license. * For all details and documentation: * http://documentcloud.github.com/visualsearch */ (function() { var $ = jQuery; // Handle namespaced jQuery // Setting up VisualSearch globals. These will eventually be made instance-based. if (!window.VS) window.VS = {}; if (!VS.app) VS.app = {}; if (!VS.ui) VS.ui = {}; if (!VS.model) VS.model = {}; if (!VS.utils) VS.utils = {}; // Sets the version for VisualSearch to be used programatically elsewhere. VS.VERSION = '0.5.0'; VS.VisualSearch = function(options) { var defaults = { container : '', query : '', autosearch : true, unquotable : [], remainder : 'text', showFacets : true, readOnly : false, callbacks : { search : $.noop, focus : $.noop, blur : $.noop, facetMatches : $.noop, valueMatches : $.noop, clearSearch : $.noop, removedFacet : $.noop } }; this.options = _.extend({}, defaults, options); this.options.callbacks = _.extend({}, defaults.callbacks, options.callbacks); VS.app.hotkeys.initialize(); this.searchQuery = new VS.model.SearchQuery(); this.searchBox = new VS.ui.SearchBox({ app: this, showFacets: this.options.showFacets }); if (options.container) { var searchBox = this.searchBox.render().el; $(this.options.container).html(searchBox); } this.searchBox.value(this.options.query || ''); // Disable page caching for browsers that incorrectly cache the visual search inputs. // This forces the browser to re-render the page when it is retrieved in its history. $(window).bind('unload', function(e) {}); // Gives the user back a reference to the `searchBox` so they // can use public methods. return this; }; // Entry-point used to tie all parts of VisualSearch together. It will either attach // itself to `options.container`, or pass back the `searchBox` so it can be rendered // at will. VS.init = function(options) { return new VS.VisualSearch(options); }; })(); (function() { var $ = jQuery; // Handle namespaced jQuery // The search box is responsible for managing the many facet views and input views. VS.ui.SearchBox = Backbone.View.extend({ id : 'search', events : { 'click .VS-cancel-search-box' : 'clearSearch', 'mousedown .VS-search-box' : 'maybeFocusSearch', 'dblclick .VS-search-box' : 'highlightSearch', 'click .VS-search-box' : 'maybeTripleClick' }, // Creating a new SearchBox registers handlers for re-rendering facets when necessary, // as well as handling typing when a facet is selected. initialize : function(options) { this.options = _.extend({}, this.options, options); this.app = this.options.app; this.flags = { allSelected : false }; this.facetViews = []; this.inputViews = []; _.bindAll(this, 'renderFacets', '_maybeDisableFacets', 'disableFacets', 'deselectAllFacets', 'addedFacet', 'removedFacet', 'changedFacet'); this.app.searchQuery .bind('reset', this.renderFacets) .bind('add', this.addedFacet) .bind('remove', this.removedFacet) .bind('change', this.changedFacet); $(document).bind('keydown', this._maybeDisableFacets); }, // Renders the search box, but requires placement on the page through `this.el`. render : function() { $(this.el).append(JST['search_box']({ readOnly: this.app.options.readOnly })); $(document.body).setMode('no', 'search'); return this; }, // # Querying Facets # // Either gets a serialized query string or sets the faceted query from a query string. value : function(query) { if (query == null) return this.serialize(); return this.setQuery(query); }, // Uses the VS.app.searchQuery collection to serialize the current query from the various // facets that are in the search box. serialize : function() { var query = []; var inputViewsCount = this.inputViews.length; this.app.searchQuery.each(_.bind(function(facet, i) { query.push(this.inputViews[i].value()); query.push(facet.serialize()); }, this)); if (inputViewsCount) { query.push(this.inputViews[inputViewsCount-1].value()); } return _.compact(query).join(' '); }, // Returns any facet views that are currently selected. Useful for changing the value // callbacks based on what else is in the search box and which facet is being edited. selected: function() { return _.select(this.facetViews, function(view) { return view.modes.editing == 'is' || view.modes.selected == 'is'; }); }, // Similar to `this.selected`, returns any facet models that are currently selected. selectedModels: function() { return _.pluck(this.selected(), 'model'); }, // Takes a query string and uses the SearchParser to parse and render it. Note that // `VS.app.SearchParser` refreshes the `VS.app.searchQuery` collection, which is bound // here to call `this.renderFacets`. setQuery : function(query) { this.currentQuery = query; VS.app.SearchParser.parse(this.app, query); }, // Returns the position of a facet/input view. Useful when moving between facets. viewPosition : function(view) { var views = view.type == 'facet' ? this.facetViews : this.inputViews; var position = _.indexOf(views, view); if (position == -1) position = 0; return position; }, // Used to launch a search. Hitting enter or clicking the search button. searchEvent : function(e) { var query = this.value(); this.focusSearch(e); this.value(query); this.app.options.callbacks.search(query, this.app.searchQuery); }, // # Rendering Facets # // Add a new facet. Facet will be focused and ready to accept a value. Can also // specify position, in the case of adding facets from an inbetween input. addFacet : function(category, initialQuery, position) { category = VS.utils.inflector.trim(category); initialQuery = VS.utils.inflector.trim(initialQuery || ''); if (!category) return; var model = new VS.model.SearchFacet({ category : category, value : initialQuery || '', app : this.app }); this.app.searchQuery.add(model, {at: position}); }, // Renders a newly added facet, and selects it. addedFacet : function (model) { this.renderFacets(); var facetView = _.detect(this.facetViews, function(view) { if (view.model == model) return true; }); _.defer(function() { facetView.enableEdit(); }); }, // Changing a facet programmatically re-renders it. changedFacet: function () { this.renderFacets(); }, // When removing a facet, potentially do something. For now, the adjacent // remaining facet is selected, but this is handled by the facet's view, // since its position is unknown by the time the collection triggers this // remove callback. removedFacet : function (facet, query, options) { this.app.options.callbacks.removedFacet(facet, query, options); }, // Renders each facet as a searchFacet view. renderFacets : function() { this.facetViews = []; this.inputViews = []; this.$('.VS-search-inner').empty(); this.app.searchQuery.each(_.bind(this.renderFacet, this)); // Add on an n+1 empty search input on the very end. this.renderSearchInput(); this.renderPlaceholder(); }, // Render a single facet, using its category and query value. renderFacet : function(facet, position) { var view = new VS.ui.SearchFacet({ app : this.app, model : facet, order : position }); // Input first, facet second. this.renderSearchInput(); this.facetViews.push(view); this.$('.VS-search-inner').children().eq(position*2).after(view.render().el); view.calculateSize(); _.defer(_.bind(view.calculateSize, view)); return view; }, // Render a single input, used to create and autocomplete facets renderSearchInput : function() { var input = new VS.ui.SearchInput({ position: this.inputViews.length, app: this.app, showFacets: this.options.showFacets }); this.$('.VS-search-inner').append(input.render().el); this.inputViews.push(input); }, // Handles showing/hiding the placeholder text renderPlaceholder : function() { var $placeholder = this.$('.VS-placeholder'); if (this.app.searchQuery.length) { $placeholder.addClass("VS-hidden"); } else { $placeholder.removeClass("VS-hidden") .text(this.app.options.placeholder); } }, // # Modifying Facets # // Clears out the search box. Command+A + delete can trigger this, as can a cancel button. // // If a `clearSearch` callback was provided, the callback is invoked and // provided with a function performs the actual removal of the data. This // allows third-party developers to either clear data asynchronously, or // prior to performing their custom "clear" logic. clearSearch : function(e) { if (this.app.options.readOnly) return; var actualClearSearch = _.bind(function() { this.disableFacets(); this.value(''); this.flags.allSelected = false; this.searchEvent(e); this.focusSearch(e); }, this); if (this.app.options.callbacks.clearSearch != $.noop) { this.app.options.callbacks.clearSearch(actualClearSearch); } else { actualClearSearch(); } }, // Command+A selects all facets. selectAllFacets : function() { this.flags.allSelected = true; $(document).one('click.selectAllFacets', this.deselectAllFacets); _.each(this.facetViews, function(facetView, i) { facetView.selectFacet(); }); _.each(this.inputViews, function(inputView, i) { inputView.selectText(); }); }, // Used by facets and input to see if all facets are currently selected. allSelected : function(deselect) { if (deselect) this.flags.allSelected = false; return this.flags.allSelected; }, // After `selectAllFacets` is engaged, this method is bound to the entire document. // This immediate disables and deselects all facets, but it also checks if the user // has clicked on either a facet or an input, and properly selects the view. deselectAllFacets : function(e) { this.disableFacets(); if (this.$(e.target).is('.category,input')) { var el = $(e.target).closest('.search_facet,.search_input'); var view = _.detect(this.facetViews.concat(this.inputViews), function(v) { return v.el == el[0]; }); if (view.type == 'facet') { view.selectFacet(); } else if (view.type == 'input') { _.defer(function() { view.enableEdit(true); }); } } }, // Disables all facets except for the passed in view. Used when switching between // facets, so as not to have to keep state of active facets. disableFacets : function(keepView) { _.each(this.inputViews, function(view) { if (view && view != keepView && (view.modes.editing == 'is' || view.modes.selected == 'is')) { view.disableEdit(); } }); _.each(this.facetViews, function(view) { if (view && view != keepView && (view.modes.editing == 'is' || view.modes.selected == 'is')) { view.disableEdit(); view.deselectFacet(); } }); this.flags.allSelected = false; this.removeFocus(); $(document).unbind('click.selectAllFacets'); }, // Resize all inputs to account for extra keystrokes which may be changing the facet // width incorrectly. This is a safety check to ensure inputs are correctly sized. resizeFacets : function(view) { _.each(this.facetViews, function(facetView, i) { if (!view || facetView == view) { facetView.resize(); } }); }, // Handles keydown events on the document. Used to complete the Cmd+A deletion, and // blurring focus. _maybeDisableFacets : function(e) { if (this.flags.allSelected && VS.app.hotkeys.key(e) == 'backspace') { e.preventDefault(); this.clearSearch(e); return false; } else if (this.flags.allSelected && VS.app.hotkeys.printable(e)) { this.clearSearch(e); } }, // # Focusing Facets # // Move focus between facets and inputs. Takes a direction as well as many options // for skipping over inputs and only to facets, placement of cursor position in facet // (i.e. at the end), and selecting the text in the input/facet. focusNextFacet : function(currentView, direction, options) { options = options || {}; var viewCount = this.facetViews.length; var viewPosition = options.viewPosition || this.viewPosition(currentView); if (!options.skipToFacet) { // Correct for bouncing between matching text and facet arrays. if (currentView.type == 'text' && direction > 0) direction -= 1; if (currentView.type == 'facet' && direction < 0) direction += 1; } else if (options.skipToFacet && currentView.type == 'text' && viewCount == viewPosition && direction >= 0) { // Special case of looping around to a facet from the last search input box. return false; } var view, next = Math.min(viewCount, viewPosition + direction); if (currentView.type == 'text') { if (next >= 0 && next < viewCount) { view = this.facetViews[next]; } else if (next == viewCount) { view = this.inputViews[this.inputViews.length-1]; } if (view && options.selectFacet && view.type == 'facet') { view.selectFacet(); } else if (view) { view.enableEdit(); view.setCursorAtEnd(direction || options.startAtEnd); } } else if (currentView.type == 'facet') { if (options.skipToFacet) { if (next >= viewCount || next < 0) { view = _.last(this.inputViews); view.enableEdit(); } else { view = this.facetViews[next]; view.enableEdit(); view.setCursorAtEnd(direction || options.startAtEnd); } } else { view = this.inputViews[next]; view.enableEdit(); } } if (options.selectText) view.selectText(); this.resizeFacets(); return true; }, maybeFocusSearch : function(e) { if (this.app.options.readOnly) return; if ($(e.target).is('.VS-search-box') || $(e.target).is('.VS-search-inner') || e.type == 'keydown') { this.focusSearch(e); } }, // Bring focus to last input field. focusSearch : function(e, selectText) { if (this.app.options.readOnly) return; var view = this.inputViews[this.inputViews.length-1]; view.enableEdit(selectText); if (!selectText) view.setCursorAtEnd(-1); if (e.type == 'keydown') { view.keydown(e); view.box.trigger('keydown'); } _.defer(_.bind(function() { if (!this.$('input:focus').length) { view.enableEdit(selectText); } }, this)); }, // Double-clicking on the search wrapper should select the existing text in // the last search input. Also start the triple-click timer. highlightSearch : function(e) { if (this.app.options.readOnly) return; if ($(e.target).is('.VS-search-box') || $(e.target).is('.VS-search-inner') || e.type == 'keydown') { var lastinput = this.inputViews[this.inputViews.length-1]; lastinput.startTripleClickTimer(); this.focusSearch(e, true); } }, maybeTripleClick : function(e) { var lastinput = this.inputViews[this.inputViews.length-1]; return lastinput.maybeTripleClick(e); }, // Used to show the user is focused on some input inside the search box. addFocus : function() { if (this.app.options.readOnly) return; this.app.options.callbacks.focus(); this.$('.VS-search-box').addClass('VS-focus'); }, // User is no longer focused on anything in the search box. removeFocus : function() { this.app.options.callbacks.blur(); var focus = _.any(this.facetViews.concat(this.inputViews), function(view) { return view.isFocused(); }); if (!focus) this.$('.VS-search-box').removeClass('VS-focus'); }, // Show a menu which adds pre-defined facets to the search box. This is unused for now. showFacetCategoryMenu : function(e) { e.preventDefault(); e.stopPropagation(); if (this.facetCategoryMenu && this.facetCategoryMenu.modes.open == 'is') { return this.facetCategoryMenu.close(); } var items = [ {title: 'Account', onClick: _.bind(this.addFacet, this, 'account', '')}, {title: 'Project', onClick: _.bind(this.addFacet, this, 'project', '')}, {title: 'Filter', onClick: _.bind(this.addFacet, this, 'filter', '')}, {title: 'Access', onClick: _.bind(this.addFacet, this, 'access', '')} ]; var menu = this.facetCategoryMenu || (this.facetCategoryMenu = new dc.ui.Menu({ items : items, standalone : true })); this.$('.VS-icon-search').after(menu.render().open().content); return false; } }); })(); (function() { var $ = jQuery; // Handle namespaced jQuery // This is the visual search facet that holds the category and its autocompleted // input field. VS.ui.SearchFacet = Backbone.View.extend({ type : 'facet', className : 'search_facet', events : { 'click .category' : 'selectFacet', 'keydown input' : 'keydown', 'mousedown input' : 'enableEdit', 'mouseover .VS-icon-cancel' : 'showDelete', 'mouseout .VS-icon-cancel' : 'hideDelete', 'click .VS-icon-cancel' : 'remove' }, initialize : function(options) { this.options = _.extend({}, this.options, options); this.flags = { canClose : false }; _.bindAll(this, 'set', 'keydown', 'deselectFacet', 'deferDisableEdit'); this.app = this.options.app; }, // Rendering the facet sets up autocompletion, events on blur, and populates // the facet's input with its starting value. render : function() { $(this.el).html(JST['search_facet']({ model : this.model, readOnly: this.app.options.readOnly })); this.setMode('not', 'editing'); this.setMode('not', 'selected'); this.box = this.$('input'); this.box.val(this.model.label()); this.box.bind('blur', this.deferDisableEdit); // Handle paste events with `propertychange` this.box.bind('input propertychange', this.keydown); this.setupAutocomplete(); return this; }, // This method is used to setup the facet's input to auto-grow. // This is defered in the searchBox so it can be attached to the // DOM to get the correct font-size. calculateSize : function() { this.box.autoGrowInput(); this.box.unbind('updated.autogrow'); this.box.bind('updated.autogrow', _.bind(this.moveAutocomplete, this)); }, // Forces a recalculation of this facet's input field's value. Called when // the facet is focused, removed, or otherwise modified. resize : function(e) { this.box.trigger('resize.autogrow', e); }, // Watches the facet's input field to see if it matches the beginnings of // words in `autocompleteValues`, which is different for every category. // If the value, when selected from the autocompletion menu, is different // than what it was, commit the facet and search for it. setupAutocomplete : function() { this.box.autocomplete({ source : _.bind(this.autocompleteValues, this), minLength : 0, delay : 0, autoFocus : true, position : {offset : "0 5"}, create : _.bind(function(e, ui) { $(this.el).find('.ui-autocomplete-input').css('z-index','auto'); }, this), select : _.bind(function(e, ui) { e.preventDefault(); var originalValue = this.model.get('value'); this.set(ui.item.value); if (originalValue != ui.item.value || this.box.val() != ui.item.value) { if (this.app.options.autosearch) { this.search(e); } else { this.app.searchBox.renderFacets(); this.app.searchBox.focusNextFacet(this, 1, {viewPosition: this.options.order}); } } return false; }, this), open : _.bind(function(e, ui) { var box = this.box; this.box.autocomplete('widget').find('.ui-menu-item').each(function() { var $value = $(this), autoCompleteData = $value.data('item.autocomplete') || $value.data('ui-autocomplete-item'); if (autoCompleteData['value'] == box.val() && box.data('autocomplete').menu.activate) { box.data('autocomplete').menu.activate(new $.Event("mouseover"), $value); } }); }, this) }); this.box.autocomplete('widget').addClass('VS-interface'); }, // As the facet's input field grows, it may move to the next line in the // search box. `autoGrowInput` triggers an `updated` event on the input // field, which is bound to this method to move the autocomplete menu. moveAutocomplete : function() { var autocomplete = this.box.data('autocomplete'); if (autocomplete) { autocomplete.menu.element.position({ my : "left top", at : "left bottom", of : this.box.data('autocomplete').element, collision : "flip", offset : "0 5" }); } }, // When a user enters a facet and it is being edited, immediately show // the autocomplete menu and size it to match the contents. searchAutocomplete : function(e) { var autocomplete = this.box.data('autocomplete'); if (autocomplete) { var menu = autocomplete.menu.element; autocomplete.search(); // Resize the menu based on the correctly measured width of what's bigger: // the menu's original size or the menu items' new size. menu.outerWidth(Math.max( menu.width('').outerWidth(), autocomplete.element.outerWidth() )); } }, // Closes the autocomplete menu. Called on disabling, selecting, deselecting, // and anything else that takes focus out of the facet's input field. closeAutocomplete : function() { var autocomplete = this.box.data('autocomplete'); if (autocomplete) autocomplete.close(); }, // Search terms used in the autocomplete menu. These are specific to the facet, // and only match for the facet's category. The values are then matched on the // first letter of any word in matches, and finally sorted according to the // value's own category. You can pass `preserveOrder` as an option in the // `facetMatches` callback to skip any further ordering done client-side. autocompleteValues : function(req, resp) { var category = this.model.get('category'); var value = this.model.get('value'); var searchTerm = req.term; this.app.options.callbacks.valueMatches(category, searchTerm, function(matches, options) { options = options || {}; matches = matches || []; if (searchTerm && value != searchTerm) { if (options.preserveMatches) { resp(matches); } else { var re = VS.utils.inflector.escapeRegExp(searchTerm || ''); var matcher = new RegExp('\\b' + re, 'i'); matches = $.grep(matches, function(item) { return matcher.test(item) || matcher.test(item.value) || matcher.test(item.label); }); } } if (options.preserveOrder) { resp(matches); } else { resp(_.sortBy(matches, function(match) { if (match == value || match.value == value) return ''; else return match; })); } }); }, // Sets the facet's model's value. set : function(value) { if (!value) return; this.model.set({'value': value}); }, // Before the searchBox performs a search, we need to close the // autocomplete menu. search : function(e, direction) { if (!direction) direction = 1; this.closeAutocomplete(); this.app.searchBox.searchEvent(e); _.defer(_.bind(function() { this.app.searchBox.focusNextFacet(this, direction, {viewPosition: this.options.order}); }, this)); }, // Begin editing the facet's input. This is called when the user enters // the input either from another facet or directly clicking on it. // // This method tells all other facets and inputs to disable so it can have // the sole focus. It also prepares the autocompletion menu. enableEdit : function() { if (this.app.options.readOnly) return; if (this.modes.editing != 'is') { this.setMode('is', 'editing'); this.deselectFacet(); if (this.box.val() == '') { this.box.val(this.model.get('value')); } } this.flags.canClose = false; this.app.searchBox.disableFacets(this); this.app.searchBox.addFocus(); _.defer(_.bind(function() { this.app.searchBox.addFocus(); }, this)); this.resize(); this.searchAutocomplete(); this.box.focus(); }, // When the user blurs the input, they may either be going to another input // or off the search box entirely. If they go to another input, this facet // will be instantly disabled, and the canClose flag will be turned back off. // // However, if the user clicks elsewhere on the page, this method starts a timer // that checks if any of the other inputs are selected or are being edited. If // not, then it can finally close itself and its autocomplete menu. deferDisableEdit : function() { this.flags.canClose = true; _.delay(_.bind(function() { if (this.flags.canClose && !this.box.is(':focus') && this.modes.editing == 'is' && this.modes.selected != 'is') { this.disableEdit(); } }, this), 250); }, // Called either by other facets receiving focus or by the timer in `deferDisableEdit`, // this method will turn off the facet, remove any text selection, and close // the autocomplete menu. disableEdit : function() { var newFacetQuery = VS.utils.inflector.trim(this.box.val()); if (newFacetQuery != this.model.get('value')) { this.set(newFacetQuery); } this.flags.canClose = false; this.box.selectRange(0, 0); this.box.blur(); this.setMode('not', 'editing'); this.closeAutocomplete(); this.app.searchBox.removeFocus(); }, // Selects the facet, which blurs the facet's input and highlights the facet. // If this is the only facet being selected (and not part of a select all event), // we attach a mouse/keyboard watcher to check if the next action by the user // should delete this facet or just deselect it. selectFacet : function(e) { if (e) e.preventDefault(); if (this.app.options.readOnly) return; var allSelected = this.app.searchBox.allSelected(); if (this.modes.selected == 'is') return; if (this.box.is(':focus')) { this.box.setCursorPosition(0); this.box.blur(); } this.flags.canClose = false; this.closeAutocomplete(); this.setMode('is', 'selected'); this.setMode('not', 'editing'); if (!allSelected || e) { $(document).unbind('keydown.facet', this.keydown); $(document).unbind('click.facet', this.deselectFacet); _.defer(_.bind(function() { $(document).unbind('keydown.facet').bind('keydown.facet', this.keydown); $(document).unbind('click.facet').one('click.facet', this.deselectFacet); }, this)); this.app.searchBox.disableFacets(this); this.app.searchBox.addFocus(); } return false; }, // Turns off highlighting on the facet. Called in a variety of ways, this // only deselects the facet if it is selected, and then cleans up the // keyboard/mouse watchers that were created when the facet was first // selected. deselectFacet : function(e) { if (e) e.preventDefault(); if (this.modes.selected == 'is') { this.setMode('not', 'selected'); this.closeAutocomplete(); this.app.searchBox.removeFocus(); } $(document).unbind('keydown.facet', this.keydown); $(document).unbind('click.facet', this.deselectFacet); return false; }, // Is the user currently focused in this facet's input field? isFocused : function() { return this.box.is(':focus'); }, // Hovering over the delete button styles the facet so the user knows that // the delete button will kill the entire facet. showDelete : function() { $(this.el).addClass('search_facet_maybe_delete'); }, // On `mouseout`, the user is no longer hovering on the delete button. hideDelete : function() { $(this.el).removeClass('search_facet_maybe_delete'); }, // When switching between facets, depending on the direction the cursor is // coming from, the cursor in this facet's input field should match the original // direction. setCursorAtEnd : function(direction) { if (direction == -1) { this.box.setCursorPosition(this.box.val().length); } else { this.box.setCursorPosition(0); } }, // Deletes the facet and sends the cursor over to the nearest input field. remove : function(e) { var committed = this.model.get('value'); this.deselectFacet(); this.disableEdit(); this.app.searchQuery.remove(this.model); if (committed && this.app.options.autosearch) { this.search(e, -1); } else { this.app.searchBox.renderFacets(); this.app.searchBox.focusNextFacet(this, -1, {viewPosition: this.options.order}); } }, // Selects the text in the facet's input field. When the user tabs between // facets, convention is to highlight the entire field. selectText: function() { this.box.selectRange(0, this.box.val().length); }, // Handles all keyboard inputs when in the facet's input field. This checks // for movement between facets and inputs, entering a new value that needs // to be autocompleted, as well as the removal of this facet. keydown : function(e) { var key = VS.app.hotkeys.key(e); if (key == 'enter' && this.box.val()) { this.disableEdit(); this.search(e); } else if (key == 'left') { if (this.modes.selected == 'is') { this.deselectFacet(); this.app.searchBox.focusNextFacet(this, -1, {startAtEnd: -1}); } else if (this.box.getCursorPosition() == 0 && !this.box.getSelection().length) { this.selectFacet(); } } else if (key == 'right') { if (this.modes.selected == 'is') { e.preventDefault(); this.deselectFacet(); this.setCursorAtEnd(0); this.enableEdit(); } else if (this.box.getCursorPosition() == this.box.val().length) { e.preventDefault(); this.disableEdit(); this.app.searchBox.focusNextFacet(this, 1); } } else if (VS.app.hotkeys.shift && key == 'tab') { e.preventDefault(); this.app.searchBox.focusNextFacet(this, -1, { startAtEnd : -1, skipToFacet : true, selectText : true }); } else if (key == 'tab') { e.preventDefault(); this.app.searchBox.focusNextFacet(this, 1, { skipToFacet : true, selectText : true }); } else if (VS.app.hotkeys.command && (e.which == 97 || e.which == 65)) { e.preventDefault(); this.app.searchBox.selectAllFacets(); return false; } else if (VS.app.hotkeys.printable(e) && this.modes.selected == 'is') { this.app.searchBox.focusNextFacet(this, -1, {startAtEnd: -1}); this.remove(e); } else if (key == 'backspace') { $(document).on('keydown.backspace', function(e) { if (VS.app.hotkeys.key(e) === 'backspace') { e.preventDefault(); } }); $(document).on('keyup.backspace', function(e) { $(document).off('.backspace'); }); if (this.modes.selected == 'is') { e.preventDefault(); this.remove(e); } else if (this.box.getCursorPosition() == 0 && !this.box.getSelection().length) { e.preventDefault(); this.selectFacet(); } e.stopPropagation(); } // Handle paste events if (e.which == null) { // this.searchAutocomplete(e); _.defer(_.bind(this.resize, this, e)); } else { this.resize(e); } } }); })(); (function() { var $ = jQuery; // Handle namespaced jQuery // This is the visual search input that is responsible for creating new facets. // There is one input placed in between all facets. VS.ui.SearchInput = Backbone.View.extend({ type : 'text', className : 'search_input ui-menu', events : { 'keypress input' : 'keypress', 'keydown input' : 'keydown', 'keyup input' : 'keyup', 'click input' : 'maybeTripleClick', 'dblclick input' : 'startTripleClickTimer' }, initialize : function(options) { this.options = _.extend({}, this.options, options); this.app = this.options.app; this.flags = { canClose : false }; _.bindAll(this, 'removeFocus', 'addFocus', 'moveAutocomplete', 'deferDisableEdit'); }, // Rendering the input sets up autocomplete, events on focusing and blurring // the input, and the auto-grow of the input. render : function() { $(this.el).html(JST['search_input']({ readOnly: this.app.options.readOnly })); this.setMode('not', 'editing'); this.setMode('not', 'selected'); this.box = this.$('input'); this.box.autoGrowInput(); this.box.bind('updated.autogrow', this.moveAutocomplete); this.box.bind('blur', this.deferDisableEdit); this.box.bind('focus', this.addFocus); this.setupAutocomplete(); return this; }, // Watches the input and presents an autocompleted menu, taking the // remainder of the input field and adding a separate facet for it. // // See `addTextFacetRemainder` for explanation on how the remainder works. setupAutocomplete : function() { this.box.autocomplete({ minLength : this.options.showFacets ? 0 : 1, delay : 50, autoFocus : true, position : {offset : "0 -1"}, source : _.bind(this.autocompleteValues, this), // Prevent changing the input value on focus of an option focus : function() { return false; }, create : _.bind(function(e, ui) { $(this.el).find('.ui-autocomplete-input').css('z-index','auto'); }, this), select : _.bind(function(e, ui) { e.preventDefault(); // stopPropogation does weird things in jquery-ui 1.9 // e.stopPropagation(); var remainder = this.addTextFacetRemainder(ui.item.label || ui.item.value); var position = this.options.position + (remainder ? 1 : 0); this.app.searchBox.addFacet(ui.item instanceof String ? ui.item : ui.item.value, '', position); return false; }, this) }); // Renders the results grouped by the categories they belong to. this.box.data('autocomplete')._renderMenu = function(ul, items) { var category = ''; _.each(items, _.bind(function(item, i) { if (item.category && item.category != category) { ul.append('