SimpleAutocomplete/hinter.js

529 lines
16 KiB
JavaScript

/* Simple autocomplete for text inputs, with the support for multiple selection.
Homepage: http://yourcmc.ru/wiki/SimpleAutocomplete
(c) Vitaliy Filippov 2011
Usage:
Include hinter.css, hinter.js on your page. Then write:
var hint = new SimpleAutocomplete(input, dataLoader, multipleDelimiter, onChangeListener, maxHeight, emptyText, allowHTML);
Parameters:
input
The input, either id or DOM element reference (the input must have an id anyway).
dataLoader(hint, value)
Callback which should load autocomplete options and then call
hint.replaceItems([ [ name, value ], [ name, value ], ... ])
'hint' parameter will be this autocompleter object, and the guess
should be done based on 'value' parameter (string).
Optional parameters:
multipleDelimiter
Pass a delimiter string (for example ',' or ';') to enable multiple selection.
Item values cannot have leading or trailing whitespace. Input value will consist
of selected item values separated by this delimiter plus single space.
dataLoader should handle it's 'value' parameter accordingly in this case,
because it will be just the raw value of the input, probably with incomplete
item or items, typed by the user.
onChangeListener(hint, index)
Callback which is called when input value is changed using this dropdown.
index is the number of element which selection is changed, starting with 0.
It must be used instead of normal 'onchange' event.
maxHeight
Maximum hint dropdown height in pixels
emptyText
Text to show when dataLoader returns no options.
If emptyText === false, the hint will be hidden instead of showing text.
allowHTML
If true, HTML code will be allowed in option names.
*/
var SimpleAutocomplete = function(input, dataLoader, multipleDelimiter, onChangeListener, maxHeight, emptyText, allowHTML)
{
if (typeof(input) == 'string')
input = document.getElementById(input);
if (emptyText === undefined)
emptyText = 'No items found';
// Parameters
var self = this;
self.input = input;
self.multipleDelimiter = multipleDelimiter;
self.dataLoader = dataLoader;
self.onChangeListener = onChangeListener;
self.maxHeight = maxHeight;
self.emptyText = emptyText;
self.allowHTML = allowHTML;
// Variables
self.items = [];
self.skipHideCounter = 0;
self.selectedIndex = -1;
self.id = input.id;
self.disabled = false;
// Initialiser
var init = function()
{
var e = self.input;
var p = getOffset(e);
// Create hint layer
var t = self.hintLayer = document.createElement('div');
t.className = 'hintLayer';
t.style.display = 'none';
t.style.position = 'absolute';
t.style.top = (p.top+e.offsetHeight) + 'px';
t.style.zIndex = 1000;
t.style.left = p.left + 'px';
if (self.maxHeight)
{
t.style.overflowY = 'scroll';
try { t.style.overflow = '-moz-scrollbars-vertical'; } catch(exc) {}
t.style.maxHeight = self.maxHeight+'px';
if (!t.style.maxHeight)
self.scriptMaxHeight = true;
}
document.body.appendChild(t);
// Remember instance
e.SimpleAutocomplete_input = self;
t.SimpleAutocomplete_layer = self;
SimpleAutocomplete.SimpleAutocompletes.push(self);
// Set event listeners
var ie_opera = navigator.userAgent.match('MSIE') || navigator.userAgent.match('Opera');
if (ie_opera)
addListener(e, 'keydown', self.onKeyPress);
else
{
addListener(e, 'keydown', self.onKeyDown);
addListener(e, 'keypress', self.onKeyPress);
}
addListener(e, 'keyup', self.onKeyUp);
addListener(e, 'change', self.onChange);
addListener(e, 'focus', self.onInputFocus);
addListener(e, 'blur', self.onInputBlur);
self.onChange();
};
// obj = [ [ name, value, disabled ], [ name, value ], ... ]
self.replaceItems = function(items)
{
self.hintLayer.innerHTML = '';
self.hintLayer.scrollTop = 0;
self.items = [];
if (!items || items.length == 0)
{
if (self.emptyText)
{
var d = document.createElement('div');
d.className = 'hintEmptyText';
d.innerHTML = self.emptyText;
self.hintLayer.appendChild(d);
}
else
self.disable();
return;
}
self.enable();
var h = {};
if (self.multipleDelimiter)
{
var old = self.input.value.split(self.multipleDelimiter);
for (var i = 0; i < old.length; i++)
h[old[i].trim()] = true;
}
for (var i in items)
self.hintLayer.appendChild(self.makeItem(items[i][0], items[i][1], h[items[i][1]]));
if (self.maxHeight)
{
self.hintLayer.style.height =
(self.hintLayer.scrollHeight > self.maxHeight
? self.maxHeight : self.hintLayer.scrollHeight) + 'px';
}
};
// Create a drop-down list item, include checkbox if self.multipleDelimiter is true
self.makeItem = function(name, value, checked)
{
var d = document.createElement('div');
d.id = self.id+'_item_'+self.items.length;
d.className = 'hintItem';
d.title = value;
if (self.allowHTML)
d.innerHTML = name;
if (self.multipleDelimiter)
{
var c = document.createElement('input');
c.type = 'checkbox';
c.id = self.id+'_check_'+self.items.length;
c.checked = checked && true;
c.value = value;
if (d.childNodes.length)
d.insertBefore(c, d.firstChild);
else
d.appendChild(c);
addListener(c, 'click', self.preventCheck);
}
if (!self.allowHTML)
d.appendChild(document.createTextNode(name));
addListener(d, 'mouseover', self.onItemMouseOver);
addListener(d, 'mousedown', self.onItemClick);
self.items.push([name, value, checked]);
return d;
};
// Prevent default action on checkbox
self.preventCheck = function(ev)
{
ev = ev||window.event;
return stopEvent(ev, false, true);
};
// Handle item mouse over
self.onItemMouseOver = function()
{
return self.highlightItem(this);
};
// Handle item clicks
self.onItemClick = function(ev)
{
self.selectItem(parseInt(this.id.substr(self.id.length+6)));
return true;
};
// Move highlight forward or back by 'by' items (integer)
self.moveHighlight = function(by)
{
var n = self.selectedIndex+by;
if (n < 0)
n = 0;
var elem = document.getElementById(self.id+'_item_'+n);
if (!elem)
return true;
return self.highlightItem(elem);
};
// Make item 'elem' active (highlighted)
self.highlightItem = function(elem)
{
if (self.selectedIndex >= 0)
{
var c = self.getItem();
if (c)
c.className = 'hintItem';
}
self.selectedIndex = parseInt(elem.id.substr(self.id.length+6));
elem.className = 'hintActiveItem';
return false;
};
// Get index'th item, or current when index is null
self.getItem = function(index)
{
if (index == null)
index = self.selectedIndex;
if (index < 0)
return null;
return document.getElementById(self.id+'_item_'+self.selectedIndex);
};
// Select index'th item - change the input value and hide the hint if not a multi-select
self.selectItem = function(index)
{
if (!self.multipleDelimiter)
{
self.input.value = self.items[index][1];
self.hide();
}
else
{
document.getElementById(self.id+'_check_'+index).checked = self.items[index][2] = !self.items[index][2];
var old = self.input.value.split(self.multipleDelimiter);
for (var i = 0; i < old.length; i++)
old[i] = old[i].trim();
if (!self.items[index][2])
{
for (var i = old.length-1; i >= 0; i--)
if (old[i] == self.items[index][1])
old.splice(i, 1);
self.input.value = old.join(self.multipleDelimiter+' ');
}
else
{
var h = {};
for (var i = 0; i < self.items.length; i++)
if (self.items[i][2])
h[self.items[i][1]] = true;
var nl = [];
for (var i = 0; i < old.length; i++)
{
if (h[old[i]])
{
delete h[old[i]];
nl.push(old[i]);
}
}
for (var i = 0; i < self.items.length; i++)
if (self.items[i][2] && h[self.items[i][1]])
nl.push(self.items[i][1]);
self.input.value = nl.join(self.multipleDelimiter+' ');
}
}
self.curValue = self.input.value;
if (self.onChangeListener)
self.onChangeListener(self, index);
};
// Handle user input, load new items
self.onChange = function()
{
var v = self.input.value.trim();
if (v != self.curValue)
{
self.curValue = v;
self.dataLoader(self, v);
}
return true;
};
// Handle Enter key presses, cancel handling of arrow keys
self.onKeyUp = function(ev)
{
ev = ev||window.event;
if (ev.keyCode != 10 && ev.keyCode != 13)
self.show();
if (ev.keyCode == 38 || ev.keyCode == 40 || ev.keyCode == 10 || ev.keyCode == 13)
{
if (self.hintLayer.style.display == '')
return stopEvent(ev, true, true);
else
return true;
}
self.onChange();
return true;
};
// Cancel handling of Enter key
self.onKeyDown = function(ev)
{
ev = ev||window.event;
if (ev.keyCode == 10 || ev.keyCode == 13)
{
if (self.hintLayer.style.display == '')
return stopEvent(ev, true, true);
else
return true;
}
return true;
};
// Handle arrow keys and Enter
self.onKeyPress = function(ev)
{
if (self.hintLayer.style.display == 'none')
return true;
ev = ev||window.event;
if (ev.keyCode == 38) // up
self.moveHighlight(-1);
else if (ev.keyCode == 40) // down
self.moveHighlight(1);
else if (ev.keyCode == 10 || ev.keyCode == 13) // enter
{
if (self.selectedIndex >= 0)
self.selectItem(self.selectedIndex);
return stopEvent(ev, true, true);
}
else
return true;
// scrolling
if (self.selectedIndex >= 0)
{
var c = self.getItem();
var t = self.hintLayer;
var ct = getOffset(c).top + t.scrollTop - t.style.top.substr(0, t.style.top.length-2);
var ch = c.scrollHeight;
if (ct+ch-t.offsetHeight > t.scrollTop)
t.scrollTop = ct+ch-t.offsetHeight;
else if (ct < t.scrollTop)
t.scrollTop = ct;
}
return stopEvent(ev, true, true);
};
// Called when input receives focus
self.onInputFocus = function()
{
self.show();
self.input.autocomplete = 'off';
self.hasFocus = true;
return true;
};
// Called when input loses focus
self.onInputBlur = function()
{
self.hide();
self.input.autocomplete = 'on';
self.hasFocus = false;
return true;
};
// Hide hinter
self.hide = function()
{
if (!self.skipHideCounter)
self.hintLayer.style.display = 'none';
else
self.skipHideCounter = 0;
};
// Show hinter
self.show = function()
{
if (!self.disabled)
{
var p = getOffset(self.input);
self.hintLayer.style.top = (p.top+self.input.offsetHeight) + 'px';
self.hintLayer.style.left = p.left + 'px';
self.hintLayer.style.display = '';
}
};
// Disable hinter, for the case when there is no items and no empty text
self.disable = function()
{
self.disabled = true;
self.hide();
};
// Enable hinter
self.enable = function()
{
var show = self.disabled;
self.disabled = false;
if (show)
self.show();
}
// *** Call initialise ***
init();
};
// Global variable
SimpleAutocomplete.SimpleAutocompletes = [];
// Global mousedown handler, hides dropdowns when clicked outside
SimpleAutocomplete.GlobalMouseDown = function(ev)
{
var target = ev.target || ev.srcElement;
var esh;
while (target)
{
esh = target.SimpleAutocomplete_input;
if (esh)
break;
else if (target.SimpleAutocomplete_layer)
{
if (target.SimpleAutocomplete_layer.hasFocus)
target.SimpleAutocomplete_layer.skipHideCounter++;
return true;
}
target = target.parentNode;
}
for (var i in SimpleAutocomplete.SimpleAutocompletes)
if (SimpleAutocomplete.SimpleAutocompletes[i] != esh)
SimpleAutocomplete.SimpleAutocompletes[i].hide();
return true;
};
//// UTILITY FUNCTIONS ////
// You can delete this section if you already have them somewhere in your scripts //
// Cross-browser adding of event listeners
var addListener = function()
{
if (window.addEventListener)
{
return function(el, type, fn) { el.addEventListener(type, fn, false); };
}
else if (window.attachEvent)
{
return function(el, type, fn) {
var f = function() { return fn.call(el, window.event); };
el.attachEvent('on'+type, f);
};
}
else
{
return function(el, type, fn) { element['on'+type] = fn; }
}
}();
// Cancel event bubbling and/or default action
var stopEvent = function(ev, cancelBubble, preventDefault)
{
if (cancelBubble)
{
if (ev.stopPropagation)
ev.stopPropagation();
else
ev.cancelBubble = true;
}
if (preventDefault && ev.preventDefault)
ev.preventDefault();
ev.returnValue = !preventDefault;
return !preventDefault;
};
// Remove leading and trailing whitespace
if (!String.prototype.trim)
{
String.prototype.trim = function()
{
return this.replace(/^\s\s*/, '').replace(/\s\s*$/, '');
};
}
// Get element position, relative to the top-left corner of page
var getOffset = function(elem)
{
if (elem.getBoundingClientRect)
return getOffsetRect(elem);
else
return getOffsetSum(elem);
};
// Get element position using getBoundingClientRect()
var getOffsetRect = function(elem)
{
var box = elem.getBoundingClientRect();
var body = document.body;
var docElem = document.documentElement;
var scrollTop = window.pageYOffset || docElem.scrollTop || body.scrollTop;
var scrollLeft = window.pageXOffset || docElem.scrollLeft || body.scrollLeft;
var clientTop = docElem.clientTop || body.clientTop || 0;
var clientLeft = docElem.clientLeft || body.clientLeft || 0;
var top = box.top + scrollTop - clientTop;
var left = box.left + scrollLeft - clientLeft;
return { top: Math.round(top), left: Math.round(left) };
};
// Get element position using sum of offsetTop/offsetLeft
var getOffsetSum = function(elem)
{
var top = 0, left = 0;
while(elem)
{
top = top + parseInt(elem.offsetTop);
left = left + parseInt(elem.offsetLeft);
elem = elem.offsetParent;
}
return { top: top, left: left };
};
//// END UTILITY FUNCTIONS ////
// Set global mousedown listener
addListener(window, 'load', function() { addListener(document, 'mousedown', SimpleAutocomplete.GlobalMouseDown) });