/**
* The autocompletion feature implemented with RightJS
*
* Home page: http://rightjs.org/ui/autocompleter
*
* @copyright (C) 2009-2010 Nikolay V. Nemshilov
*/
if (!RightJS) { throw "Gimme RightJS. Please." };
/**
* The RightJS UI Autocompleter unit base class
*
* Copyright (C) 2009-2010 Nikolay V. Nemshilov
*/
var Autocompleter = new Class(Observer, {
extend: {
EVENTS: $w('show hide update load select done'),
Options: {
url: document.location.href,
param: 'search',
method: 'get',
minLength: 1, // the minimal length when it starts work
threshold: 200, // the typing pause threshold
cache: true, // the use the results cache
local: null, // an optional local search results list
fxName: 'slide', // list appearance fx name
fxDuration: 'short', // list appearance fx duration
spinner: 'native', // spinner element reference
cssRule: '[rel^=autocompleter]'
},
current: null, // reference to the currently active options list
instances: {}, // the input <-> instance map
// finds/instances an autocompleter for the event
find: function(event) {
var input = event.target;
if (input.match(Autocompleter.Options.cssRule)) {
var uid = $uid(input);
if (!Autocompleter.instances[uid])
new Autocompleter(input);
}
},
// DEPRECATED scans the document for autocompletion fields
rescan: function(scope) { }
},
/**
* basic constructor
*
* @param mixed the input element reference, a string id or the element instance
* @param Object options
*/
initialize: function(input, options) {
this.input = $(input); // don't low it down!
this.$super(options);
// storing the callbacks so we could detach them later
this._watch = this.watch.bind(this);
this._hide = this.hide.bind(this);
this.input.onKeyup(this._watch).onBlur(this._hide);
this.holder = $E('div', {'class': 'right-autocompleter'}).insertTo(this.input, 'after');
this.container = $E('div', {'class': 'autocompleter'}).insertTo(this.holder);
this.input.autocompleter = Autocompleter.instances[$uid(input)] = this;
},
// kills the autocompleter
destroy: function() {
this.input.stopObserving('keyup', this._watch).stopObserving('blur', this._hide);
delete(this.input.autocompleter);
return this;
},
// catching up with some additonal options
setOptions: function(options) {
this.$super(this.grabOptions(options));
// building the correct url template with a placeholder
if (!this.options.url.includes('%{search}')) {
this.options.url += (this.options.url.includes('?') ? '&' : '?') + this.options.param + '=%{search}';
}
},
// handles the list appearance
show: function() {
if (this.container.hidden()) {
var dims = this.input.dimensions(), pos = this.holder.position();
this.container.setStyle({
top: (dims.top + dims.height - pos.y) + 'px',
left: (dims.left - pos.x) + 'px',
width: dims.width + 'px'
}).show(this.options.fxName, {
duration: this.options.fxDuration,
onFinish: this.fire.bind(this, 'show')
});
}
return Autocompleter.current = this;
},
// handles the list hidding
hide: function() {
if (this.container.visible()) {
this.container.hide();
this.fire.bind(this, 'hide');
}
Autocompleter.current = null;
return this;
},
// selects the next item on the list
prev: function() {
return this.select('prev', this.container.select('li').last());
},
// selects the next item on the list
next: function() {
return this.select('next', this.container.first('li'));
},
// marks it done
done: function(current) {
var current = current || this.container.first('li.current');
if (current) {
this.input.value = current.innerHTML.stripTags();
}
return this.fire('done').hide();
},
// protected
// trying to extract the input element options
grabOptions: function(options) {
var input = this.input;
var options = options || eval('('+input.get('data-autocompleter-options')+')') || {};
var keys = Autocompleter.Options.cssRule.split('[').last().split(']')[0].split('^='),
key = keys[1], value = input.get(keys[0]), match;
// trying to extract options
if (value && (match = value.match(new RegExp('^'+ key +'+\\[(.*?)\\]$')))) {
match = match[1];
// deciding whether it's a list of local options or an url
if (match.match(/^['"].*?['"]$/)) {
options.local = eval('['+ match +']');
} else if (!match.blank()) {
options.url = match;
}
}
return options;
},
// works with the 'prev' and 'next' methods
select: function(what, fallback) {
var current = this.container.first('li.current');
if (current) {
current = current[what]('li') || current;
}
return this.fire('select', (current || fallback).radioClass('current'));
},
// receives the keyboard events out of the input element
watch: function(event) {
// skip the overlaping key codes that are already watched in the document.js
if ([27,37,38,39,40,13].include(event.keyCode)) return;
if (this.input.value.length >= this.options.minLength) {
if (this.timeout) {
this.timeout.cancel();
}
this.timeout = this.trigger.bind(this).delay(this.options.threshold);
} else {
return this.hide();
}
},
// triggers the actual action
trigger: function() {
this.timeout = null;
this.cache = this.cache || {};
var search = this.input.value;
if (search.length < this.options.minLength) return this.hide();
if (this.cache[search]) {
this.suggest(this.cache[search], search);
} else if (this.options.local) {
this.suggest(this.findLocal(search), search);
} else {
this.request = Xhr.load(this.options.url.replace('%{search}', encodeURIComponent(search)), {
method: this.options.method,
spinner: this.getSpinner(),
onComplete: function(response) {
this.fire('load').suggest(response.responseText, search);
}.bind(this)
});
}
},
// updates the suggestions list
suggest: function(result, search) {
this.container.update(result).select('li').each(function(li) {
// we reassiging the events each time so the were no doublecalls
li.onmouseover = function() { li.radioClass('current'); };
li.onmousedown = function() { this.done(li); }.bind(this);
}, this);
// saving the result in cache
if (this.options.cache) {
this.cache[search] = result;
}
return this.fire('update').show();
},
// performs the locals search
findLocal: function(search) {
var regexp = new RegExp("("+RegExp.escape(search)+")", 'ig');
return $E('ul').insert(
this.options.local.map(function(option) {
if (regexp.test(option)) {
return $E('li', {html:
option.replace(regexp, '$1')
});
}
}).compact()
);
},
// builds a native textual spinner if necessary
getSpinner: function() {
this._spinner = this._spinner || this.options.spinner;
// building the native spinner
if (this._spinner == 'native') {
this._spinner = $E('div', {
'class': 'autocompleter-spinner'
}).insertTo(this.holder);
var dots = '123'.split('').map(function(i) {
return $E('div', {'class': 'dot-'+i, html: '»'});
});
(function() {
var dot = dots.shift();
dots.push(dot);
this._spinner.update(dot);
}.bind(this)).periodical(400);
}
// repositioning the native spinner
if (this.options.spinner == 'native') {
var dims = this.input.dimensions(), pos = this.holder.position();
this._spinner.setStyle('visiblity: hidden').show();
this._spinner.setStyle({
visibility: 'visible',
top: (dims.top + 1 - pos.y) + 'px',
height: (dims.height - 2) + 'px',
lineHeight: (dims.height - 2) + 'px',
left: (dims.left + dims.width - this._spinner.offsetWidth - 1 - pos.x) + 'px'
}).hide();
}
return this._spinner;
}
});
/**
* The document events hooking
*
* Copyright (C) 2009-2010 Nikolay V. Nemshilov
*/
document.on({
// the autocompletion list navigation
keydown: function(event) {
// if there is an active options list, hijacking the navigation buttons
if (Autocompleter.current) {
var name;
switch (event.keyCode) {
case 27: name = 'hide'; break;
case 37: name = 'prev'; break;
case 39: name = 'next'; break;
case 38: name = 'prev'; break;
case 40: name = 'next'; break;
case 13: name = 'done'; break;
}
if (name) {
Autocompleter.current[name]();
event.stop();
}
}
// otherwise trying to find/instanciate an autocompliter
else {
Autocompleter.find(event);
}
}
});
document.write("");