diff --git a/Gemfile b/Gemfile index 9ccad44a7a3ad46588a711e9afed2530aca5606f..2683adc4f8efaece289b569db6d07efde9b44e05 100644 --- a/Gemfile +++ b/Gemfile @@ -104,6 +104,7 @@ source "https://rails-assets.org" do gem "rails-assets-markdown-it-sub", "1.0.0" gem "rails-assets-markdown-it-sup", "1.0.0" gem "rails-assets-highlightjs", "8.6.0" + gem "rails-assets-typeahead.js", "0.11.1" # jQuery plugins diff --git a/Gemfile.lock b/Gemfile.lock index cf28ab54b3b56505aa989fdbaba8642c797d4eca..108d579e42ce1c48ce9d1e970b9543364cd81b86 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -569,6 +569,8 @@ GEM rails-assets-markdown-it-sub (1.0.0) rails-assets-markdown-it-sup (1.0.0) rails-assets-perfect-scrollbar (0.6.4) + rails-assets-typeahead.js (0.11.1) + rails-assets-jquery (>= 1.7) rails-deprecated_sanitizer (1.0.3) activesupport (>= 4.2.0.alpha) rails-dom-testing (1.0.6) @@ -860,6 +862,7 @@ DEPENDENCIES rails-assets-markdown-it-sub (= 1.0.0)! rails-assets-markdown-it-sup (= 1.0.0)! rails-assets-perfect-scrollbar (= 0.6.4)! + rails-assets-typeahead.js (= 0.11.1)! rails-i18n (= 4.0.4) rails-timeago (= 2.11.0) rails_admin (= 0.6.8) diff --git a/app/assets/javascripts/app/views/header_view.js b/app/assets/javascripts/app/views/header_view.js index 6434f1a8990bf1154a163724cd47d37b61b0af6d..5b682c3b347289caaea546f5fcb0d953a55a5817 100644 --- a/app/assets/javascripts/app/views/header_view.js +++ b/app/assets/javascripts/app/views/header_view.js @@ -6,11 +6,6 @@ app.views.Header = app.views.Base.extend({ className: "dark-header", - events: { - "focusin #q": "toggleSearchActive", - "focusout #q": "toggleSearchActive" - }, - presenter: function() { return _.extend({}, this.defaultPresenter(), { podname: gon.appConfig.settings.podname @@ -24,13 +19,5 @@ app.views.Header = app.views.Base.extend({ }, menuElement: function(){ return this.$("ul.dropdown"); }, - - toggleSearchActive: function(evt){ - // jQuery produces two events for focus/blur (for bubbling) - // don't rely on which event arrives first, by allowing for both variants - var isActive = (_.indexOf(["focus","focusin"], evt.type) !== -1); - $(evt.target).toggleClass("active", isActive); - return false; - } }); // @license-end diff --git a/app/assets/javascripts/app/views/search_view.js b/app/assets/javascripts/app/views/search_view.js index aed2e54ca48787c829cb51164f4bcbc9bcdc1593..6e8a1d272e79c6dccdae31c1af1290b54e39dcb7 100644 --- a/app/assets/javascripts/app/views/search_view.js +++ b/app/assets/javascripts/app/views/search_view.js @@ -1,71 +1,95 @@ // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later app.views.Search = app.views.Base.extend({ + events: { + "focusin #q": "toggleSearchActive", + "focusout #q": "toggleSearchActive", + "keypress #q": "inputKeypress", + }, + initialize: function(){ - this.searchFormAction = this.$el.attr('action'); - this.searchInput = this.$('input[type="search"]'); - this.searchInputName = this.$('input[type="search"]').attr('name'); - this.searchInputHandle = this.$('input[type="search"]').attr('handle'); - this.options = { - cacheLength: 15, - delay: 800, - extraParams: {limit: 4}, - formatItem: this.formatItem, - formatResult: this.formatResult, - max: 5, - minChars: 2, - onSelect: this.selectItemCallback, - parse: this.parse, - scroll: false, - context: this - }; + this.searchFormAction = this.$el.attr("action"); + this.searchInput = this.$("#q"); - var self = this; - this.searchInput.autocomplete(self.searchFormAction + '.json', - $.extend(self.options, { element: self.searchInput })); + // constructs the suggestion engine + this.setupBloodhound(); + this.setupTypeahead(); + this.searchInput.on("typeahead:select", this.suggestionSelected); }, - formatItem: function(row){ - if(typeof row.search !== 'undefined') { return Diaspora.I18n.t('search_for', row); } - else { - var item = ''; - if (row.avatar) { item += '<img src="' + row.avatar + '" class="avatar"/>'; } - item += row.name; - if (row.handle) { item += '<div class="search_handle">' + row.handle + '</div>'; } - return item; - } + setupBloodhound: function() { + this.bloodhound = new Bloodhound({ + datumTokenizer: function(datum) { + var nameTokens = Bloodhound.tokenizers.nonword(datum.name); + var handleTokens = datum.handle ? Bloodhound.tokenizers.nonword(datum.name) : []; + return nameTokens.concat(handleTokens); + }, + queryTokenizer: Bloodhound.tokenizers.whitespace, + remote: { + url: this.searchFormAction + ".json?q=%QUERY", + wildcard: "%QUERY", + transform: this.transformBloodhoundResponse + }, + prefetch: { + url: "/contacts.json", + transform: this.transformBloodhoundResponse, + cache: false + }, + sufficient: 5 + }); }, - formatResult: function(row){ return Handlebars.Utils.escapeExpression(row.name); }, + setupTypeahead: function() { + this.searchInput.typeahead({ + hint: false, + highlight: true, + minLength: 2 + }, + { + name: "search", + display: "name", + limit: 5, + source: this.bloodhound, + templates: { + /* jshint camelcase: false */ + suggestion: HandlebarsTemplates.search_suggestion_tpl + /* jshint camelcase: true */ + } + }); + }, - parse: function(data) { - var self = this.context; + transformBloodhoundResponse: function(response) { + var result = response.map(function(data) { + // person + if(data.handle) { + data.person = true; + return data; + } - var results = data.map(function(person){ - person.name = self.formatResult(person); - return {data : person, value : person.name}; + // hashtag + return { + hashtag: true, + name: data.name, + url: Routes.tag(data.name.substring(1)) + }; }); - results.push({ - data: { - name: self.searchInput.val(), - url: self.searchFormAction + '?' + self.searchInputName + '=' + self.searchInput.val(), - search: true - }, - value: self.searchInput.val() - }); + return result; + }, - return results; + toggleSearchActive: function(evt) { + // jQuery produces two events for focus/blur (for bubbling) + // don't rely on which event arrives first, by allowing for both variants + var isActive = (_.indexOf(["focus","focusin"], evt.type) !== -1); + $(evt.target).toggleClass("active", isActive); }, - selectItemCallback: function(evt, data, formatted){ - var self = this.context; + suggestionSelected: function(evt, datum) { + window.location = datum.url; + }, - if(data.search === true){ - window.location = self.searchFormAction + '?' + self.searchInputName + '=' + data.name; - } - else{ // The actual result - self.options.element.val(formatted); - window.location = data.url ? data.url : '/tags/' + data.name.substring(1); + inputKeypress: function(evt) { + if(evt.which === 13 && $(".tt-suggestion.tt-cursor").length === 0) { + $(evt.target).closest("form").submit(); } } }); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 8ee4dfc2b12ced2443e12a30ff01cb8642abb53d..cbad4371fdc15cbaed63fb309d35d47b4de1cfc5 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -17,7 +17,6 @@ //= require jakobmattsson-jquery-elastic //= require jquery.mentionsInput //= require jquery.infinitescroll-custom -//= require jquery.autocomplete-custom //= require jquery-ui/core //= require jquery-ui/widget //= require jquery-ui/mouse @@ -35,6 +34,7 @@ //= require markdown-it-sup //= require highlightjs //= require clear-form +//= require typeahead.js //= require app/app //= require diaspora //= require_tree ./helpers diff --git a/app/assets/javascripts/view.js b/app/assets/javascripts/view.js index 5f43ad4dda210a40ecc0c4e9cf09a99b45cb68ae..4810a8a691224904604d2cca8951651e676bcfd4 100644 --- a/app/assets/javascripts/view.js +++ b/app/assets/javascripts/view.js @@ -7,14 +7,6 @@ var View = { /* label placeholders */ $("input, textarea").placeholder(); - /* "Toggling" the search input */ - $(this.search.selector) - .blur(this.search.blur) - .focus(this.search.focus) - - /* Submit the form when the user hits enter */ - .keypress(this.search.keyPress); - /* Dropdowns */ $(document) .on('click', this.dropdowns.selector, this.dropdowns.click) @@ -48,16 +40,6 @@ var View = { }); }, - search: { - blur: function() { - $(this).removeClass("active"); - }, - focus: function() { - $(this).addClass("active"); - }, - selector: "#q" - }, - dropdowns: { click: function(evt) { $(this).parent('.dropdown').toggleClass("active"); diff --git a/app/assets/stylesheets/_application.scss b/app/assets/stylesheets/_application.scss index a0b6d7d0ce01b8567d4d54f454e104ac2fde87fc..585929f2b0a2b6a7eb97f59c9600f6976ac4f783 100644 --- a/app/assets/stylesheets/_application.scss +++ b/app/assets/stylesheets/_application.scss @@ -7,7 +7,6 @@ /* core */ @import 'media-box'; -@import 'autocomplete'; @import 'entypo'; @import 'icons'; @import 'mentions'; @@ -21,6 +20,7 @@ @import 'timeago'; @import 'vendor/fileuploader'; @import 'vendor/autoSuggest'; +@import 'typeahead'; /* font overrides */ @import 'new_styles/typography'; diff --git a/app/assets/stylesheets/autocomplete.scss b/app/assets/stylesheets/autocomplete.scss deleted file mode 100644 index 051dd658075b9dd1f0c848d13ea6eb8878be1744..0000000000000000000000000000000000000000 --- a/app/assets/stylesheets/autocomplete.scss +++ /dev/null @@ -1,95 +0,0 @@ -.ac_results { - border: 1px solid #999; - background-color: transparent; - overflow: hidden; - z-index: 99999; - min-width: 300px !important; - width: 100%; - - border-radius: 3px; - box-shadow: 0 1px 3px #999; -} - -.ac_results ul { - width: 100%; - list-style-position: outside; - list-style: none; - padding: 0; - margin: 0; -} - -.ac_results li { - color: white; - margin: 0px; - padding: 2px 5px { - left: 50px; - top: 6px; - } - cursor: default; - display: block; - height: 45px; - position: relative; - // if width will be 100% horizontal scrollbar will apear - // when scroll mode will be used - // width 100% - font: menu; - font-size: 1em; - // it is very important, if line-height not setted or setted - // in relative units scroll will be broken in firefox - //:line-height 16px - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.ac_input + .spinner { - display: none; -} - -.ac_input.ac_loading + .spinner { - display: inline-block; - height: 18px; - margin-left: -26px; - margin-right: 8px; - margin-top: 7px; - position: absolute; - width: 18px; -} - -.ac_odd { - background-color: $navbar-inverse-bg; -} - -.ac_even { - background-color: darken($navbar-inverse-bg, 3%); -} - -.ac_over { - background-color: $brand-primary; -} - -.ac_results { - .avatar { - height: 35px; - width: 35px; - position: absolute; - left: 5px; - top: 5px; - } - - .search_handle { - font-size: 0.8em; - color: #999; - margin-top: -3px; - } - - .ac_over .search_handle{ - color: #fff; - } - - .ac_over .search_handle, .search_handle { - display: block; - overflow: hidden; - text-overflow: ellipsis; - } -} diff --git a/app/assets/stylesheets/typeahead.scss b/app/assets/stylesheets/typeahead.scss new file mode 100644 index 0000000000000000000000000000000000000000..4c08bc2196ee47013701820ae1b24a41a1c5868f --- /dev/null +++ b/app/assets/stylesheets/typeahead.scss @@ -0,0 +1,30 @@ +.tt-menu { + width: 300px; + margin-top: 11px; + background-color: $navbar-inverse-bg; + box-shadow: 0 5px 10px rgba(0,0,0,.2); +} + +.tt-suggestion { + border-top: 1px solid $gray-dark; + color: $white; + cursor: pointer; + line-height: 20px; + &.tt-cursor { + background-color: $brand-primary; + } + + &.search-suggestion-person { + padding: 8px; + .avatar { + height: 40px; + margin-right: 8px; + width: 40px; + } + .diaspora-id { font-size: $font-size-small; } + } + &.search-suggestion-hashtag { + padding: 8px 20px; + .name { line-height: 25px; } + } +} diff --git a/app/assets/templates/header_tpl.jst.hbs b/app/assets/templates/header_tpl.jst.hbs index f00f237290298fc1072d0c1044ac5c78d8e57d8f..e835337a1a23d23458c61fdec1eaca45cd82ed60 100644 --- a/app/assets/templates/header_tpl.jst.hbs +++ b/app/assets/templates/header_tpl.jst.hbs @@ -101,8 +101,7 @@ <form id="header-search-form" accept-charset="UTF-8" action="/search" class="navbar-form navbar-right" role="search" method="get"> <div class="form-group"> - <input id="q" name="q" placeholder="{{t "header.search"}}" results="5" type="search" autocomplete="off" class="ac_input form-control input-sm"> - <div class="spinner"></div> + <input id="q" name="q" placeholder="{{t "header.search"}}" results="5" type="search" autocomplete="off" class="form-control input-sm"> </div> <input name="utf8" type="hidden" value="✓"> </form> diff --git a/app/assets/templates/search_suggestion_tpl.jst.hbs b/app/assets/templates/search_suggestion_tpl.jst.hbs new file mode 100644 index 0000000000000000000000000000000000000000..a2c734cab5dd3304d0fdfbaaf717ce029af4451a --- /dev/null +++ b/app/assets/templates/search_suggestion_tpl.jst.hbs @@ -0,0 +1,13 @@ +{{#if person}} + <div class="search-suggestion-person"> + {{#if avatar}} + <img src="{{ avatar }}" class="avatar pull-left"> + {{/if}} + <div class="name">{{ name }}</div> + <div class="diaspora-id">{{ handle }}</div> + </div> +{{else}}{{#if hashtag}} + <div class="search-suggestion-hashtag"> + <div class="name">{{ name }}</div> + </div> +{{/if}}{{/if}} diff --git a/config/.jshint.json b/config/.jshint.json index 86bcc1cf27c5918b06c43418c7f96991f470c66e..676c0f1f6d6fb2cbf0c09654d372be6b6496a643 100644 --- a/config/.jshint.json +++ b/config/.jshint.json @@ -40,6 +40,7 @@ "_", "autosize", "Backbone", + "Bloodhound", "gon", "Handlebars", "HandlebarsTemplates", diff --git a/features/desktop/search.feature b/features/desktop/search.feature index da49164df39deee80ca1f780f7649749b22f5d93..8b436018f76fa593fcc83798c95e524dee7dc4a4 100644 --- a/features/desktop/search.feature +++ b/features/desktop/search.feature @@ -6,28 +6,51 @@ Feature: search for users and hashtags Background: Given following users exist: - | username | email | - | Bob Jones | bob@bob.bob | - | Alice Smith | alice@alice.alice | - And I sign in as "bob@bob.bob" + | username | email | + | Bob Jones | bob@bob.bob | + | Alice Smith | alice@alice.alice | + | Carol Williams | carol@example.com | Scenario: search for a user and go to its profile - When I enter "Alice Sm" in the search input - Then I should see "Alice Smith" within ".ac_results" + When I sign in as "bob@bob.bob" + And I enter "Alice Sm" in the search input + Then I should see "Alice Smith" within ".tt-menu" When I click on the first search result Then I should see "Alice Smith" within ".profile_header #name" Scenario: search for a inexistent user and go to the search page - When I enter "Trinity" in the search input - Then I should see "Search for Trinity" within ".ac_even" + When I sign in as "bob@bob.bob" + And I enter "Trinity" in the search input + And I press enter in the search input - When I click on the first search result Then I should see "Users matching Trinity" within "#search_title" +Scenario: search for a not searchable user + When I sign in as "carol@example.com" + And I go to the edit profile page + And I mark myself as not searchable + And I submit the form + Then I should be on the edit profile page + And the "profile[searchable]" checkbox should not be checked + + When I sign out + And I sign in as "bob@bob.bob" + And I enter "Carol Wi" in the search input + Then I should not see any search results + + Given a user with email "bob@bob.bob" is connected with "carol@example.com" + When I go to the home page + And I enter "Carol Wi" in the search input + Then I should see "Carol Williams" within ".tt-menu" + + When I click on the first search result + Then I should see "Carol Williams" within ".profile_header #name" + Scenario: search for a tag - When I enter "#Matrix" in the search input - Then I should see "#matrix" within ".ac_even" + When I sign in as "bob@bob.bob" + And I enter "#Matrix" in the search input + Then I should see "#Matrix" within ".tt-menu" When I click on the first search result Then I should be on the tag page for "matrix" diff --git a/features/step_definitions/profile_steps.rb b/features/step_definitions/profile_steps.rb index 28a5a3489d40430203ac0a051f4d0e525411671a..af9949d9320b8920e33e02553663804a4ac17f3e 100644 --- a/features/step_definitions/profile_steps.rb +++ b/features/step_definitions/profile_steps.rb @@ -6,6 +6,10 @@ And /^I mark myself as safe for work$/ do uncheck('profile[nsfw]') end +And /^I mark myself as not searchable$/ do + uncheck("profile[searchable]") +end + When(/^I delete a photo$/) do find('.photo.loaded .thumbnail', :match => :first).hover find('.delete', :match => :first).click diff --git a/features/step_definitions/search_steps.rb b/features/step_definitions/search_steps.rb index c623c202db4a1ea13526a6352c98982682983874..18b499989bd851827c81b8749d521a0257e2a65d 100644 --- a/features/step_definitions/search_steps.rb +++ b/features/step_definitions/search_steps.rb @@ -3,7 +3,15 @@ When /^I enter "([^"]*)" in the search input$/ do |search_term| end When /^I click on the first search result$/ do - within(".ac_results") do - find("li", match: :first).click + within(".tt-menu") do + find(".tt-suggestion", match: :first).click end end + +When /^I press enter in the search input$/ do + find("input#q").native.send_keys :return +end + +Then /^I should not see any search results$/ do + expect(page).to_not have_selector(".tt-suggestion") +end diff --git a/spec/javascripts/app/views/header_view_spec.js b/spec/javascripts/app/views/header_view_spec.js index 1106d6d6f59df2c57d5fd4372ba211190a86b779..2b1987ff827c56a40f49e4d4b3e4c891e4a19175 100644 --- a/spec/javascripts/app/views/header_view_spec.js +++ b/spec/javascripts/app/views/header_view_spec.js @@ -54,35 +54,4 @@ describe("app.views.Header", function() { }); }); }); - - describe("search", function() { - var input; - - beforeEach(function() { - $("#jasmine_content").html(this.view.el); - input = $(this.view.el).find("#q"); - }); - - describe("focus", function() { - beforeEach(function(done){ - input.trigger("focusin"); - done(); - }); - - it("adds the class 'active' when the user focuses the text field", function() { - expect(input).toHaveClass("active"); - }); - }); - - describe("blur", function() { - beforeEach(function(done) { - input.trigger("focusin").trigger("focusout"); - done(); - }); - - it("removes the class 'active' when the user blurs the text field", function() { - expect(input).not.toHaveClass("active"); - }); - }); - }); }); diff --git a/spec/javascripts/app/views/search_view_spec.js b/spec/javascripts/app/views/search_view_spec.js index ca23e6f7cd778b713e39b66a3df0132ca87b85db..19fc0ccfb2c119fc27bc3fb6ebf4c51730242607 100644 --- a/spec/javascripts/app/views/search_view_spec.js +++ b/spec/javascripts/app/views/search_view_spec.js @@ -1,14 +1,78 @@ describe("app.views.Search", function() { beforeEach(function(){ - spec.content().html('<form action="#" id="search_people_form"></form>'); - this.view = new app.views.Search({ el: '#search_people_form' }); - }); - describe("parse", function() { - it("escapes a persons name", function() { - var person = { 'name': '</script><script>alert("xss");</script' }; - this.view.context = this.view; - var result = this.view.parse([$.extend({}, person)]); - expect(result[0].data.name).not.toEqual(person.name); + spec.content().html( + "<form action='/search' id='search_people_form'><input id='q' name='q' type='search'></input></form>" + ); + }); + + describe("initialize", function() { + it("calls setupBloodhound", function() { + spyOn(app.views.Search.prototype, "setupBloodhound").and.callThrough(); + new app.views.Search({ el: "#search_people_form" }); + expect(app.views.Search.prototype.setupBloodhound).toHaveBeenCalled(); + }); + + it("calls setupTypeahead", function() { + spyOn(app.views.Search.prototype, "setupTypeahead"); + new app.views.Search({ el: "#search_people_form" }); + expect(app.views.Search.prototype.setupTypeahead).toHaveBeenCalled(); + }); + }); + + describe("toggleSearchActive", function() { + beforeEach(function() { + this.view = new app.views.Search({ el: "#search_people_form" }); + this.typeaheadInput = this.view.$("#q"); + }); + + context("focus", function() { + it("adds the class 'active' when the user focuses the text field", function() { + expect(this.typeaheadInput).not.toHaveClass("active"); + this.typeaheadInput.trigger("focusin"); + expect(this.typeaheadInput).toHaveClass("active"); + }); + }); + + context("blur", function() { + beforeEach(function() { + this.typeaheadInput.addClass("active"); + }); + + it("removes the class 'active' when the user blurs the text field", function() { + this.typeaheadInput.trigger("focusout"); + expect(this.typeaheadInput).not.toHaveClass("active"); + }); + }); + }); + + describe("transformBloodhoundResponse" , function() { + beforeEach(function() { + this.view = new app.views.Search({ el: "#search_people_form" }); + }); + context("with persons", function() { + beforeEach(function() { + this.response = [{name: "Person", handle: "person@pod.tld"},{name: "User", handle: "user@pod.tld"}]; + }); + + it("sets data.person to true", function() { + expect(this.view.transformBloodhoundResponse(this.response)).toEqual([ + {name: "Person", handle: "person@pod.tld", person: true}, + {name: "User", handle: "user@pod.tld", person: true} + ]); + }); + }); + + context("with hashtags", function() { + beforeEach(function() { + this.response = [{name: "#tag"}, {name: "#hashTag"}]; + }); + + it("sets data.hashtag to true and adds the correct URL", function() { + expect(this.view.transformBloodhoundResponse(this.response)).toEqual([ + {name: "#tag", hashtag: true, url: Routes.tag("tag")}, + {name: "#hashTag", hashtag: true, url: Routes.tag("hashTag")} + ]); + }); }); }); }); diff --git a/vendor/assets/javascripts/jquery.autocomplete-custom.js b/vendor/assets/javascripts/jquery.autocomplete-custom.js deleted file mode 100644 index a1816a96dc16362d1b2aafa245355442208ab3d8..0000000000000000000000000000000000000000 --- a/vendor/assets/javascripts/jquery.autocomplete-custom.js +++ /dev/null @@ -1,763 +0,0 @@ -/* - * Autocomplete - jQuery plugin 1.1pre - * - * Copyright (c) 2007 Dylan Verheul, Dan G. Switzer, Anjesh Tuladhar, Jörn Zaefferer - * - * Dual licensed under the MIT and GPL licenses: - * http://www.opensource.org/licenses/mit-license.php - * http://www.gnu.org/licenses/gpl.html - * - * Revision: $Id: jquery.autocomplete.js 5785 2008-07-12 10:37:33Z joern.zaefferer $ - * Modified by Diaspora - */ - -;(function($) { - -$.fn.extend({ - autocomplete: function(urlOrData, options) { - var isUrl = typeof urlOrData == "string"; - options = $.extend({}, $.Autocompleter.defaults, { - url: isUrl ? urlOrData : null, - data: isUrl ? null : urlOrData, - delay: isUrl ? $.Autocompleter.defaults.delay : 10, - max: options && !options.scroll ? 10 : 150 - }, options); - - // if highlight is set to false, replace it with a do-nothing function - options.highlight = options.highlight || function(value) { return value; }; - - // if the formatMatch option is not specified, then use formatItem for backwards compatibility - options.formatMatch = options.formatMatch || options.formatItem; - - return this.each(function() { - new $.Autocompleter(this, options); - }); - }, - result: function(handler) { - return this.bind("result", handler); - }, - search: function(handler) { - return this.trigger("search", [handler]); - }, - flushCache: function() { - return this.trigger("flushCache"); - }, - setOptions: function(options){ - return this.trigger("setOptions", [options]); - }, - unautocomplete: function() { - return this.trigger("unautocomplete"); - } -}); - -$.Autocompleter = function(input, options) { - - var KEY = KEYCODES; - - // Create $ object for input element - var $input = $(input).attr("autocomplete", "off").addClass(options.inputClass); - - var timeout; - var previousValue = ""; - var cache = $.Autocompleter.Cache(options); - var hasFocus = 0; - var lastKeyPressCode; - var config = { - mouseDownOnSelect: false - }; - var select = $.Autocompleter.Select(options, input, selectCurrent, config); - - var blockSubmit; - - // prevent form submit in opera when selecting with return key - $.browser.opera && $(input.form).bind("submit.autocomplete", function() { - if (blockSubmit) { - blockSubmit = false; - return false; - } - }); - - // only opera doesn't trigger keydown multiple times while pressed, others don't work with keypress at all - $input.bind(($.browser.opera ? "keypress" : "keydown") + ".autocomplete", function(event) { - // track last key pressed - lastKeyPressCode = event.keyCode; - switch(event.keyCode) { - - case KEY.LEFT: - case KEY.RIGHT: - if( options.disableRightAndLeft && select.visible()){ - event.preventDefault(); - } - break; - case KEY.UP: - if ( select.visible() ) { - event.preventDefault(); - select.prev(); - } else { - onChange(0, true); - } - break; - - case KEY.DOWN: - if ( select.visible() ) { - event.preventDefault(); - select.next(); - } else { - onChange(0, true); - } - break; - - case KEY.PAGEUP: - if ( select.visible() ) { - event.preventDefault(); - select.pageUp(); - } else { - onChange(0, true); - } - break; - - case KEY.PAGEDOWN: - if ( select.visible() ) { - event.preventDefault(); - select.pageDown(); - } else { - onChange(0, true); - } - break; - - // matches also semicolon - case options.multiple && $.trim(options.multipleSeparator) == "," && KEY.COMMA: - case KEY.TAB: - case KEY.RETURN: - if( selectCurrent() ) { - // stop default to prevent a form submit, Opera needs special handling - event.preventDefault(); - blockSubmit = true; - return false; - } - break; - - case KEY.ESC: - select.hide(); - break; - - default: - options.onLetterTyped(event, $input); - clearTimeout(timeout); - timeout = setTimeout(onChange, options.delay); - break; - } - }).focus(function(){ - // track whether the field has focus, we shouldn't process any - // results if the field no longer has focus - hasFocus++; - }).blur(function() { - hasFocus = 0; - if (!config.mouseDownOnSelect) { - hideResults(); - } - }).click(function() { - // show select when clicking in a focused field - if ( hasFocus++ > 1 && !select.visible() ) { - onChange(0, true); - } - }).bind("search", function() { - // TODO why not just specifying both arguments? - var fn = (arguments.length > 1) ? arguments[1] : null; - function findValueCallback(q, data) { - var result; - if( data && data.length ) { - for (var i=0; i < data.length; i++) { - if( data[i].result.toLowerCase() == q.toLowerCase() ) { - result = data[i]; - break; - } - } - } - if( typeof fn == "function" ) fn(result); - else $input.trigger("result", result && [result.data, result.value]); - } - $.each(trimWords($input.val()), function(i, value) { - request(value, findValueCallback, findValueCallback); - }); - }).bind("flushCache", function() { - cache.flush(); - }).bind("setOptions", function() { - $.extend(options, arguments[1]); - // if we've updated the data, repopulate - if ( "data" in arguments[1] ) - cache.populate(); - }).bind("unautocomplete", function() { - select.unbind(); - $input.unbind(); - $(input.form).unbind(".autocomplete"); - }); - - - function selectCurrent() { - var selected = select.selected(); - if( !selected ) - return false; - - var v = selected.result; - previousValue = v; - - if ( options.multiple ) { - var words = trimWords($input.val()); - if ( words.length > 1 ) { - v = words.slice(0, words.length - 1).join( options.multipleSeparator ) + options.multipleSeparator + v; - } - v += options.multipleSeparator; - } - - hideResultsNow(); - options.onSelect($input, selected.data, selected.value); - return true; - } - - function onChange(crap, skipPrevCheck) { - if( lastKeyPressCode == KEY.DEL ) { - select.hide(); - return; - } - - var currentValue = $input.val(); - - if ( !skipPrevCheck && currentValue == previousValue ) - return; - - previousValue = currentValue; - - currentValue = options.searchTermFromValue(currentValue, $input[0].selectionStart); - if ( currentValue.length >= options.minChars) { - $input.addClass(options.loadingClass); - if (!options.matchCase) - currentValue = currentValue.toLowerCase(); - request(currentValue, receiveData, hideResultsNow); - } else { - stopLoading(); - select.hide(); - } - }; - - function trimWords(value) { - if ( !value ) { - return [""]; - } - var words = value.split( options.multipleSeparator ); - var result = []; - $.each(words, function(i, value) { - if ( $.trim(value) ) - result[i] = $.trim(value); - }); - return result; - } - - // fills in the input box w/the first match (assumed to be the best match) - // q: the term entered - // sValue: the first matching result - function autoFill(q, sValue){ - // autofill in the complete box w/the first match as long as the user hasn't entered in more data - // if the last user key pressed was backspace, don't autofill - if( options.autoFill && (options.lastWord($input.val(), null, options.multiple).toLowerCase() == q.toLowerCase()) && lastKeyPressCode != KEY.BACKSPACE ) { - // fill in the value (keep the case the user has typed) - $input.val($input.val() + sValue.substring(options.lastWord(previousValue, null, options.multiple).length)); - // select the portion of the value not typed by the user (so the next character will erase) - $.Autocompleter.Selection(input, previousValue.length, previousValue.length + sValue.length); - } - }; - - function hideResults() { - clearTimeout(timeout); - timeout = setTimeout(hideResultsNow, 200); - }; - - function hideResultsNow() { - select.hide(); - clearTimeout(timeout); - stopLoading(); - if (options.mustMatch) { - // call search and run callback - $input.search( - function (result){ - // if no value found, clear the input box - if( !result ) { - if (options.multiple) { - var words = trimWords($input.val()).slice(0, -1); - $input.val( words.join(options.multipleSeparator) + (words.length ? options.multipleSeparator : "") ); - } - else - $input.val( "" ); - } - } - ); - } - }; - - function receiveData(q, data) { - if ( data && data.length && hasFocus ) { - stopLoading(); - select.display(data, q); - autoFill(q, data[0].value); - select.show(); - } else { - hideResultsNow(); - } - }; - - function request(term, success, failure) { - if (!options.matchCase) - term = term.toLowerCase(); - var data = cache.load(term); - // recieve the cached data - if (data && data.length) { - success(term, data); - // if an AJAX url has been supplied, try loading the data now - } else if( (typeof options.url == "string") && (options.url.length > 0) ){ - - var extraParams = { - timestamp: +new Date() - }; - $.each(options.extraParams, function(key, param) { - extraParams[key] = typeof param == "function" ? param() : param; - }); - - $.ajax({ - // try to leverage ajaxQueue plugin to abort previous requests - mode: "abort", - // limit abortion to this input - port: "autocomplete" + input.name, - dataType: options.dataType, - url: options.url, - data: $.extend({ - q: options.lastWord(term, null, options.multiple), - limit: options.max - }, extraParams), - success: function(data) { - var parsed = options.parse && options.parse(data) || parse(data); - cache.add(term, parsed); - success(term, parsed); - } - }); - } else { - // if we have a failure, we need to empty the list -- this prevents the the [TAB] key from selecting the last successful match - select.emptyList(); - failure(term); - } - }; - - function parse(data) { - var parsed = []; - var rows = data.split("\n"); - for (var i=0; i < rows.length; i++) { - var row = $.trim(rows[i]); - if (row) { - row = row.split("|"); - parsed[parsed.length] = { - data: row, - value: row[0], - result: options.formatResult && options.formatResult(row, row[0]) || row[0] - }; - } - } - return parsed; - }; - - function stopLoading() { - $input.removeClass(options.loadingClass); - }; - -}; - -$.Autocompleter.defaults = { - onLetterTyped : function(event){}, - lastWord : function(value, crap, multiple) { - if ( !multiple ) - return value; - var words = trimWords(value); - return words[words.length - 1]; - }, - inputClass: "ac_input", - resultsClass: "ac_results", - loadingClass: "ac_loading", - onSelect: function(input, data, formatted){ - if (select.visible()) - // position cursor at end of input field - $.Autocompleter.Selection(input, input.value.length, input.value.length); - input.val(formatted); - }, - minChars: 1, - delay: 400, - matchCase: false, - matchSubset: true, - matchContains: false, - cacheLength: 10, - max: 100, - mustMatch: false, - extraParams: {}, - selectFirst: true, - formatItem: function(row) { return row[0]; }, - selectionChanged : function(newItem) {}, - formatMatch: null, - autoFill: false, - width: 0, - multiple: false, - multipleSeparator: ", ", - disableRightAndLeft: false, - highlight: function(value, term) { - return value.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + term.replace(/([\^\$\(\)\[\]\{\}\*\.\+\?\|\\])/gi, "\\$1") + ")(?![^<>]*>)(?![^&;]+;)", "gi"), "<strong>$1</strong>"); - }, - scroll: true, - scrollHeight: 180 -}; -$.Autocompleter.defaults.searchTermFromValue = $.Autocompleter.defaults.lastWord; - -$.Autocompleter.Cache = function(options) { - - var data = {}; - var length = 0; - - function matchSubset(s, sub) { - if (!options.matchCase) - s = s.toLowerCase(); - var i = s.indexOf(sub); - if (options.matchContains == "word"){ - i = s.toLowerCase().search("\\b" + sub.toLowerCase()); - } - if (i == -1) return false; - return i == 0 || options.matchContains; - }; - - function add(q, value) { - if (length > options.cacheLength){ - flush(); - } - if (!data[q]){ - length++; - } - data[q] = value; - } - - function populate(){ - if( !options.data ) return false; - // track the matches - var stMatchSets = {}, - nullData = 0; - - // no url was specified, we need to adjust the cache length to make sure it fits the local data store - if( !options.url ) options.cacheLength = 1; - - // track all options for minChars = 0 - stMatchSets[""] = []; - - // loop through the array and create a lookup structure - for ( var i = 0, ol = options.data.length; i < ol; i++ ) { - var rawValue = options.data[i]; - // if rawValue is a string, make an array otherwise just reference the array - rawValue = (typeof rawValue == "string") ? [rawValue] : rawValue; - - var value = options.formatMatch(rawValue, i+1, options.data.length); - if ( value === false ) - continue; - - var firstChar = value.charAt(0).toLowerCase(); - // if no lookup array for this character exists, look it up now - if( !stMatchSets[firstChar] ) - stMatchSets[firstChar] = []; - - // if the match is a string - var row = { - value: value, - data: rawValue, - result: options.formatResult && options.formatResult(rawValue) || value - }; - - // push the current match into the set list - stMatchSets[firstChar].push(row); - - // keep track of minChars zero items - if ( nullData++ < options.max ) { - stMatchSets[""].push(row); - } - }; - - // add the data items to the cache - $.each(stMatchSets, function(i, value) { - // increase the cache size - options.cacheLength++; - // add to the cache - add(i, value); - }); - } - - // populate any existing data - setTimeout(populate, 25); - - function flush(){ - data = {}; - length = 0; - } - - return { - flush: flush, - add: add, - populate: populate, - load: function(q) { - if (!options.cacheLength || !length) - return null; - /* - * if dealing w/local data and matchContains than we must make sure - * to loop through all the data collections looking for matches - */ - if( !options.url && options.matchContains ){ - // track all matches - var csub = []; - // loop through all the data grids for matches - for( var k in data ){ - // don't search through the stMatchSets[""] (minChars: 0) cache - // this prevents duplicates - if( k.length > 0 ){ - var c = data[k]; - $.each(c, function(i, x) { - // if we've got a match, add it to the array - if (matchSubset(x.value, q)) { - csub.push(x); - } - }); - } - } - return csub; - } else - // if the exact item exists, use it - if (data[q]){ - return data[q]; - } else - if (options.matchSubset) { - for (var i = q.length - 1; i >= options.minChars; i--) { - var c = data[q.substr(0, i)]; - if (c) { - var csub = []; - $.each(c, function(i, x) { - if (matchSubset(x.value, q)) { - csub[csub.length] = x; - } - }); - return csub; - } - } - } - return null; - } - }; -}; - -$.Autocompleter.Select = function (options, input, select, config) { - var CLASSES = { - ACTIVE: "ac_over" - }; - - var listItems, - active = -1, - data, - term = "", - needsInit = true, - element, - list; - - // Create results - function init() { - if (!needsInit) - return; - element = $("<div/>") - .hide() - .addClass(options.resultsClass) - .css("position", "fixed") - .appendTo(document.body); - - list = $("<ul/>").appendTo(element).mouseover( function(event) { - if(target(event).nodeName && target(event).nodeName.toUpperCase() == 'LI') { - active = $("li", list).removeClass(CLASSES.ACTIVE).index(target(event)); - $(target(event)).addClass(CLASSES.ACTIVE); - } - }).click(function(event) { - $(target(event)).addClass(CLASSES.ACTIVE); - select(); - // TODO provide option to avoid setting focus again after selection? useful for cleanup-on-focus - input.focus(); - return false; - }).mousedown(function() { - config.mouseDownOnSelect = true; - }).mouseup(function() { - config.mouseDownOnSelect = false; - }); - - if( options.width > 0 ) - element.css("width", options.width); - - needsInit = false; - } - - function target(event) { - var element = event.target; - while(element && element.tagName != "LI") - element = element.parentNode; - // more fun with IE, sometimes event.target is empty, just ignore it then - if(!element) - return []; - return element; - } - - function moveSelect(step) { - listItems.slice(active, active + 1).removeClass(CLASSES.ACTIVE); - movePosition(step); - var activeItem = listItems.slice(active, active + 1).addClass(CLASSES.ACTIVE); - if(options.scroll) { - var offset = 0; - listItems.slice(0, active).each(function() { - offset += this.offsetHeight; - }); - if((offset + activeItem[0].offsetHeight - list.scrollTop()) > list[0].clientHeight) { - list.scrollTop(offset + activeItem[0].offsetHeight - list.innerHeight()); - } else if(offset < list.scrollTop()) { - list.scrollTop(offset); - } - } - options.selectionChanged(activeItem); - }; - - function movePosition(step) { - active += step; - if (active < 0) { - active = listItems.size() - 1; - } else if (active >= listItems.size()) { - active = 0; - } - } - - function limitNumberOfItems(available) { - return options.max && options.max < available - ? options.max - : available; - } - - function fillList() { - list.empty(); - var max = limitNumberOfItems(data.length); - for (var i=0; i < max; i++) { - if (!data[i]) - continue; - var formatted = options.formatItem(data[i].data, i+1, max, data[i].value, term); - if ( formatted === false ) - continue; - var li = $("<li/>").html( options.highlight(formatted, term) ).addClass(i%2 == 0 ? "ac_even" : "ac_odd").appendTo(list)[0]; - $.data(li, "ac_data", data[i]); - } - listItems = list.find("li"); - if ( options.selectFirst ) { - listItems.slice(0, 1).addClass(CLASSES.ACTIVE); - active = 0; - } - // apply bgiframe if available - if ( $.fn.bgiframe ) - list.bgiframe(); - } - - return { - display: function(d, q) { - init(); - data = d; - term = q; - fillList(); - }, - next: function() { - moveSelect(1); - }, - prev: function() { - moveSelect(-1); - }, - pageUp: function() { - if (active != 0 && active - 8 < 0) { - moveSelect( -active ); - } else { - moveSelect(-8); - } - }, - pageDown: function() { - if (active != listItems.size() - 1 && active + 8 > listItems.size()) { - moveSelect( listItems.size() - 1 - active ); - } else { - moveSelect(8); - } - }, - hide: function() { - element && element.hide(); - listItems && listItems.removeClass(CLASSES.ACTIVE); - active = -1; - }, - visible : function() { - return element && element.is(":visible"); - }, - current: function() { - return this.visible() && (listItems.filter("." + CLASSES.ACTIVE)[0] || options.selectFirst && listItems[0]); - }, - show: function() { - var offset = $(input).offset(); - element.css({ - width: typeof options.width == "string" || options.width > 0 ? options.width : $(input).width(), - top: offset.top + input.offsetHeight - $("nav.navbar").offset().top, - left: offset.left - }).show(); - if(options.scroll) { - list.scrollTop(0); - list.css({ - maxHeight: options.scrollHeight, - overflow: 'auto' - }); - - if($.browser.msie && typeof document.body.style.maxHeight === "undefined") { - var listHeight = 0; - listItems.each(function() { - listHeight += this.offsetHeight; - }); - var scrollbarsVisible = listHeight > options.scrollHeight; - list.css('height', scrollbarsVisible ? options.scrollHeight : listHeight ); - if (!scrollbarsVisible) { - // IE doesn't recalculate width when scrollbar disappears - listItems.width( list.width() - parseInt(listItems.css("padding-left")) - parseInt(listItems.css("padding-right")) ); - } - } - - } - }, - selected: function() { - var selected = listItems && listItems.filter("." + CLASSES.ACTIVE).removeClass(CLASSES.ACTIVE); - return selected && selected.length && $.data(selected[0], "ac_data"); - }, - emptyList: function (){ - list && list.empty(); - }, - unbind: function() { - element && element.remove(); - } - }; -}; - -$.Autocompleter.Selection = function(field, start, end) { - if( field.createTextRange ){ - var selRange = field.createTextRange(); - selRange.collapse(true); - selRange.moveStart("character", start); - selRange.moveEnd("character", end); - selRange.select(); - } else if( field.setSelectionRange ){ - field.setSelectionRange(start, end); - } else { - if( field.selectionStart ){ - field.selectionStart = start; - field.selectionEnd = end; - } - } - field.focus(); -}; - -})(jQuery);