1699 lines
53 KiB
JavaScript
1699 lines
53 KiB
JavaScript
|
/*
|
||
|
* jquery-autocomplete-light - v3.5.0
|
||
|
* Dead simple autocompletion and widgets for jQuery
|
||
|
* http://yourlabs.org
|
||
|
*
|
||
|
* Made by James Pic
|
||
|
* Under MIT License
|
||
|
*/
|
||
|
/*
|
||
|
Here is the list of the major difference with other autocomplete scripts:
|
||
|
|
||
|
- don't do anything but fire a signal when a choice is selected: it's
|
||
|
left as an exercise to the developer to implement whatever he wants when
|
||
|
that happens
|
||
|
- don't generate the autocomplete HTML, it should be generated by the server
|
||
|
|
||
|
Let's establish the vocabulary used in this script, so that we speak the
|
||
|
same language:
|
||
|
|
||
|
- The text input element is "input",
|
||
|
- The box that contains a list of choices is "box",
|
||
|
- Each result in the "autocomplete" is a "choice",
|
||
|
- With a capital A, "Autocomplete", is the class or an instance of the
|
||
|
class.
|
||
|
|
||
|
Here is a fantastic schema in ASCII art:
|
||
|
|
||
|
+---------------------+ <----- Input
|
||
|
| Your city name ? <---------- Placeholder
|
||
|
+---------------------+
|
||
|
| Paris, France | <----- Autocomplete
|
||
|
| Paris, TX, USA |
|
||
|
| Paris, TN, USA |
|
||
|
| Paris, KY, USA <------------ Choice
|
||
|
| Paris, IL, USA |
|
||
|
+---------------------+
|
||
|
|
||
|
This script defines three signals:
|
||
|
|
||
|
- hilightChoice: when a choice is hilight, or that the user
|
||
|
navigates into a choice with the keyboard,
|
||
|
- dehilightChoice: when a choice was hilighed, and that the user
|
||
|
navigates into another choice with the keyboard or mouse,
|
||
|
- selectChoice: when the user clicks on a choice, or that he pressed
|
||
|
enter on a hilighted choice.
|
||
|
|
||
|
They all work the same, here's a trivial example:
|
||
|
|
||
|
$('#your-autocomplete').bind(
|
||
|
'selectChoice',
|
||
|
function(e, choice, autocomplete) {
|
||
|
alert('You selected: ' + choice.html());
|
||
|
}
|
||
|
);
|
||
|
|
||
|
Note that 'e' is the variable containing the event object.
|
||
|
|
||
|
Also, note that this script is composed of two main parts:
|
||
|
|
||
|
- The Autocomplete class that handles all interaction, defined as
|
||
|
`Autocomplete`,
|
||
|
- The jQuery plugin that manages Autocomplete instance, defined as
|
||
|
`$.fn.yourlabsAutocomplete`
|
||
|
*/
|
||
|
|
||
|
if (window.isOpera === undefined) {
|
||
|
var isOpera = (navigator.userAgent.indexOf('Opera')>=0) && parseFloat(navigator.appVersion);
|
||
|
}
|
||
|
|
||
|
if (window.isIE === undefined) {
|
||
|
var isIE = ((document.all) && (!isOpera)) && parseFloat(navigator.appVersion.split('MSIE ')[1].split(';')[0]);
|
||
|
}
|
||
|
|
||
|
if (window.findPosX === undefined) {
|
||
|
window.findPosX = function(obj) {
|
||
|
var curleft = 0;
|
||
|
if (obj.offsetParent) {
|
||
|
while (obj.offsetParent) {
|
||
|
curleft += obj.offsetLeft - ((isOpera) ? 0 : obj.scrollLeft);
|
||
|
obj = obj.offsetParent;
|
||
|
}
|
||
|
// IE offsetParent does not include the top-level
|
||
|
if (isIE && obj.parentElement){
|
||
|
curleft += obj.offsetLeft - obj.scrollLeft;
|
||
|
}
|
||
|
} else if (obj.x) {
|
||
|
curleft += obj.x;
|
||
|
}
|
||
|
return curleft;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (window.findPosY === undefined) {
|
||
|
window.findPosY = function(obj) {
|
||
|
var curtop = 0;
|
||
|
if (obj.offsetParent) {
|
||
|
while (obj.offsetParent) {
|
||
|
curtop += obj.offsetTop - ((isOpera) ? 0 : obj.scrollTop);
|
||
|
obj = obj.offsetParent;
|
||
|
}
|
||
|
// IE offsetParent does not include the top-level
|
||
|
if (isIE && obj.parentElement){
|
||
|
curtop += obj.offsetTop - obj.scrollTop;
|
||
|
}
|
||
|
} else if (obj.y) {
|
||
|
curtop += obj.y;
|
||
|
}
|
||
|
return curtop;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Our class will live in the yourlabs global namespace.
|
||
|
if (window.yourlabs === undefined) window.yourlabs = {};
|
||
|
|
||
|
// Fix #25: Prevent accidental inclusion of autocomplete_light/static.html
|
||
|
if (window.yourlabs.Autocomplete !== undefined)
|
||
|
console.log('WARNING ! You are loading autocomplete.js **again**.');
|
||
|
|
||
|
yourlabs.getInternetExplorerVersion = function()
|
||
|
// Returns the version of Internet Explorer or a -1
|
||
|
// (indicating the use of another browser).
|
||
|
{
|
||
|
var rv = -1; // Return value assumes failure.
|
||
|
if (navigator.appName === 'Microsoft Internet Explorer')
|
||
|
{
|
||
|
var ua = navigator.userAgent;
|
||
|
var re = new RegExp('MSIE ([0-9]{1,}[.0-9]{0,})');
|
||
|
if (re.exec(ua) !== null)
|
||
|
rv = parseFloat( RegExp.$1 );
|
||
|
}
|
||
|
return rv;
|
||
|
};
|
||
|
|
||
|
$.fn.yourlabsRegistry = function(key, value) {
|
||
|
var ie = yourlabs.getInternetExplorerVersion();
|
||
|
|
||
|
if (ie === -1 || ie > 8) {
|
||
|
// If not on IE8 and friends, that's all we need to do.
|
||
|
return value === undefined ? this.data(key) : this.data(key, value);
|
||
|
}
|
||
|
|
||
|
if ($.fn.yourlabsRegistry.data === undefined) {
|
||
|
$.fn.yourlabsRegistry.data = {};
|
||
|
}
|
||
|
|
||
|
if ($.fn.yourlabsRegistry.guid === undefined) {
|
||
|
$.fn.yourlabsRegistry.guid = function() {
|
||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(
|
||
|
/[xy]/g,
|
||
|
function(c) {
|
||
|
var r = Math.random()*16|0, v = c === 'x' ? r : (r&0x3|0x8);
|
||
|
return v.toString(16);
|
||
|
}
|
||
|
);
|
||
|
};
|
||
|
}
|
||
|
|
||
|
var attributeName = 'data-yourlabs-' + key + '-registry-id';
|
||
|
var id = this.attr(attributeName);
|
||
|
|
||
|
if (id === undefined) {
|
||
|
id = $.fn.yourlabsRegistry.guid();
|
||
|
this.attr(attributeName, id);
|
||
|
}
|
||
|
|
||
|
if (value !== undefined) {
|
||
|
$.fn.yourlabsRegistry.data[id] = value;
|
||
|
}
|
||
|
|
||
|
return $.fn.yourlabsRegistry.data[id];
|
||
|
};
|
||
|
|
||
|
/*
|
||
|
The autocomplete class constructor:
|
||
|
|
||
|
- takes a takes a text input element as argument,
|
||
|
- sets attributes and methods for this instance.
|
||
|
|
||
|
The reason you want to learn about all this script is that you will then be
|
||
|
able to override any variable or function in it on a case-per-case basis.
|
||
|
However, overriding is the job of the jQuery plugin so the procedure is
|
||
|
described there.
|
||
|
*/
|
||
|
yourlabs.Autocomplete = function (input) {
|
||
|
/*
|
||
|
The text input element that should have an autocomplete.
|
||
|
*/
|
||
|
this.input = input;
|
||
|
|
||
|
// The value of the input. It is kept as an attribute for optimisation
|
||
|
// purposes.
|
||
|
this.value = null;
|
||
|
|
||
|
/*
|
||
|
It is possible to wait until a certain number of characters have been
|
||
|
typed in the input before making a request to the server, to limit the
|
||
|
number of requests.
|
||
|
|
||
|
However, you may want the autocomplete to behave like a select. If you
|
||
|
want that a simple click shows the autocomplete, set this to 0.
|
||
|
*/
|
||
|
this.minimumCharacters = 2;
|
||
|
|
||
|
/*
|
||
|
In a perfect world, we would hide the autocomplete when the input looses
|
||
|
focus (on blur). But in reality, if the user clicks on a choice, the
|
||
|
input looses focus, and that would hide the autocomplete, *before* we
|
||
|
can intercept the click on the choice.
|
||
|
|
||
|
When the input looses focus, wait for this number of milliseconds before
|
||
|
hiding the autocomplete.
|
||
|
*/
|
||
|
this.hideAfter = 200;
|
||
|
|
||
|
/*
|
||
|
Normally the autocomplete box aligns with the left edge of the input. To
|
||
|
align with the right edge of the input instead, change this variable.
|
||
|
*/
|
||
|
this.alignRight = false;
|
||
|
|
||
|
/*
|
||
|
The server should have a URL that takes the input value, and responds
|
||
|
with the list of choices as HTML. In most cases, an absolute URL is
|
||
|
better.
|
||
|
*/
|
||
|
this.url = false;
|
||
|
|
||
|
/*
|
||
|
Although this script will make sure that it doesn't have multiple ajax
|
||
|
requests at the time, it also supports debouncing.
|
||
|
|
||
|
Set a number of milliseconds here, it is the number of milliseconds that it
|
||
|
will wait before querying the server. The higher it is, the less it will
|
||
|
spam the server but the more the user will wait.
|
||
|
*/
|
||
|
this.xhrWait = 200;
|
||
|
|
||
|
/*
|
||
|
As the server responds with plain HTML, we need a selector to find the
|
||
|
choices that it contains.
|
||
|
|
||
|
For example, if the URL returns an HTML body where every result is in a
|
||
|
div of class "choice", then this should be set to '.choice'.
|
||
|
*/
|
||
|
this.choiceSelector = '.choice';
|
||
|
|
||
|
/*
|
||
|
When the user hovers a choice, it is nice to hilight it, for
|
||
|
example by changing it's background color. That's the job of CSS code.
|
||
|
|
||
|
However, the CSS can not depend on the :hover because the user can
|
||
|
hilight choices with the keyboard by pressing the up and down
|
||
|
keys.
|
||
|
|
||
|
To counter that problem, we specify a particular class that will be set
|
||
|
on a choice when it's 'hilighted', and unset when it's
|
||
|
'dehilighted'.
|
||
|
*/
|
||
|
this.hilightClass = 'hilight';
|
||
|
|
||
|
/*
|
||
|
You can set this variable to true if you want the first choice
|
||
|
to be hilighted by default.
|
||
|
*/
|
||
|
this.autoHilightFirst = false;
|
||
|
|
||
|
|
||
|
/*
|
||
|
You can set this variable to false in order to allow opening of results
|
||
|
in new tabs or windows
|
||
|
*/
|
||
|
this.bindMouseDown = true;
|
||
|
|
||
|
/*
|
||
|
The value of the input is passed to the server via a GET variable. This
|
||
|
is the name of the variable.
|
||
|
*/
|
||
|
this.queryVariable = 'q';
|
||
|
|
||
|
/*
|
||
|
This dict will also be passed to the server as GET variables.
|
||
|
|
||
|
If this autocomplete depends on another user defined value, then the
|
||
|
other user defined value should be set in this dict.
|
||
|
|
||
|
Consider a country select and a city autocomplete. The city autocomplete
|
||
|
should only fetch city choices that are in the selected country. To
|
||
|
achieve this, update the data with the value of the country select:
|
||
|
|
||
|
$('select[name=country]').change(function() {
|
||
|
$('city[name=country]').yourlabsAutocomplete().data = {
|
||
|
country: $(this).val(),
|
||
|
}
|
||
|
});
|
||
|
*/
|
||
|
this.data = {};
|
||
|
|
||
|
/*
|
||
|
To avoid several requests to be pending at the same time, the current
|
||
|
request is aborted before a new one is sent. This attribute will hold the
|
||
|
current XMLHttpRequest.
|
||
|
*/
|
||
|
this.xhr = false;
|
||
|
|
||
|
/*
|
||
|
fetch() keeps a copy of the data sent to the server in this attribute. This
|
||
|
avoids double fetching the same autocomplete.
|
||
|
*/
|
||
|
this.lastData = {};
|
||
|
|
||
|
// The autocomplete box HTML.
|
||
|
this.box = $('<span class="yourlabs-autocomplete" ' +
|
||
|
'data-input-id="' + this.input.attr('id') + '"></span>');
|
||
|
|
||
|
/*
|
||
|
We'll append the box to the container and calculate an absolute position
|
||
|
every time the autocomplete is shown in the fixPosition method.
|
||
|
|
||
|
By default, this traverses this.input's parents to find the nearest parent
|
||
|
with an 'absolute' or 'fixed' position. This prevents scrolling issues. If
|
||
|
we can't find a parent that would be correct to append to, default to
|
||
|
<body>.
|
||
|
*/
|
||
|
this.container = this.input.parents().filter(function() {
|
||
|
var position = $(this).css('position');
|
||
|
return position === 'absolute' || position === 'fixed';
|
||
|
}).first();
|
||
|
if (!this.container.length) this.container = $('body');
|
||
|
};
|
||
|
|
||
|
/*
|
||
|
Rather than directly setting up the autocomplete (DOM events etc ...) in
|
||
|
the constructor, setup is done in this method. This allows to:
|
||
|
|
||
|
- instanciate an Autocomplete,
|
||
|
- override attribute/methods of the instance,
|
||
|
- and *then* setup the instance.
|
||
|
*/
|
||
|
yourlabs.Autocomplete.prototype.initialize = function() {
|
||
|
var ie = yourlabs.getInternetExplorerVersion();
|
||
|
|
||
|
this.input
|
||
|
.on('blur.autocomplete', $.proxy(this.inputBlur, this))
|
||
|
.on('focus.autocomplete', $.proxy(this.inputClick, this))
|
||
|
.on('keydown.autocomplete', $.proxy(this.inputKeyup, this));
|
||
|
|
||
|
$(window).on('resize', $.proxy(function() {
|
||
|
if (this.box.is(':visible')) this.fixPosition();
|
||
|
}, this));
|
||
|
|
||
|
// Currently, our positioning doesn't work well in Firefox. Since it's not
|
||
|
// the first option on mobile phones and small devices, we'll hide the bug
|
||
|
// until this is fixed.
|
||
|
if (/Firefox/i.test(navigator.userAgent))
|
||
|
$(window).on('scroll', $.proxy(this.hide, this));
|
||
|
|
||
|
if (ie === -1 || ie > 9) {
|
||
|
this.input.on('input.autocomplete', $.proxy(this.refresh, this));
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
var events = [
|
||
|
'keyup.autocomplete',
|
||
|
'keypress.autocomplete',
|
||
|
'cut.autocomplete',
|
||
|
'paste.autocomplete'
|
||
|
]
|
||
|
|
||
|
this.input.on(events.join(' '), function($e) {
|
||
|
$.proxy(this.inputKeyup, this);
|
||
|
})
|
||
|
}
|
||
|
|
||
|
/*
|
||
|
Bind mouse events to fire signals. Because the same signals will be
|
||
|
sent if the user uses keyboard to work with the autocomplete.
|
||
|
*/
|
||
|
this.box
|
||
|
.on('mouseenter', this.choiceSelector, $.proxy(this.boxMouseenter, this))
|
||
|
.on('mouseleave', this.choiceSelector, $.proxy(this.boxMouseleave, this));
|
||
|
if(this.bindMouseDown){
|
||
|
this.box
|
||
|
.on('mousedown', this.choiceSelector, $.proxy(this.boxClick, this));
|
||
|
}
|
||
|
|
||
|
/*
|
||
|
Initially - empty data queried
|
||
|
*/
|
||
|
this.data[this.queryVariable] = '';
|
||
|
};
|
||
|
|
||
|
// Unbind callbacks on input.
|
||
|
yourlabs.Autocomplete.prototype.destroy = function(input) {
|
||
|
input
|
||
|
.unbind('blur.autocomplete')
|
||
|
.unbind('focus.autocomplete')
|
||
|
.unbind('input.autocomplete')
|
||
|
.unbind('keydown.autocomplete')
|
||
|
.unbind('keypress.autocomplete')
|
||
|
.unbind('keyup.autocomplete')
|
||
|
};
|
||
|
|
||
|
yourlabs.Autocomplete.prototype.inputBlur = function(e) {
|
||
|
window.setTimeout($.proxy(this.hide, this), this.hideAfter);
|
||
|
};
|
||
|
|
||
|
yourlabs.Autocomplete.prototype.inputClick = function(e) {
|
||
|
if (this.value === null)
|
||
|
this.value = this.getQuery();
|
||
|
|
||
|
if (this.value.length >= this.minimumCharacters)
|
||
|
this.show();
|
||
|
};
|
||
|
|
||
|
// When mouse enters the box:
|
||
|
yourlabs.Autocomplete.prototype.boxMouseenter = function(e) {
|
||
|
// ... the first thing we want is to send the dehilight signal
|
||
|
// for any hilighted choice ...
|
||
|
var current = this.box.find('.' + this.hilightClass);
|
||
|
|
||
|
this.input.trigger('dehilightChoice',
|
||
|
[current, this]);
|
||
|
|
||
|
// ... and then sent the hilight signal for the choice.
|
||
|
this.input.trigger('hilightChoice',
|
||
|
[$(e.currentTarget), this]);
|
||
|
};
|
||
|
|
||
|
// When mouse leaves the box:
|
||
|
yourlabs.Autocomplete.prototype.boxMouseleave = function(e) {
|
||
|
// Send dehilightChoice when the mouse leaves a choice.
|
||
|
this.input.trigger('dehilightChoice',
|
||
|
[this.box.find('.' + this.hilightClass), this]);
|
||
|
};
|
||
|
|
||
|
// When mouse clicks in the box:
|
||
|
yourlabs.Autocomplete.prototype.boxClick = function(e) {
|
||
|
var current = this.box.find('.' + this.hilightClass);
|
||
|
|
||
|
this.input.trigger('selectChoice', [current, this]);
|
||
|
};
|
||
|
|
||
|
// Return the value to pass to this.queryVariable.
|
||
|
yourlabs.Autocomplete.prototype.getQuery = function() {
|
||
|
// Return the input's value by default.
|
||
|
return this.input.val();
|
||
|
};
|
||
|
|
||
|
yourlabs.Autocomplete.prototype.inputKeyup = function(e) {
|
||
|
if (!this.input.is(':visible'))
|
||
|
// Don't handle keypresses on hidden inputs (ie. with limited choices)
|
||
|
return;
|
||
|
|
||
|
switch(e.keyCode) {
|
||
|
case 40: // down arrow
|
||
|
case 38: // up arrow
|
||
|
case 16: // shift
|
||
|
case 17: // ctrl
|
||
|
case 18: // alt
|
||
|
this.move(e);
|
||
|
break;
|
||
|
|
||
|
case 9: // tab
|
||
|
case 13: // enter
|
||
|
if (!this.box.is(':visible')) return;
|
||
|
|
||
|
var choice = this.box.find('.' + this.hilightClass);
|
||
|
|
||
|
if (!choice.length) {
|
||
|
// Don't get in the way, let the browser submit form or focus
|
||
|
// on next element.
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
e.preventDefault();
|
||
|
e.stopPropagation();
|
||
|
|
||
|
this.input.trigger('selectChoice', [choice, this]);
|
||
|
break;
|
||
|
|
||
|
case 27: // escape
|
||
|
if (!this.box.is(':visible')) return;
|
||
|
this.hide();
|
||
|
break;
|
||
|
|
||
|
default:
|
||
|
this.refresh();
|
||
|
}
|
||
|
};
|
||
|
|
||
|
// This function is in charge of ensuring that a relevant autocomplete is
|
||
|
// shown.
|
||
|
yourlabs.Autocomplete.prototype.show = function(html) {
|
||
|
// First recalculate the absolute position since the autocomplete may
|
||
|
// have changed position.
|
||
|
this.fixPosition();
|
||
|
|
||
|
// Is autocomplete empty ?
|
||
|
var empty = $.trim(this.box.find(this.choiceSelector)).length === 0;
|
||
|
|
||
|
// If the inner container is empty or data has changed and there is no
|
||
|
// current pending request, rely on fetch(), which should show the
|
||
|
// autocomplete as soon as it's done fetching.
|
||
|
if ((this.hasChanged() || empty) && !this.xhr) {
|
||
|
this.fetch();
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// And actually, fetch() will call show() with the response
|
||
|
// body as argument.
|
||
|
if (html !== undefined) {
|
||
|
this.box.html(html);
|
||
|
this.fixPosition();
|
||
|
}
|
||
|
|
||
|
// Don't display empty boxes.
|
||
|
if (this.box.is(':empty')) {
|
||
|
if (this.box.is(':visible')) {
|
||
|
this.hide();
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
var current = this.box.find('.' + this.hilightClass);
|
||
|
var first = this.box.find(this.choiceSelector + ':first');
|
||
|
if (first && !current.length && this.autoHilightFirst) {
|
||
|
first.addClass(this.hilightClass);
|
||
|
}
|
||
|
|
||
|
// Show the inner and outer container only if necessary.
|
||
|
if (!this.box.is(':visible')) {
|
||
|
this.box.css('display', 'block');
|
||
|
this.fixPosition();
|
||
|
}
|
||
|
};
|
||
|
|
||
|
// This function is in charge of the opposite.
|
||
|
yourlabs.Autocomplete.prototype.hide = function() {
|
||
|
this.box.hide();
|
||
|
};
|
||
|
|
||
|
// This function is in charge of hilighting the right result from keyboard
|
||
|
// navigation.
|
||
|
yourlabs.Autocomplete.prototype.move = function(e) {
|
||
|
if (this.value === null)
|
||
|
this.value = this.getQuery();
|
||
|
|
||
|
// If the autocomplete should not be displayed then return.
|
||
|
if (this.value.length < this.minimumCharacters) return true;
|
||
|
|
||
|
// The current choice if any.
|
||
|
var current = this.box.find('.' + this.hilightClass);
|
||
|
|
||
|
// Prevent default browser behaviours on TAB and RETURN if a choice is
|
||
|
// hilighted.
|
||
|
if ($.inArray(e.keyCode, [9,13]) > -1 && current.length) {
|
||
|
e.preventDefault();
|
||
|
}
|
||
|
|
||
|
// If not KEY_UP or KEY_DOWN, then return.
|
||
|
// NOTE: with Webkit, both keyCode and charCode are set to 38/40 for &/(.
|
||
|
// charCode is 0 for arrow keys.
|
||
|
// Ref: http://stackoverflow.com/a/12046935/15690
|
||
|
var way;
|
||
|
if (e.keyCode === 38 && !e.charCode) way = 'up';
|
||
|
else if (e.keyCode === 40 && !e.charCode) way = 'down';
|
||
|
else return;
|
||
|
|
||
|
// The first and last choices. If the user presses down on the last
|
||
|
// choice, then the first one will be hilighted.
|
||
|
var first = this.box.find(this.choiceSelector + ':first');
|
||
|
var last = this.box.find(this.choiceSelector + ':last');
|
||
|
|
||
|
// The choice that should be hilighted after the move.
|
||
|
var target;
|
||
|
|
||
|
// The autocomplete must be shown so that the user sees what choice
|
||
|
// he is hilighting.
|
||
|
this.show();
|
||
|
|
||
|
// If a choice is currently hilighted:
|
||
|
if (current.length) {
|
||
|
if (way === 'up') {
|
||
|
// The target choice becomes the first previous choice.
|
||
|
target = current.prevAll(this.choiceSelector + ':first');
|
||
|
|
||
|
// If none, then the last choice becomes the target.
|
||
|
if (!target.length) target = last;
|
||
|
} else {
|
||
|
// The target choice becomes the first next** choice.
|
||
|
target = current.nextAll(this.choiceSelector + ':first');
|
||
|
|
||
|
// If none, then the first choice becomes the target.
|
||
|
if (!target.length) target = first;
|
||
|
}
|
||
|
|
||
|
// Trigger dehilightChoice on the currently hilighted choice.
|
||
|
this.input.trigger('dehilightChoice',
|
||
|
[current, this]);
|
||
|
} else {
|
||
|
target = way === 'up' ? last : first;
|
||
|
}
|
||
|
|
||
|
// Avoid moving the cursor in the input.
|
||
|
e.preventDefault();
|
||
|
|
||
|
// Trigger hilightChoice on the target choice.
|
||
|
this.input.trigger('hilightChoice',
|
||
|
[target, this]);
|
||
|
};
|
||
|
|
||
|
/*
|
||
|
Calculate and set the outer container's absolute positionning. We're copying
|
||
|
the system from Django admin's JS widgets like the date calendar, which means:
|
||
|
|
||
|
- the autocomplete box is an element appended to this.co,
|
||
|
-
|
||
|
*/
|
||
|
yourlabs.Autocomplete.prototype.fixPosition = function() {
|
||
|
var el = this.input.get(0);
|
||
|
|
||
|
var zIndex = this.input.parents().filter(function() {
|
||
|
return $(this).css('z-index') !== 'auto' && $(this).css('z-index') !== '0';
|
||
|
}).first().css('z-index');
|
||
|
|
||
|
var absolute_parent = this.input.parents().filter(function(){
|
||
|
return $(this).css('position') === 'absolute';
|
||
|
}).get(0);
|
||
|
|
||
|
var top = (findPosY(el) + this.input.outerHeight()) + 'px';
|
||
|
var left = findPosX(el) + 'px';
|
||
|
|
||
|
if(absolute_parent !== undefined){
|
||
|
var parentTop = findPosY(absolute_parent);
|
||
|
var parentLeft = findPosX(absolute_parent);
|
||
|
var inputBottom = findPosY(el) + this.input.outerHeight();
|
||
|
var inputLeft = findPosX(el);
|
||
|
top = (inputBottom - parentTop) + 'px';
|
||
|
left = (inputLeft - parentLeft) + 'px';
|
||
|
}
|
||
|
|
||
|
if (this.alignRight) {
|
||
|
left = (findPosX(el) + el.scrollLeft - (this.box.outerWidth() - this.input.outerWidth())) + 'px';
|
||
|
}
|
||
|
|
||
|
this.box.appendTo(this.container).css({
|
||
|
position: 'absolute',
|
||
|
minWidth: parseInt(this.input.outerWidth()),
|
||
|
top: top,
|
||
|
left: left,
|
||
|
zIndex: zIndex
|
||
|
});
|
||
|
};
|
||
|
|
||
|
// Proxy fetch(), with some sanity checks.
|
||
|
yourlabs.Autocomplete.prototype.refresh = function() {
|
||
|
// Set the new current value.
|
||
|
this.value = this.getQuery();
|
||
|
|
||
|
// If the input doesn't contain enought characters then abort, else fetch.
|
||
|
if (this.value.length < this.minimumCharacters)
|
||
|
this.hide();
|
||
|
else
|
||
|
this.fetch();
|
||
|
};
|
||
|
|
||
|
// Return true if the data for this query has changed from last query.
|
||
|
yourlabs.Autocomplete.prototype.hasChanged = function() {
|
||
|
for(var key in this.data) {
|
||
|
if (!(key in this.lastData) || this.data[key] !== this.lastData[key]) {
|
||
|
return true;
|
||
|
}
|
||
|
}
|
||
|
return false;
|
||
|
};
|
||
|
|
||
|
// Manage requests to this.url.
|
||
|
yourlabs.Autocomplete.prototype.fetch = function() {
|
||
|
// Add the current value to the data dict.
|
||
|
this.data[this.queryVariable] = this.value;
|
||
|
|
||
|
// Ensure that this request is different from the previous one
|
||
|
if (!this.hasChanged()) {
|
||
|
// Else show the same box again.
|
||
|
this.show();
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
this.lastData = {};
|
||
|
for(var key in this.data) {
|
||
|
this.lastData[key] = this.data[key];
|
||
|
}
|
||
|
|
||
|
// Abort any unsent requests.
|
||
|
if (this.xhr && this.xhr.readyState === 0) this.xhr.abort();
|
||
|
|
||
|
// Abort any request that we planned to make.
|
||
|
if (this.timeoutId) clearTimeout(this.timeoutId);
|
||
|
|
||
|
// Make an asynchronous GET request to this.url in this.xhrWait ms
|
||
|
this.timeoutId = setTimeout($.proxy(this.makeXhr, this), this.xhrWait);
|
||
|
};
|
||
|
|
||
|
// Wrapped ajax call to use with setTimeout in fetch().
|
||
|
yourlabs.Autocomplete.prototype.makeXhr = function() {
|
||
|
this.input.addClass('xhr-pending');
|
||
|
|
||
|
this.xhr = $.ajax(this.url, {
|
||
|
type: 'GET',
|
||
|
data: this.data,
|
||
|
complete: $.proxy(this.fetchComplete, this)
|
||
|
});
|
||
|
};
|
||
|
|
||
|
// Callback for the ajax response.
|
||
|
yourlabs.Autocomplete.prototype.fetchComplete = function(jqXHR, textStatus) {
|
||
|
if (this.xhr === jqXHR) {
|
||
|
// Current request finished.
|
||
|
this.xhr = false;
|
||
|
} else {
|
||
|
// Ignore response from earlier request.
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Current request done, nothing else pending.
|
||
|
this.input.removeClass('xhr-pending');
|
||
|
|
||
|
if (textStatus === 'abort') return;
|
||
|
this.show(jqXHR.responseText);
|
||
|
};
|
||
|
|
||
|
/*
|
||
|
The jQuery plugin that manages Autocomplete instances across the various
|
||
|
inputs. It is named 'yourlabsAutocomplete' rather than just 'autocomplete'
|
||
|
to live happily with other plugins that may define an autocomplete() jQuery
|
||
|
plugin.
|
||
|
|
||
|
It takes an array as argument, the array may contain any attribute or
|
||
|
function that should override the Autocomplete builtin. For example:
|
||
|
|
||
|
$('input#your-autocomplete').yourlabsAutocomplete({
|
||
|
url: '/some/url/',
|
||
|
hide: function() {
|
||
|
this.outerContainer
|
||
|
},
|
||
|
})
|
||
|
|
||
|
Also, it implements a simple identity map, which means that:
|
||
|
|
||
|
// First call for an input instanciates the Autocomplete instance
|
||
|
$('input#your-autocomplete').yourlabsAutocomplete({
|
||
|
url: '/some/url/',
|
||
|
});
|
||
|
|
||
|
// Other calls return the previously created Autocomplete instance
|
||
|
$('input#your-autocomplete').yourlabsAutocomplete().data = {
|
||
|
newData: $('#foo').val(),
|
||
|
}
|
||
|
|
||
|
To destroy an autocomplete, call yourlabsAutocomplete('destroy').
|
||
|
*/
|
||
|
$.fn.yourlabsAutocomplete = function(overrides) {
|
||
|
if (this.length < 1) {
|
||
|
// avoid crashing when called on a non existing element
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
overrides = overrides ? overrides : {};
|
||
|
var autocomplete = this.yourlabsRegistry('autocomplete');
|
||
|
|
||
|
if (overrides === 'destroy') {
|
||
|
if (autocomplete) {
|
||
|
autocomplete.destroy(this);
|
||
|
this.removeData('autocomplete');
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Disable the browser's autocomplete features on that input.
|
||
|
this.attr('autocomplete', 'off');
|
||
|
|
||
|
// If no Autocomplete instance is defined for this id, make one.
|
||
|
if (autocomplete === undefined) {
|
||
|
// Instanciate Autocomplete.
|
||
|
autocomplete = new yourlabs.Autocomplete(this);
|
||
|
|
||
|
// Extend the instance with data-autocomplete-* overrides
|
||
|
for (var key in this.data()) {
|
||
|
if (!key) continue;
|
||
|
if (key.substr(0, 12) !== 'autocomplete' || key === 'autocomplete')
|
||
|
continue;
|
||
|
var newKey = key.replace('autocomplete', '');
|
||
|
newKey = newKey.charAt(0).toLowerCase() + newKey.slice(1);
|
||
|
autocomplete[newKey] = this.data(key);
|
||
|
}
|
||
|
|
||
|
// Extend the instance with overrides.
|
||
|
autocomplete = $.extend(autocomplete, overrides);
|
||
|
|
||
|
if (!autocomplete.url) {
|
||
|
alert('Autocomplete needs a url !');
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
this.yourlabsRegistry('autocomplete', autocomplete);
|
||
|
|
||
|
// All set, call initialize().
|
||
|
autocomplete.initialize();
|
||
|
}
|
||
|
|
||
|
// Return the Autocomplete instance for this id from the registry.
|
||
|
return autocomplete;
|
||
|
};
|
||
|
|
||
|
// Binding some default behaviors.
|
||
|
$(document).ready(function() {
|
||
|
function removeHilightClass(e, choice, autocomplete) {
|
||
|
choice.removeClass(autocomplete.hilightClass);
|
||
|
}
|
||
|
$(document).bind('hilightChoice', function(e, choice, autocomplete) {
|
||
|
choice.addClass(autocomplete.hilightClass);
|
||
|
});
|
||
|
$(document).bind('dehilightChoice', removeHilightClass);
|
||
|
$(document).bind('selectChoice', removeHilightClass);
|
||
|
$(document).bind('selectChoice', function(e, choice, autocomplete) {
|
||
|
autocomplete.hide();
|
||
|
});
|
||
|
});
|
||
|
|
||
|
$(document).ready(function() {
|
||
|
/* Credit: django.contrib.admin (BSD) */
|
||
|
|
||
|
var showAddAnotherPopup = function(triggeringLink) {
|
||
|
var name = triggeringLink.attr( 'id' ).replace(/^add_/, '');
|
||
|
name = id_to_windowname(name);
|
||
|
href = triggeringLink.attr( 'href' );
|
||
|
|
||
|
if (href.indexOf('?') === -1) {
|
||
|
href += '?';
|
||
|
}
|
||
|
|
||
|
href += '&winName=' + name;
|
||
|
|
||
|
var height = 500;
|
||
|
var width = 800;
|
||
|
var left = (screen.width/2)-(width/2);
|
||
|
var top = (screen.height/2)-(height/2);
|
||
|
var win = window.open(href, name, 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=yes, resizable=yes, copyhistory=no, width='+width+', height='+height+', top='+top+', left='+left)
|
||
|
|
||
|
function removeOverlay() {
|
||
|
if (win.closed) {
|
||
|
$('#yourlabs_overlay').remove();
|
||
|
} else {
|
||
|
setTimeout(removeOverlay, 500);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
$('body').append('<div id="yourlabs_overlay"></div');
|
||
|
$('#yourlabs_overlay').click(function() {
|
||
|
win.close();
|
||
|
$(this).remove();
|
||
|
});
|
||
|
|
||
|
setTimeout(removeOverlay, 1500);
|
||
|
|
||
|
win.focus();
|
||
|
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
var dismissAddAnotherPopup = function(win, newId, newRepr) {
|
||
|
// newId and newRepr are expected to have previously been escaped by
|
||
|
newId = html_unescape(newId);
|
||
|
newRepr = html_unescape(newRepr);
|
||
|
var name = windowname_to_id(win.name);
|
||
|
var elem = document.getElementById(name);
|
||
|
|
||
|
if (elem) {
|
||
|
if ($(elem).is('select')) {
|
||
|
var o = new Option(newRepr, newId);
|
||
|
elem.options[elem.options.length] = o;
|
||
|
o.selected = true;
|
||
|
}
|
||
|
} else {
|
||
|
alert('Could not get input id for win ' + name);
|
||
|
}
|
||
|
|
||
|
win.close();
|
||
|
}
|
||
|
|
||
|
window.dismissAddAnotherPopup = dismissAddAnotherPopup
|
||
|
|
||
|
var html_unescape = function(text) {
|
||
|
// Unescape a string that was escaped using django.utils.html.escape.
|
||
|
text = text.replace(/</g, '');
|
||
|
text = text.replace(/"/g, '"');
|
||
|
text = text.replace(/'/g, '\'');
|
||
|
text = text.replace(/&/g, '&');
|
||
|
return text;
|
||
|
}
|
||
|
|
||
|
// IE doesn't accept periods or dashes in the window name, but the element IDs
|
||
|
// we use to generate popup window names may contain them, therefore we map them
|
||
|
// to allowed characters in a reversible way so that we can locate the correct
|
||
|
// element when the popup window is dismissed.
|
||
|
var id_to_windowname = function(text) {
|
||
|
text = text.replace(/\./g, '__dot__');
|
||
|
text = text.replace(/\-/g, '__dash__');
|
||
|
text = text.replace(/\[/g, '__braceleft__');
|
||
|
text = text.replace(/\]/g, '__braceright__');
|
||
|
return text;
|
||
|
}
|
||
|
|
||
|
var windowname_to_id = function(text) {
|
||
|
text = text.replace(/__dot__/g, '.');
|
||
|
text = text.replace(/__dash__/g, '-');
|
||
|
text = text.replace(/__braceleft__/g, '[');
|
||
|
text = text.replace(/__braceright__/g, ']');
|
||
|
return text;
|
||
|
}
|
||
|
|
||
|
$( '.autocomplete-add-another' ).show().click(function(e) {
|
||
|
e.preventDefault( );
|
||
|
showAddAnotherPopup( $( this ) );
|
||
|
});
|
||
|
});
|
||
|
|
||
|
if (window.yourlabs === undefined) window.yourlabs = {};
|
||
|
|
||
|
yourlabs.RemoteAutocompleteWidget = {
|
||
|
/*
|
||
|
The default deck getValue() implementation just returns the PK from the
|
||
|
choice HTML. RemoteAutocompleteWidget.getValue's implementation checks for
|
||
|
a url too. If a url is found, it will post to that url and expect the pk to
|
||
|
be in the response.
|
||
|
|
||
|
This is how autocomplete-light supports proposing values that are not there
|
||
|
in the database until user selection.
|
||
|
*/
|
||
|
getValue: function(choice) {
|
||
|
var value = choice.data('value');
|
||
|
|
||
|
if (typeof(value) === 'string' && isNaN(value) && value.match(/^https?:/)) {
|
||
|
$.ajax(this.autocompleteOptions.url, {
|
||
|
async: false,
|
||
|
traditional: true,
|
||
|
type: 'post',
|
||
|
data: {
|
||
|
'value': value
|
||
|
},
|
||
|
success: function(text, jqXHR, textStatus) {
|
||
|
value = text;
|
||
|
}
|
||
|
});
|
||
|
|
||
|
choice.data('value', value);
|
||
|
}
|
||
|
|
||
|
return value;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
$(document).bind('yourlabsWidgetReady', function() {
|
||
|
// Instanciate decks with RemoteAutocompleteWidget as override for all widgets with
|
||
|
// autocomplete 'remote'.
|
||
|
$('body').on('initialize', '.autocomplete-light-widget[data-bootstrap=rest_model]', function() {
|
||
|
$(this).yourlabsWidget(yourlabs.RemoteAutocompleteWidget);
|
||
|
});
|
||
|
});
|
||
|
|
||
|
/*
|
||
|
This script enables TextWidget, a widget for CharField that supports
|
||
|
autocomplete for comma-separated values.
|
||
|
|
||
|
It's organization is not final, there are a couple of things that are also used
|
||
|
in widget.js that will be re-factored probably in a script called lib.js.
|
||
|
|
||
|
The API however, is consistent with widget.js, and is not meant to change.
|
||
|
|
||
|
For now, the script is composed of these parts:
|
||
|
|
||
|
- a handful of jQuery extensions to ease treatment of comma separated values in
|
||
|
an input,
|
||
|
- yourlabs.TextWidget is stripped version of yourlabs.Widget, to handle the
|
||
|
behavior of a comma separated autocompleted input,
|
||
|
- yourlabsTextWidget jQuery extension which role is to manage TextWidget instances,
|
||
|
- yourlabsTextWidget initialization system, which supports dynamically added
|
||
|
autocompletes (ie. admin inlines)
|
||
|
*/
|
||
|
|
||
|
$.fn.getSelectionStart = function(){
|
||
|
var r;
|
||
|
// Written by jQuery4U
|
||
|
// http://www.jquery4u.com/snippets/6-jquery-cursor-functions/#.UDPQ9xXtFw8
|
||
|
if(this.lengh === 0) return -1;
|
||
|
input = this[0];
|
||
|
|
||
|
var pos = input.value.length;
|
||
|
|
||
|
if (input.createTextRange) {
|
||
|
if (window.getSelection) {
|
||
|
r = window.getSelection(); //IE11
|
||
|
} else {
|
||
|
r = document.selection.createRange().duplicate();
|
||
|
r.moveEnd('character', input.value.length);
|
||
|
}
|
||
|
if (r.text === '')
|
||
|
pos = input.value.length;
|
||
|
pos = input.value.lastIndexOf(r.text);
|
||
|
} else if (typeof(input.selectionStart) !== undefined)
|
||
|
pos = input.selectionStart;
|
||
|
|
||
|
return pos;
|
||
|
}
|
||
|
|
||
|
$.fn.getCursorPosition = function(){
|
||
|
// Written by jQuery4U
|
||
|
if (this.lengh === 0) return -1;
|
||
|
return $(this).getSelectionStart();
|
||
|
}
|
||
|
|
||
|
// Return the word on which the cursor is on.
|
||
|
//
|
||
|
// Consider the pipe "|" as an ASCII representation of the cursor, with such an
|
||
|
// input value::
|
||
|
//
|
||
|
// foo, bar|, baz
|
||
|
//
|
||
|
// getCursorWord would return 'bar'.
|
||
|
$.fn.getCursorWord = function() {
|
||
|
var value = $(this).val();
|
||
|
var positions = $(this).getCursorWordPositions();
|
||
|
return value.substring(positions[0], positions[1]);
|
||
|
}
|
||
|
|
||
|
// Return the offsets of the word on which the cursor is on.
|
||
|
//
|
||
|
// Consider the pipe "|" as an ASCII representation of the cursor, with such an
|
||
|
// input value::
|
||
|
//
|
||
|
// foo, bar|, baz
|
||
|
//
|
||
|
// getCursorWord would return [6, 8].
|
||
|
$.fn.getCursorWordPositions = function() {
|
||
|
var position = $(this).getCursorPosition();
|
||
|
var value = $(this).val();
|
||
|
|
||
|
// find start of word
|
||
|
for(var start=position - 1; start >= 0; start--) {
|
||
|
if (value[start] === ',') {
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
start = start < 0 ? 0 : start;
|
||
|
|
||
|
// find end of word
|
||
|
for(var end=position; end <= value.length - 1; end++) {
|
||
|
if (value[end] === ',') {
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
while(value[start] === ',' || value[start] === ' ') start++;
|
||
|
while(value[end] === ',' || value[end] === ' ') end--;
|
||
|
|
||
|
return [start, end + 1];
|
||
|
}
|
||
|
|
||
|
// TextWidget ties an input with an autocomplete.
|
||
|
yourlabs.TextWidget = function(input) {
|
||
|
this.input = input;
|
||
|
this.autocompleteOptions = {
|
||
|
getQuery: function() {
|
||
|
return this.input.getCursorWord();
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// The widget is in charge of managing its Autocomplete.
|
||
|
yourlabs.TextWidget.prototype.initializeAutocomplete = function() {
|
||
|
this.autocomplete = this.input.yourlabsAutocomplete(
|
||
|
this.autocompleteOptions);
|
||
|
|
||
|
// Add a class to ease css selection of autocompletes for widgets
|
||
|
this.autocomplete.box.addClass(
|
||
|
'autocomplete-light-text-widget');
|
||
|
};
|
||
|
|
||
|
// Bind Autocomplete.selectChoice signal to TextWidget.selectChoice()
|
||
|
yourlabs.TextWidget.prototype.bindSelectChoice = function() {
|
||
|
this.input.bind('selectChoice', function(e, choice) {
|
||
|
if (!choice.length)
|
||
|
return // placeholder: create choice here
|
||
|
|
||
|
$(this).yourlabsTextWidget().selectChoice(choice);
|
||
|
});
|
||
|
};
|
||
|
|
||
|
// Called when a choice is selected from the Autocomplete.
|
||
|
yourlabs.TextWidget.prototype.selectChoice = function(choice) {
|
||
|
var inputValue = this.input.val();
|
||
|
var choiceValue = this.getValue(choice);
|
||
|
var positions = this.input.getCursorWordPositions();
|
||
|
|
||
|
var newValue = inputValue.substring(0, positions[0]);
|
||
|
newValue += choiceValue;
|
||
|
newValue += inputValue.substring(positions[1]);
|
||
|
|
||
|
this.input.val(newValue);
|
||
|
this.input.focus();
|
||
|
}
|
||
|
|
||
|
// Return the value of an HTML choice, used to fill the input.
|
||
|
yourlabs.TextWidget.prototype.getValue = function(choice) {
|
||
|
return $.trim(choice.text());
|
||
|
}
|
||
|
|
||
|
// Initialize the widget.
|
||
|
yourlabs.TextWidget.prototype.initialize = function() {
|
||
|
this.initializeAutocomplete();
|
||
|
this.bindSelectChoice();
|
||
|
}
|
||
|
|
||
|
// Destroy the widget. Takes a widget element because a cloned widget element
|
||
|
// will be dirty, ie. have wrong .input and .widget properties.
|
||
|
yourlabs.TextWidget.prototype.destroy = function(input) {
|
||
|
input
|
||
|
.unbind('selectChoice')
|
||
|
.yourlabsAutocomplete('destroy');
|
||
|
}
|
||
|
|
||
|
// TextWidget factory, registry and destroyer, as jQuery extension.
|
||
|
$.fn.yourlabsTextWidget = function(overrides) {
|
||
|
var widget;
|
||
|
overrides = overrides ? overrides : {};
|
||
|
|
||
|
if (overrides === 'destroy') {
|
||
|
widget = this.data('widget');
|
||
|
if (widget) {
|
||
|
widget.destroy(this);
|
||
|
this.removeData('widget');
|
||
|
}
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if (this.data('widget') === undefined) {
|
||
|
// Instanciate the widget
|
||
|
widget = new yourlabs.TextWidget(this);
|
||
|
|
||
|
// Pares data-*
|
||
|
var data = this.data();
|
||
|
var dataOverrides = {
|
||
|
autocompleteOptions: {
|
||
|
// workaround a display bug
|
||
|
minimumCharacters: 0,
|
||
|
getQuery: function() {
|
||
|
// Override getQuery since we need the autocomplete to filter
|
||
|
// choices based on the word the cursor is on, rather than the full
|
||
|
// input value.
|
||
|
return this.input.getCursorWord();
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
for (var key in data) {
|
||
|
if (!key) continue;
|
||
|
|
||
|
if (key.substr(0, 12) === 'autocomplete') {
|
||
|
if (key === 'autocomplete') continue;
|
||
|
|
||
|
var newKey = key.replace('autocomplete', '');
|
||
|
newKey = newKey.replace(newKey[0], newKey[0].toLowerCase())
|
||
|
dataOverrides.autocompleteOptions[newKey] = data[key];
|
||
|
} else {
|
||
|
dataOverrides[key] = data[key];
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Allow attribute overrides
|
||
|
widget = $.extend(widget, dataOverrides);
|
||
|
|
||
|
// Allow javascript object overrides
|
||
|
widget = $.extend(widget, overrides);
|
||
|
|
||
|
this.data('widget', widget);
|
||
|
|
||
|
// Setup for usage
|
||
|
widget.initialize();
|
||
|
|
||
|
// Widget is ready
|
||
|
widget.input.attr('data-widget-ready', 1);
|
||
|
widget.input.trigger('widget-ready');
|
||
|
}
|
||
|
|
||
|
return this.data('widget');
|
||
|
}
|
||
|
|
||
|
$(document).ready(function() {
|
||
|
$('body').on('initialize', 'input[data-widget-bootstrap=text]', function() {
|
||
|
/*
|
||
|
Only setup autocompletes on inputs which have
|
||
|
data-widget-bootstrap=text, if you want to initialize some
|
||
|
autocompletes with custom code, then set
|
||
|
data-widget-boostrap=yourbootstrap or something like that.
|
||
|
*/
|
||
|
$(this).yourlabsTextWidget();
|
||
|
});
|
||
|
|
||
|
// Solid initialization, usage::
|
||
|
//
|
||
|
// $(document).bind('yourlabsTextWidgetReady', function() {
|
||
|
// $('body').on('initialize', 'input[data-widget-bootstrap=text]', function() {
|
||
|
// $(this).yourlabsTextWidget({
|
||
|
// yourCustomArgs: // ...
|
||
|
// })
|
||
|
// });
|
||
|
// });
|
||
|
$(document).trigger('yourlabsTextWidgetReady');
|
||
|
|
||
|
$('.autocomplete-light-text-widget:not([id*="__prefix__"])').each(function() {
|
||
|
$(this).trigger('initialize');
|
||
|
});
|
||
|
|
||
|
$(document).bind('DOMNodeInserted', function(e) {
|
||
|
var widget = $(e.target).find('.autocomplete-light-text-widget');
|
||
|
|
||
|
if (!widget.length) {
|
||
|
widget = $(e.target).is('.autocomplete-light-text-widget') ? $(e.target) : false;
|
||
|
|
||
|
if (!widget) {
|
||
|
return;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Ignore inserted autocomplete box elements.
|
||
|
if (widget.is('.yourlabs-autocomplete')) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Ensure that the newly added widget is clean, in case it was cloned.
|
||
|
widget.yourlabsWidget('destroy');
|
||
|
widget.find('input').yourlabsAutocomplete('destroy');
|
||
|
|
||
|
widget.trigger('initialize');
|
||
|
});
|
||
|
})
|
||
|
|
||
|
/*
|
||
|
Widget complements Autocomplete by enabling autocompletes to be used as
|
||
|
value holders. It looks very much like Autocomplete in its design. Thus, it
|
||
|
is recommended to read the source of Autocomplete first.
|
||
|
|
||
|
Widget behaves like the autocomplete in facebook profile page, which all
|
||
|
users should be able to use.
|
||
|
|
||
|
Behind the scenes, Widget maintains a normal hidden select which makes it
|
||
|
simple to play with on the server side like on the client side. If a value
|
||
|
is added and selected in the select element, then it is added to the deck,
|
||
|
and vice-versa.
|
||
|
|
||
|
It needs some elements, and established vocabulary:
|
||
|
|
||
|
- ".autocomplete-light-widget" element wraps all the HTML necessary for the
|
||
|
widget,
|
||
|
- ".deck" contains the list of selected choice(s) as HTML,
|
||
|
- "input" should be the text input that has the Autocomplete,
|
||
|
- "select" a (optionnaly multiple) select
|
||
|
- ".remove" a (preferabely hidden) element that contains a value removal
|
||
|
indicator, like an "X" sign or a trashcan icon, it is used to prefix every
|
||
|
children of the deck
|
||
|
- ".choice-template" a (preferabely hidden) element that contains the template
|
||
|
for choices which are added directly in the select, as they should be
|
||
|
copied in the deck,
|
||
|
|
||
|
To avoid complexity, this script relies on extra HTML attributes, and
|
||
|
particularely one called 'data-value'. Learn more about data attributes:
|
||
|
http://dev.w3.org/html5/spec/global-attributes.html#embedding-custom-non-visible-data-with-the-data-attributes
|
||
|
|
||
|
When a choice is selected from the Autocomplete, its element is cloned
|
||
|
and appended to the deck - "deck" contains "choices". It is important that the
|
||
|
choice elements of the autocomplete all contain a data-value attribute.
|
||
|
The value of data-value is used to fill the selected options in the hidden
|
||
|
select field.
|
||
|
|
||
|
If choices may not all have a data-value attribute, then you can
|
||
|
override Widget.getValue() to implement your own logic.
|
||
|
*/
|
||
|
|
||
|
// Our class will live in the yourlabs global namespace.
|
||
|
if (window.yourlabs === undefined) window.yourlabs = {};
|
||
|
|
||
|
/*
|
||
|
Instanciate a Widget.
|
||
|
*/
|
||
|
yourlabs.Widget = function(widget) {
|
||
|
// These attributes where described above.
|
||
|
this.widget = widget;
|
||
|
this.input = this.widget.find('input[data-autocomplete-url]');
|
||
|
this.select = this.widget.find('select');
|
||
|
this.deck = this.widget.find('.deck');
|
||
|
this.choiceTemplate = this.widget.find('.choice-template .choice');
|
||
|
|
||
|
// The number of choices that the user may select with this widget. Set 0
|
||
|
// for no limit. In the case of a foreign key you want to set it to 1.
|
||
|
this.maximumValues = 0;
|
||
|
|
||
|
// Clear input when choice made? 1 for yes, 0 for no
|
||
|
this.clearInputOnSelectChoice = '1';
|
||
|
}
|
||
|
|
||
|
// When a choice is selected from the autocomplete of this widget,
|
||
|
// getValue() is called to add and select the option in the select.
|
||
|
yourlabs.Widget.prototype.getValue = function(choice) {
|
||
|
return choice.attr('data-value');
|
||
|
};
|
||
|
|
||
|
// The widget is in charge of managing its Autocomplete.
|
||
|
yourlabs.Widget.prototype.initializeAutocomplete = function() {
|
||
|
this.autocomplete = this.input.yourlabsAutocomplete()
|
||
|
|
||
|
// Add a class to ease css selection of autocompletes for widgets
|
||
|
this.autocomplete.box.addClass('autocomplete-light-widget');
|
||
|
};
|
||
|
|
||
|
// Bind Autocomplete.selectChoice signal to Widget.selectChoice()
|
||
|
yourlabs.Widget.prototype.bindSelectChoice = function() {
|
||
|
this.input.bind('selectChoice', function(e, choice) {
|
||
|
if (!choice.length)
|
||
|
return // placeholder: create choice here
|
||
|
|
||
|
var widget = $(this).parents('.autocomplete-light-widget'
|
||
|
).yourlabsWidget();
|
||
|
|
||
|
widget.selectChoice(choice);
|
||
|
|
||
|
widget.widget.trigger('widgetSelectChoice', [choice, widget]);
|
||
|
});
|
||
|
};
|
||
|
|
||
|
// Called when a choice is selected from the Autocomplete.
|
||
|
yourlabs.Widget.prototype.selectChoice = function(choice) {
|
||
|
// Get the value for this choice.
|
||
|
var value = this.getValue(choice);
|
||
|
|
||
|
if (!value) {
|
||
|
if (window.console) console.log('yourlabs.Widget.getValue failed');
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
this.freeDeck();
|
||
|
this.addToDeck(choice, value);
|
||
|
this.addToSelect(choice, value);
|
||
|
|
||
|
var index = $(':input:visible').index(this.input);
|
||
|
this.resetDisplay();
|
||
|
|
||
|
if (this.clearInputOnSelectChoice === '1') {
|
||
|
this.input.val('');
|
||
|
this.autocomplete.value = '';
|
||
|
}
|
||
|
|
||
|
if (this.input.is(':visible')) {
|
||
|
this.input.focus();
|
||
|
} else {
|
||
|
var next = $(':input:visible:eq('+ index +')');
|
||
|
next.focus();
|
||
|
}
|
||
|
|
||
|
if (! this.select.is('[multiple]')) {
|
||
|
this.input.prop('disabled', true);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Unselect a value if the maximum number of selected values has been
|
||
|
// reached.
|
||
|
yourlabs.Widget.prototype.freeDeck = function() {
|
||
|
var slots = this.maximumValues - this.deck.children().length;
|
||
|
|
||
|
if (this.maximumValues && slots < 1) {
|
||
|
// We'll remove the first choice which is supposed to be the oldest
|
||
|
var choice = $(this.deck.children()[0]);
|
||
|
|
||
|
this.deselectChoice(choice);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Empty the search input and hide it if maximumValues has been reached.
|
||
|
yourlabs.Widget.prototype.resetDisplay = function() {
|
||
|
var selected = this.select.find('option:selected').length;
|
||
|
|
||
|
if (this.maximumValues && selected === this.maximumValues) {
|
||
|
this.input.hide();
|
||
|
} else {
|
||
|
this.input.show();
|
||
|
}
|
||
|
|
||
|
this.deck.show();
|
||
|
|
||
|
// Also fix the position if the autocomplete is shown.
|
||
|
if (this.autocomplete.box.is(':visible')) this.autocomplete.fixPosition();
|
||
|
}
|
||
|
|
||
|
yourlabs.Widget.prototype.deckChoiceHtml = function(choice, value) {
|
||
|
var deckChoice = choice.clone();
|
||
|
|
||
|
this.addRemove(deckChoice);
|
||
|
|
||
|
return deckChoice;
|
||
|
}
|
||
|
|
||
|
yourlabs.Widget.prototype.optionChoice = function(option) {
|
||
|
var optionChoice = this.choiceTemplate.clone();
|
||
|
|
||
|
var target = optionChoice.find('.append-option-html');
|
||
|
|
||
|
if (target.length) {
|
||
|
target.append(option.html());
|
||
|
} else {
|
||
|
optionChoice.html(option.html());
|
||
|
}
|
||
|
|
||
|
return optionChoice;
|
||
|
}
|
||
|
|
||
|
yourlabs.Widget.prototype.addRemove = function(choices) {
|
||
|
var removeTemplate = this.widget.find('.remove:last')
|
||
|
.clone().css('display', 'inline-block');
|
||
|
|
||
|
var target = choices.find('.prepend-remove');
|
||
|
|
||
|
if (target.length) {
|
||
|
target.prepend(removeTemplate);
|
||
|
} else {
|
||
|
// Add the remove icon to each choice
|
||
|
choices.prepend(removeTemplate);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Add a selected choice of a given value to the deck.
|
||
|
yourlabs.Widget.prototype.addToDeck = function(choice, value) {
|
||
|
var existing_choice = this.deck.find('[data-value="'+value+'"]');
|
||
|
|
||
|
// Avoid duplicating choices in the deck.
|
||
|
if (!existing_choice.length) {
|
||
|
var deckChoice = this.deckChoiceHtml(choice);
|
||
|
|
||
|
// In case getValue() actually **created** the value, for example
|
||
|
// with a post request.
|
||
|
deckChoice.attr('data-value', value);
|
||
|
|
||
|
this.deck.append(deckChoice);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Add a selected choice of a given value to the deck.
|
||
|
yourlabs.Widget.prototype.addToSelect = function(choice, value) {
|
||
|
var option = this.select.find('option[value="'+value+'"]');
|
||
|
|
||
|
if (! option.length) {
|
||
|
this.select.append(
|
||
|
'<option selected="selected" value="'+ value +'"></option>');
|
||
|
option = this.select.find('option[value="'+value+'"]');
|
||
|
}
|
||
|
|
||
|
option.attr('selected', 'selected');
|
||
|
|
||
|
this.select.trigger('change');
|
||
|
this.updateAutocompleteExclude();
|
||
|
}
|
||
|
|
||
|
// Called when the user clicks .remove in a deck choice.
|
||
|
yourlabs.Widget.prototype.deselectChoice = function(choice) {
|
||
|
var value = this.getValue(choice);
|
||
|
|
||
|
this.select.find('option[value="'+value+'"]').remove();
|
||
|
this.select.trigger('change');
|
||
|
|
||
|
choice.remove();
|
||
|
|
||
|
if (this.deck.children().length === 0) {
|
||
|
this.deck.hide();
|
||
|
}
|
||
|
|
||
|
this.updateAutocompleteExclude();
|
||
|
this.resetDisplay();
|
||
|
|
||
|
this.input.prop('disabled', false);
|
||
|
|
||
|
this.widget.trigger('widgetDeselectChoice', [choice, this]);
|
||
|
};
|
||
|
|
||
|
yourlabs.Widget.prototype.updateAutocompleteExclude = function() {
|
||
|
var widget = this;
|
||
|
var choices = this.deck.find(this.autocomplete.choiceSelector);
|
||
|
|
||
|
this.autocomplete.data.exclude = $.map(choices, function(choice) {
|
||
|
return widget.getValue($(choice));
|
||
|
});
|
||
|
}
|
||
|
|
||
|
yourlabs.Widget.prototype.initialize = function() {
|
||
|
this.initializeAutocomplete();
|
||
|
|
||
|
// Working around firefox tempering form values after reload
|
||
|
var widget = this;
|
||
|
this.deck.find(this.autocomplete.choiceSelector).each(function() {
|
||
|
var value = widget.getValue($(this));
|
||
|
var option = widget.select.find('option[value="'+value+'"]');
|
||
|
if (!option.prop('selected')) option.prop('selected', true);
|
||
|
});
|
||
|
|
||
|
var choices = this.deck.find(
|
||
|
this.input.yourlabsAutocomplete().choiceSelector);
|
||
|
|
||
|
this.addRemove(choices);
|
||
|
this.resetDisplay();
|
||
|
|
||
|
if (widget.select.val() && ! this.select.is('[multiple]')) {
|
||
|
this.input.prop('disabled', true);
|
||
|
}
|
||
|
|
||
|
this.bindSelectChoice();
|
||
|
}
|
||
|
|
||
|
// Destroy the widget. Takes a widget element because a cloned widget element
|
||
|
// will be dirty, ie. have wrong .input and .widget properties.
|
||
|
yourlabs.Widget.prototype.destroy = function(widget) {
|
||
|
widget.find('input')
|
||
|
.unbind('selectChoice')
|
||
|
.yourlabsAutocomplete('destroy');
|
||
|
}
|
||
|
|
||
|
// Get or create or destroy a widget instance.
|
||
|
//
|
||
|
// On first call, yourlabsWidget() will instanciate a widget applying all
|
||
|
// passed overrides.
|
||
|
//
|
||
|
// On later calls, yourlabsWidget() will return the previously created widget
|
||
|
// instance, which is stored in widget.data('widget').
|
||
|
//
|
||
|
// Calling yourlabsWidget('destroy') will destroy the widget. Useful if the
|
||
|
// element was blindly cloned with .clone(true) for example.
|
||
|
$.fn.yourlabsWidget = function(overrides) {
|
||
|
overrides = overrides ? overrides : {};
|
||
|
|
||
|
var widget = this.yourlabsRegistry('widget');
|
||
|
|
||
|
if (overrides === 'destroy') {
|
||
|
if (widget) {
|
||
|
widget.destroy(this);
|
||
|
this.removeData('widget');
|
||
|
}
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if (widget === undefined) {
|
||
|
// Instanciate the widget
|
||
|
widget = new yourlabs.Widget(this);
|
||
|
|
||
|
// Extend the instance with data-widget-* overrides
|
||
|
for (var key in this.data()) {
|
||
|
if (!key) continue;
|
||
|
if (key.substr(0, 6) !== 'widget' || key === 'widget') continue;
|
||
|
var newKey = key.replace('widget', '');
|
||
|
newKey = newKey.charAt(0).toLowerCase() + newKey.slice(1);
|
||
|
widget[newKey] = this.data(key);
|
||
|
}
|
||
|
|
||
|
// Allow javascript object overrides
|
||
|
widget = $.extend(widget, overrides);
|
||
|
|
||
|
$(this).yourlabsRegistry('widget', widget);
|
||
|
|
||
|
// Setup for usage
|
||
|
widget.initialize();
|
||
|
|
||
|
// Widget is ready
|
||
|
widget.widget.attr('data-widget-ready', 1);
|
||
|
widget.widget.trigger('widget-ready');
|
||
|
}
|
||
|
|
||
|
return widget;
|
||
|
}
|
||
|
|
||
|
$(document).ready(function() {
|
||
|
$('body').on('initialize', '.autocomplete-light-widget[data-widget-bootstrap=normal]', function() {
|
||
|
/*
|
||
|
Only setup widgets which have data-widget-bootstrap=normal, if you want to
|
||
|
initialize some Widgets with custom code, then set
|
||
|
data-widget-boostrap=yourbootstrap or something like that.
|
||
|
*/
|
||
|
$(this).yourlabsWidget();
|
||
|
});
|
||
|
|
||
|
// Call Widget.deselectChoice when .remove is clicked
|
||
|
$('body').on('click', '.autocomplete-light-widget .deck .remove', function() {
|
||
|
var widget = $(this).parents('.autocomplete-light-widget'
|
||
|
).yourlabsWidget();
|
||
|
|
||
|
var selector = widget.input.yourlabsAutocomplete().choiceSelector;
|
||
|
var choice = $(this).parents(selector);
|
||
|
|
||
|
widget.deselectChoice(choice);
|
||
|
});
|
||
|
|
||
|
// Solid initialization, usage:
|
||
|
//
|
||
|
//
|
||
|
// $(document).bind('yourlabsWidgetReady', function() {
|
||
|
// $('.your.autocomplete-light-widget').on('initialize', function() {
|
||
|
// $(this).yourlabsWidget({
|
||
|
// yourCustomArgs: // ...
|
||
|
// })
|
||
|
// });
|
||
|
// });
|
||
|
$(document).trigger('yourlabsWidgetReady');
|
||
|
|
||
|
$('.autocomplete-light-widget:not([id*="__prefix__"])').each(function() {
|
||
|
$(this).trigger('initialize');
|
||
|
});
|
||
|
|
||
|
$(document).bind('DOMNodeInserted', function(e) {
|
||
|
/*
|
||
|
Support values added directly in the select via js (ie. choices created in
|
||
|
modal or popup).
|
||
|
|
||
|
For this, we listen to DOMNodeInserted and intercept insert of <option> nodes.
|
||
|
|
||
|
The reason for that is that change is not triggered when options are
|
||
|
added like this:
|
||
|
|
||
|
$('select#id-dependencies').append(
|
||
|
'<option value="9999" selected="selected">blabla</option>')
|
||
|
*/
|
||
|
var widget;
|
||
|
|
||
|
if ($(e.target).is('option')) { // added an option ?
|
||
|
widget = $(e.target).parents('.autocomplete-light-widget');
|
||
|
|
||
|
if (!widget.length) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
widget = widget.yourlabsWidget();
|
||
|
var option = $(e.target);
|
||
|
var value = option.attr('value');
|
||
|
var choice = widget.deck.find('[data-value="'+value+'"]');
|
||
|
|
||
|
if (!choice.length) {
|
||
|
var deckChoice = widget.optionChoice(option);
|
||
|
|
||
|
deckChoice.attr('data-value', value);
|
||
|
|
||
|
widget.selectChoice(deckChoice);
|
||
|
}
|
||
|
} else { // added a widget ?
|
||
|
var notReady = '.autocomplete-light-widget:not([data-widget-ready])'
|
||
|
widget = $(e.target).find(notReady);
|
||
|
|
||
|
if (!widget.length) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Ignore inserted autocomplete box elements.
|
||
|
if (widget.is('.yourlabs-autocomplete')) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Ensure that the newly added widget is clean, in case it was
|
||
|
// cloned with data.
|
||
|
widget.yourlabsWidget('destroy');
|
||
|
widget.find('input').yourlabsAutocomplete('destroy');
|
||
|
|
||
|
// added a widget: initialize the widget.
|
||
|
widget.trigger('initialize');
|
||
|
}
|
||
|
});
|
||
|
|
||
|
var ie = yourlabs.getInternetExplorerVersion();
|
||
|
if (ie !== -1 && ie < 9) {
|
||
|
observe = [
|
||
|
'.autocomplete-light-widget:not([data-yourlabs-skip])',
|
||
|
'.autocomplete-light-widget option:not([data-yourlabs-skip])'
|
||
|
].join();
|
||
|
$(observe).attr('data-yourlabs-skip', 1);
|
||
|
|
||
|
var ieDOMNodeInserted = function() {
|
||
|
// http://msdn.microsoft.com/en-us/library/ms536957
|
||
|
$(observe).each(function() {
|
||
|
$(document).trigger(jQuery.Event('DOMNodeInserted', {target: $(this)}));
|
||
|
$(this).attr('data-yourlabs-skip', 1);
|
||
|
});
|
||
|
|
||
|
setTimeout(ieDOMNodeInserted, 500);
|
||
|
}
|
||
|
setTimeout(ieDOMNodeInserted, 500);
|
||
|
}
|
||
|
|
||
|
});
|