Replace jquery.autocomplete with typeahead.js
This commit is contained in:
parent
25be9ecfd2
commit
8bc86eb98b
18 changed files with 252 additions and 1002 deletions
1
Gemfile
1
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-sub", "1.0.0"
|
||||||
gem "rails-assets-markdown-it-sup", "1.0.0"
|
gem "rails-assets-markdown-it-sup", "1.0.0"
|
||||||
gem "rails-assets-highlightjs", "8.6.0"
|
gem "rails-assets-highlightjs", "8.6.0"
|
||||||
|
gem "rails-assets-typeahead.js", "0.11.1"
|
||||||
|
|
||||||
# jQuery plugins
|
# jQuery plugins
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -569,6 +569,8 @@ GEM
|
||||||
rails-assets-markdown-it-sub (1.0.0)
|
rails-assets-markdown-it-sub (1.0.0)
|
||||||
rails-assets-markdown-it-sup (1.0.0)
|
rails-assets-markdown-it-sup (1.0.0)
|
||||||
rails-assets-perfect-scrollbar (0.6.4)
|
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)
|
rails-deprecated_sanitizer (1.0.3)
|
||||||
activesupport (>= 4.2.0.alpha)
|
activesupport (>= 4.2.0.alpha)
|
||||||
rails-dom-testing (1.0.6)
|
rails-dom-testing (1.0.6)
|
||||||
|
|
@ -860,6 +862,7 @@ DEPENDENCIES
|
||||||
rails-assets-markdown-it-sub (= 1.0.0)!
|
rails-assets-markdown-it-sub (= 1.0.0)!
|
||||||
rails-assets-markdown-it-sup (= 1.0.0)!
|
rails-assets-markdown-it-sup (= 1.0.0)!
|
||||||
rails-assets-perfect-scrollbar (= 0.6.4)!
|
rails-assets-perfect-scrollbar (= 0.6.4)!
|
||||||
|
rails-assets-typeahead.js (= 0.11.1)!
|
||||||
rails-i18n (= 4.0.4)
|
rails-i18n (= 4.0.4)
|
||||||
rails-timeago (= 2.11.0)
|
rails-timeago (= 2.11.0)
|
||||||
rails_admin (= 0.6.8)
|
rails_admin (= 0.6.8)
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,6 @@ app.views.Header = app.views.Base.extend({
|
||||||
|
|
||||||
className: "dark-header",
|
className: "dark-header",
|
||||||
|
|
||||||
events: {
|
|
||||||
"focusin #q": "toggleSearchActive",
|
|
||||||
"focusout #q": "toggleSearchActive"
|
|
||||||
},
|
|
||||||
|
|
||||||
presenter: function() {
|
presenter: function() {
|
||||||
return _.extend({}, this.defaultPresenter(), {
|
return _.extend({}, this.defaultPresenter(), {
|
||||||
podname: gon.appConfig.settings.podname
|
podname: gon.appConfig.settings.podname
|
||||||
|
|
@ -24,13 +19,5 @@ app.views.Header = app.views.Base.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
menuElement: function(){ return this.$("ul.dropdown"); },
|
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
|
// @license-end
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,95 @@
|
||||||
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
|
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
|
||||||
app.views.Search = app.views.Base.extend({
|
app.views.Search = app.views.Base.extend({
|
||||||
|
events: {
|
||||||
|
"focusin #q": "toggleSearchActive",
|
||||||
|
"focusout #q": "toggleSearchActive",
|
||||||
|
"keypress #q": "inputKeypress",
|
||||||
|
},
|
||||||
|
|
||||||
initialize: function(){
|
initialize: function(){
|
||||||
this.searchFormAction = this.$el.attr('action');
|
this.searchFormAction = this.$el.attr("action");
|
||||||
this.searchInput = this.$('input[type="search"]');
|
this.searchInput = this.$("#q");
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
var self = this;
|
// constructs the suggestion engine
|
||||||
this.searchInput.autocomplete(self.searchFormAction + '.json',
|
this.setupBloodhound();
|
||||||
$.extend(self.options, { element: self.searchInput }));
|
this.setupTypeahead();
|
||||||
|
this.searchInput.on("typeahead:select", this.suggestionSelected);
|
||||||
},
|
},
|
||||||
|
|
||||||
formatItem: function(row){
|
setupBloodhound: function() {
|
||||||
if(typeof row.search !== 'undefined') { return Diaspora.I18n.t('search_for', row); }
|
this.bloodhound = new Bloodhound({
|
||||||
else {
|
datumTokenizer: function(datum) {
|
||||||
var item = '';
|
var nameTokens = Bloodhound.tokenizers.nonword(datum.name);
|
||||||
if (row.avatar) { item += '<img src="' + row.avatar + '" class="avatar"/>'; }
|
var handleTokens = datum.handle ? Bloodhound.tokenizers.nonword(datum.name) : [];
|
||||||
item += row.name;
|
return nameTokens.concat(handleTokens);
|
||||||
if (row.handle) { item += '<div class="search_handle">' + row.handle + '</div>'; }
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
formatResult: function(row){ return Handlebars.Utils.escapeExpression(row.name); },
|
|
||||||
|
|
||||||
parse: function(data) {
|
|
||||||
var self = this.context;
|
|
||||||
|
|
||||||
var results = data.map(function(person){
|
|
||||||
person.name = self.formatResult(person);
|
|
||||||
return {data : person, value : person.name};
|
|
||||||
});
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
data: {
|
|
||||||
name: self.searchInput.val(),
|
|
||||||
url: self.searchFormAction + '?' + self.searchInputName + '=' + self.searchInput.val(),
|
|
||||||
search: true
|
|
||||||
},
|
},
|
||||||
value: self.searchInput.val()
|
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
|
||||||
});
|
});
|
||||||
|
|
||||||
return results;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
selectItemCallback: function(evt, data, formatted){
|
setupTypeahead: function() {
|
||||||
var self = this.context;
|
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 */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
if(data.search === true){
|
transformBloodhoundResponse: function(response) {
|
||||||
window.location = self.searchFormAction + '?' + self.searchInputName + '=' + data.name;
|
var result = response.map(function(data) {
|
||||||
}
|
// person
|
||||||
else{ // The actual result
|
if(data.handle) {
|
||||||
self.options.element.val(formatted);
|
data.person = true;
|
||||||
window.location = data.url ? data.url : '/tags/' + data.name.substring(1);
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashtag
|
||||||
|
return {
|
||||||
|
hashtag: true,
|
||||||
|
name: data.name,
|
||||||
|
url: Routes.tag(data.name.substring(1))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
|
||||||
|
suggestionSelected: function(evt, datum) {
|
||||||
|
window.location = datum.url;
|
||||||
|
},
|
||||||
|
|
||||||
|
inputKeypress: function(evt) {
|
||||||
|
if(evt.which === 13 && $(".tt-suggestion.tt-cursor").length === 0) {
|
||||||
|
$(evt.target).closest("form").submit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
//= require jakobmattsson-jquery-elastic
|
//= require jakobmattsson-jquery-elastic
|
||||||
//= require jquery.mentionsInput
|
//= require jquery.mentionsInput
|
||||||
//= require jquery.infinitescroll-custom
|
//= require jquery.infinitescroll-custom
|
||||||
//= require jquery.autocomplete-custom
|
|
||||||
//= require jquery-ui/core
|
//= require jquery-ui/core
|
||||||
//= require jquery-ui/widget
|
//= require jquery-ui/widget
|
||||||
//= require jquery-ui/mouse
|
//= require jquery-ui/mouse
|
||||||
|
|
@ -35,6 +34,7 @@
|
||||||
//= require markdown-it-sup
|
//= require markdown-it-sup
|
||||||
//= require highlightjs
|
//= require highlightjs
|
||||||
//= require clear-form
|
//= require clear-form
|
||||||
|
//= require typeahead.js
|
||||||
//= require app/app
|
//= require app/app
|
||||||
//= require diaspora
|
//= require diaspora
|
||||||
//= require_tree ./helpers
|
//= require_tree ./helpers
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,6 @@ var View = {
|
||||||
/* label placeholders */
|
/* label placeholders */
|
||||||
$("input, textarea").placeholder();
|
$("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 */
|
/* Dropdowns */
|
||||||
$(document)
|
$(document)
|
||||||
.on('click', this.dropdowns.selector, this.dropdowns.click)
|
.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: {
|
dropdowns: {
|
||||||
click: function(evt) {
|
click: function(evt) {
|
||||||
$(this).parent('.dropdown').toggleClass("active");
|
$(this).parent('.dropdown').toggleClass("active");
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
|
|
||||||
/* core */
|
/* core */
|
||||||
@import 'media-box';
|
@import 'media-box';
|
||||||
@import 'autocomplete';
|
|
||||||
@import 'entypo';
|
@import 'entypo';
|
||||||
@import 'icons';
|
@import 'icons';
|
||||||
@import 'mentions';
|
@import 'mentions';
|
||||||
|
|
@ -21,6 +20,7 @@
|
||||||
@import 'timeago';
|
@import 'timeago';
|
||||||
@import 'vendor/fileuploader';
|
@import 'vendor/fileuploader';
|
||||||
@import 'vendor/autoSuggest';
|
@import 'vendor/autoSuggest';
|
||||||
|
@import 'typeahead';
|
||||||
|
|
||||||
/* font overrides */
|
/* font overrides */
|
||||||
@import 'new_styles/typography';
|
@import 'new_styles/typography';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
30
app/assets/stylesheets/typeahead.scss
Normal file
30
app/assets/stylesheets/typeahead.scss
Normal file
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -101,8 +101,7 @@
|
||||||
|
|
||||||
<form id="header-search-form" accept-charset="UTF-8" action="/search" class="navbar-form navbar-right" role="search" method="get">
|
<form id="header-search-form" accept-charset="UTF-8" action="/search" class="navbar-form navbar-right" role="search" method="get">
|
||||||
<div class="form-group">
|
<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">
|
<input id="q" name="q" placeholder="{{t "header.search"}}" results="5" type="search" autocomplete="off" class="form-control input-sm">
|
||||||
<div class="spinner"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<input name="utf8" type="hidden" value="✓">
|
<input name="utf8" type="hidden" value="✓">
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
13
app/assets/templates/search_suggestion_tpl.jst.hbs
Normal file
13
app/assets/templates/search_suggestion_tpl.jst.hbs
Normal file
|
|
@ -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}}
|
||||||
|
|
@ -40,6 +40,7 @@
|
||||||
"_",
|
"_",
|
||||||
"autosize",
|
"autosize",
|
||||||
"Backbone",
|
"Backbone",
|
||||||
|
"Bloodhound",
|
||||||
"gon",
|
"gon",
|
||||||
"Handlebars",
|
"Handlebars",
|
||||||
"HandlebarsTemplates",
|
"HandlebarsTemplates",
|
||||||
|
|
|
||||||
|
|
@ -6,28 +6,51 @@ Feature: search for users and hashtags
|
||||||
|
|
||||||
Background:
|
Background:
|
||||||
Given following users exist:
|
Given following users exist:
|
||||||
| username | email |
|
| username | email |
|
||||||
| Bob Jones | bob@bob.bob |
|
| Bob Jones | bob@bob.bob |
|
||||||
| Alice Smith | alice@alice.alice |
|
| Alice Smith | alice@alice.alice |
|
||||||
And I sign in as "bob@bob.bob"
|
| Carol Williams | carol@example.com |
|
||||||
|
|
||||||
Scenario: search for a user and go to its profile
|
Scenario: search for a user and go to its profile
|
||||||
When I enter "Alice Sm" in the search input
|
When I sign in as "bob@bob.bob"
|
||||||
Then I should see "Alice Smith" within ".ac_results"
|
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
|
When I click on the first search result
|
||||||
Then I should see "Alice Smith" within ".profile_header #name"
|
Then I should see "Alice Smith" within ".profile_header #name"
|
||||||
|
|
||||||
Scenario: search for a inexistent user and go to the search page
|
Scenario: search for a inexistent user and go to the search page
|
||||||
When I enter "Trinity" in the search input
|
When I sign in as "bob@bob.bob"
|
||||||
Then I should see "Search for Trinity" within ".ac_even"
|
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"
|
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
|
Scenario: search for a tag
|
||||||
When I enter "#Matrix" in the search input
|
When I sign in as "bob@bob.bob"
|
||||||
Then I should see "#matrix" within ".ac_even"
|
And I enter "#Matrix" in the search input
|
||||||
|
Then I should see "#Matrix" within ".tt-menu"
|
||||||
|
|
||||||
When I click on the first search result
|
When I click on the first search result
|
||||||
Then I should be on the tag page for "matrix"
|
Then I should be on the tag page for "matrix"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ And /^I mark myself as safe for work$/ do
|
||||||
uncheck('profile[nsfw]')
|
uncheck('profile[nsfw]')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
And /^I mark myself as not searchable$/ do
|
||||||
|
uncheck("profile[searchable]")
|
||||||
|
end
|
||||||
|
|
||||||
When(/^I delete a photo$/) do
|
When(/^I delete a photo$/) do
|
||||||
find('.photo.loaded .thumbnail', :match => :first).hover
|
find('.photo.loaded .thumbnail', :match => :first).hover
|
||||||
find('.delete', :match => :first).click
|
find('.delete', :match => :first).click
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,15 @@ When /^I enter "([^"]*)" in the search input$/ do |search_term|
|
||||||
end
|
end
|
||||||
|
|
||||||
When /^I click on the first search result$/ do
|
When /^I click on the first search result$/ do
|
||||||
within(".ac_results") do
|
within(".tt-menu") do
|
||||||
find("li", match: :first).click
|
find(".tt-suggestion", match: :first).click
|
||||||
end
|
end
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,78 @@
|
||||||
describe("app.views.Search", function() {
|
describe("app.views.Search", function() {
|
||||||
beforeEach(function(){
|
beforeEach(function(){
|
||||||
spec.content().html('<form action="#" id="search_people_form"></form>');
|
spec.content().html(
|
||||||
this.view = new app.views.Search({ el: '#search_people_form' });
|
"<form action='/search' id='search_people_form'><input id='q' name='q' type='search'></input></form>"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
describe("parse", function() {
|
|
||||||
it("escapes a persons name", function() {
|
describe("initialize", function() {
|
||||||
var person = { 'name': '</script><script>alert("xss");</script' };
|
it("calls setupBloodhound", function() {
|
||||||
this.view.context = this.view;
|
spyOn(app.views.Search.prototype, "setupBloodhound").and.callThrough();
|
||||||
var result = this.view.parse([$.extend({}, person)]);
|
new app.views.Search({ el: "#search_people_form" });
|
||||||
expect(result[0].data.name).not.toEqual(person.name);
|
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")}
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
Loading…
Reference in a new issue