/* Simple autocomplete for text inputs, with the support for multiple selection. Homepage: http://yourcmc.ru/wiki/SimpleAutocomplete License: MPL 2.0+ (http://www.mozilla.org/MPL/2.0/) (c) Vitaliy Filippov 2011-2012 Usage: Include hinter.css, hinter.js on your page. Then write: var hint = new SimpleAutocomplete(input, dataLoader, params); Parameters: input The input, either id or DOM element reference (the input must have an id anyway). dataLoader(hint, value[, more]) Callback which should load autocomplete options and then call: hint.replaceItems(newOptions, append) newOptions is [ [ name, value[, disabled ] ], [ name, value ], ... ] append=(more>0) Callback parameters: hint This SimpleAutocomplete object value The string guess should be done based on more This is optional and means the times user has triggered 'load more items'. i.e. if the original list had 10 items (more=0), after first 'more' click user would expect 20 items (more=1), and etc. See also moreMarker option below. params attribute is an object with 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. prompt HTML text to be displayed before a non-empty option list. Empty by default. delay If this is set to a non-zero value, the autocompleter does no more than 1 request in each delay milliseconds. moreMarker The server supplying hint options usually limits their count. But it's not always convenient having to type additional characters for narrowing down the selection. Optionally you can supply additional item with special value '#MORE' (or 'moreMarker' option value if it is there) at the end of the list, and when it will be clicked, SimpleAutocomplete will issue another request to dataLoader with incremented 'more' parameter. You can also set moreMarker to false to disable this feature. Other methods: hint.remove() -- Destroy the instance hint.enableItem(index, enabled) -- Enable or disable an item */ // *** Constructor *** var SimpleAutocomplete = function(input, dataLoader, params) { if (typeof(input) == 'string') input = document.getElementById(input); if (!params) params = {}; // Parameters this.input = input; this.dataLoader = dataLoader; this.multipleDelimiter = params.multipleDelimiter; this.onChangeListener = params.onChangeListener; this.maxHeight = params.maxHeight; this.emptyText = params.emptyText; this.allowHTML = params.allowHTML; this.prompt = params.prompt; this.delay = params.delay; this.moreMarker = params.moreMarker; // Default values if (this.moreMarker === undefined) this.moreMarker = '#MORE'; if (this.delay === undefined) this.delay = 300; if (this.emptyText === undefined) this.emptyText = false; // Variables this.origAutocomplete = input.autocomplete; this.more = 0; this.timer = null; this.closure = []; this.items = []; this.skipHideCounter = 0; this.selectedIndex = -1; this.disabled = false; // *** Call initialise *** this.init(); }; // *** Instance methods *** // Initialiser SimpleAutocomplete.prototype.init = function() { var e = this.input; var l = SimpleAutocomplete.SimpleAutocompletes; this.id = this.input.id + l.length; l.push(this); var p = getOffset(e); // Create hint layer var t = this.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 (this.maxHeight) { t.style.overflowY = 'scroll'; try { t.style.overflow = '-moz-scrollbars-vertical'; } catch(exc) {} t.style.maxHeight = this.maxHeight+'px'; if (!t.style.maxHeight) this.scriptMaxHeight = true; } document.body.appendChild(t); // Remember instance e.SimpleAutocomplete_input = this; t.SimpleAutocomplete_layer = this; // Set event listeners var self = this; var ie_opera = navigator.userAgent.match('MSIE') || navigator.userAgent.match('Opera'); if (ie_opera) this.addRmListener('keydown', function(ev) { return self.onKeyPress(ev); }); else { this.addRmListener('keydown', function(ev) { return self.onKeyDown(ev); }); this.addRmListener('keypress', function(ev) { return self.onKeyPress(ev); }); } this.addRmListener('keyup', function(ev) { return self.onKeyUp(ev); }); this.addRmListener('change', function() { return self.onChange(); }); this.addRmListener('focus', function() { return self.onInputFocus(); }); this.addRmListener('blur', function() { return self.onInputBlur(); }); addListener(t, 'mousedown', function(ev) { return self.cancelBubbleOnHint(ev); }); this.onChange(); }; // items = [ [ name, value[, disabled] ], [ name, value ], ... ] SimpleAutocomplete.prototype.replaceItems = function(items, append) { if (!append) { this.hintLayer.scrollTop = 0; this.items = []; if (!items || items.length == 0) { if (this.emptyText) this.hintLayer.innerHTML = '
'+this.emptyText+'
'; else this.disable(); return; } this.hintLayer.innerHTML = this.prompt ? '
'+this.prompt+'
' : ''; this.enable(); } var h = {}; if (this.multipleDelimiter) { var old = this.input.value.split(this.multipleDelimiter); for (var i = 0; i < old.length; i++) h[old[i].trim()] = true; } for (var i in items) this.hintLayer.appendChild(this.makeItem(items[i][0], items[i][1], items[i][2], h[items[i][1]])); if (this.maxHeight) { this.hintLayer.style.height = (this.hintLayer.scrollHeight > this.maxHeight ? this.maxHeight : this.hintLayer.scrollHeight) + 'px'; } }; // Add removable listener (remember the function) SimpleAutocomplete.prototype.addRmListener = function(n, f) { this.closure[n] = f; addListener(this.input, n, f); }; // Remove instance ("destructor") SimpleAutocomplete.prototype.remove = function() { if (!this.hintLayer) return; this.hintLayer.parentNode.removeChild(this.hintLayer); for (var i in this.closure) { removeListener(this.input, i, this.closure[i]); } for (var i = 0; i < SimpleAutocomplete.SimpleAutocompletes.length; i++) { if (SimpleAutocomplete.SimpleAutocompletes[i] == this) { SimpleAutocomplete.SimpleAutocompletes.splice(i, 1); break; } } this.closure = {}; this.input = null; this.hintLayer = null; this.items = null; }; // Enable or disable an item SimpleAutocomplete.prototype.enableItem = function(index, enabled) { if (this.items && this.items[index]) { this.items[index][2] = enabled; document.getElementById(this.id+'_item_'+index).style = enabled ? 'hintItem' : 'hintDisabledItem'; if (this.multipleDelimiter) document.getElementById(this.id+'_check_'+index).disabled = !enabled; } }; // Create a drop-down list item, include checkbox if this.multipleDelimiter is true SimpleAutocomplete.prototype.makeItem = function(name, value, disabled, checked) { var d = document.createElement('div'); d.id = this.id+'_item_'+this.items.length; d.className = disabled ? 'hintDisabledItem' : 'hintItem'; d.title = value; if (this.allowHTML) d.innerHTML = name; else d.appendChild(document.createTextNode(name)); if (this.multipleDelimiter) { var c = document.createElement('input'); c.type = 'checkbox'; c.id = this.id+'_check_'+this.items.length; c.checked = checked && true; c.disabled = disabled && true; c.value = value; if (d.childNodes.length) d.insertBefore(c, d.firstChild); else d.appendChild(c); addListener(c, 'click', this.preventCheck); } var self = this; addListener(d, 'mouseover', function() { return self.onItemMouseOver(this); }); addListener(d, 'mousedown', function(ev) { return self.onItemClick(ev, this); }); this.items.push([name, value, disabled, checked]); return d; }; // Move highlight forward or back by 'by' items (integer) SimpleAutocomplete.prototype.moveHighlight = function(by) { var n = this.selectedIndex+by; if (n < 0) n = 0; var elem = document.getElementById(this.id+'_item_'+n); if (!elem) return true; return this.highlightItem(elem); }; // Make item 'elem' active (highlighted) SimpleAutocomplete.prototype.highlightItem = function(elem) { var ni = parseInt(elem.id.substr(this.id.length+6)); if (this.items[ni][2]) return false; if (this.selectedIndex >= 0) { var c = this.getItem(); if (c) c.className = 'hintItem'; } this.selectedIndex = ni; elem.className = 'hintActiveItem'; return false; }; // Get index'th item, or current when index is null SimpleAutocomplete.prototype.getItem = function(index) { if (index == null) index = this.selectedIndex; if (index < 0) return null; return document.getElementById(this.id+'_item_'+this.selectedIndex); }; // Select index'th item - change the input value and hide the hint if not a multi-select SimpleAutocomplete.prototype.selectItem = function(index) { if (!this.multipleDelimiter) { this.input.value = this.items[index][1]; this.hide(); } else { document.getElementById(this.id+'_check_'+index).checked = this.items[index][3] = !this.items[index][3]; var old = this.input.value.split(this.multipleDelimiter); for (var i = 0; i < old.length; i++) old[i] = old[i].trim(); // Turn the clicked item on or off, preserving order if (!this.items[index][3]) { for (var i = old.length-1; i >= 0; i--) if (old[i] == this.items[index][1]) old.splice(i, 1); this.input.value = old.join(this.multipleDelimiter+' '); } else { var h = {}; for (var i = 0; i < this.items.length; i++) if (this.items[i][3]) h[this.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 < this.items.length; i++) if (this.items[i][3] && h[this.items[i][1]]) nl.push(this.items[i][1]); this.input.value = nl.join(this.multipleDelimiter+' '); } } this.curValue = this.input.value; if (this.onChangeListener) this.onChangeListener(this, index); }; // Hide hinter SimpleAutocomplete.prototype.hide = function() { if (!this.skipHideCounter) { this.hintLayer.style.display = 'none'; this.input.autocomplete = this.origAutocomplete; } else this.skipHideCounter = 0; }; // Show hinter SimpleAutocomplete.prototype.show = function() { if (!this.disabled) { var p = getOffset(this.input); this.hintLayer.style.top = (p.top+this.input.offsetHeight) + 'px'; this.hintLayer.style.left = p.left + 'px'; this.hintLayer.style.display = ''; this.input.autocomplete = 'off'; } }; // Disable hinter, for the case when there is no items and no empty text SimpleAutocomplete.prototype.disable = function() { this.disabled = true; this.hide(); }; // Enable hinter SimpleAutocomplete.prototype.enable = function() { var show = this.disabled; this.disabled = false; if (show) this.show(); } // *** Event handlers *** // Prevent default action on checkbox SimpleAutocomplete.prototype.preventCheck = function(ev) { ev = ev||window.event; return stopEvent(ev, false, true); }; // Cancel event propagation SimpleAutocomplete.prototype.cancelBubbleOnHint = function(ev) { ev = ev||window.event; if (this.hasFocus) this.skipHideCounter++; return stopEvent(ev, true, false); }; // Handle item mouse over SimpleAutocomplete.prototype.onItemMouseOver = function(elm) { return this.highlightItem(elm); }; // Handle item clicks SimpleAutocomplete.prototype.onItemClick = function(ev, elm) { var index = parseInt(elm.id.substr(this.id.length+6)); if (this.items[index][2]) return false; if (this.moreMarker && this.items[index][1] == this.moreMarker) { // User clicked 'more'. Load more items without delay. this.items.splice(index, 1); elm.parentNode.removeChild(elm); this.more++; this.onChange(true); return true; } this.selectItem(index); return true; }; // Handle user input, load new items SimpleAutocomplete.prototype.onChange = function(force) { var v = this.input.value.trim(); if (!force) this.more = 0; if (v != this.curValue || force) { this.curValue = v; if (!this.delay || force) this.dataLoader(this, v, this.more); else if (!this.timer) { var self = this; this.timer = setTimeout(function() { self.dataLoader(self, self.curValue, self.more); self.timer = null; }, this.delay); } } return true; }; // Handle Enter key presses, cancel handling of arrow keys SimpleAutocomplete.prototype.onKeyUp = function(ev) { ev = ev||window.event; if (ev.keyCode == 38 || ev.keyCode == 40) this.show(); if (ev.keyCode == 38 || ev.keyCode == 40 || ev.keyCode == 10 || ev.keyCode == 13) { if (this.hintLayer.style.display == '') return stopEvent(ev, true, true); else return true; } this.onChange(); return true; }; // Cancel handling of Enter key SimpleAutocomplete.prototype.onKeyDown = function(ev) { ev = ev||window.event; if (ev.keyCode == 10 || ev.keyCode == 13) { if (this.hintLayer.style.display == '') return stopEvent(ev, true, true); else return true; } return true; }; // Handle arrow keys and Enter SimpleAutocomplete.prototype.onKeyPress = function(ev) { if (this.hintLayer.style.display == 'none') return true; ev = ev||window.event; if (ev.keyCode == 38) // up this.moveHighlight(-1); else if (ev.keyCode == 40) // down this.moveHighlight(1); else if (ev.keyCode == 10 || ev.keyCode == 13) // enter { if (this.selectedIndex >= 0) this.selectItem(this.selectedIndex); return stopEvent(ev, true, true); } else if (ev.keyCode == 27) // escape { this.hide(); return stopEvent(ev, true, true); } else return true; // scrolling if (this.selectedIndex >= 0) { var c = this.getItem(); var t = this.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 SimpleAutocomplete.prototype.onInputFocus = function() { if (this.disabledUntil) this.enable(); this.show(); this.input.autocomplete = 'off'; this.hasFocus = true; return true; }; // Called when input loses focus SimpleAutocomplete.prototype.onInputBlur = function() { this.hide(); this.input.autocomplete = this.origAutocomplete; this.hasFocus = false; return true; }; // *** Global variables *** // List of all instances 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) return true; target = target.parentNode; } for (var i in SimpleAutocomplete.SimpleAutocompletes) if (SimpleAutocomplete.SimpleAutocompletes[i] != esh) SimpleAutocomplete.SimpleAutocompletes[i].hide(); return true; }; // *** UTILITY FUNCTIONS *** // Remove this section if you already have these functions defined somewhere else // Cross-browser add/remove event listeners var addListener = function() { return window.addEventListener ? function(el, type, fn) { el.addEventListener(type, fn, false); } : function(el, type, fn) { el.attachEvent('on'+type, fn); }; }(); var removeListener = function() { return window.removeEventListener ? function(el, type, fn) { el.removeEventListener(type, fn, false); } : function(el, type, fn) { el.detachEvent('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) });