I've made a quick port of the scriptaculous autocomplete component. The code requires the Effect code that I posted to the MochiEffects page. Example Usage: {{{ #!text/html
}}} {{{ #!text/x-javascript // // This is a port of the sciptaculous autocomplete which is distributed under the MIT license. // // Autocompleter.Base handles all the autocompletion functionality // Autocompleter.Base handles all the autocompletion functionality // that's independent of the data source for autocompletion. This // includes drawing the autocompletion menu, observing keyboard // and mouse events, and similar. // // Specific autocompleters need to provide, at the very least, // a getUpdatedChoices function that will be invoked every time // the text inside the monitored textbox changes. This method // should get the text for which to provide autocompletion by // invoking this.getToken(), NOT by directly accessing // this.element.value. This is to allow incremental tokenized // autocompletion. Specific auto-completion logic (AJAX, etc) // belongs in getUpdatedChoices. // // Tokenized incremental autocompletion is enabled automatically // when an autocompleter is instantiated with the 'tokens' option // in the options parameter, e.g.: // new Ajax.Autocompleter('id','upd', '/url/', { tokens: ',' }); // will incrementally autocomplete with a comma as the token. // Additionally, ',' in the above example can be replaced with // a token array, e.g. { tokens: [',', '\n'] } which // enables autocompletion on multiple tokens. This is most // useful when one of the tokens is \n (a newline), as it // allows smart autocompletion after linebreaks. var KEY_BACKSPACE = 8 var KEY_TAB = 9 var KEY_RETURN = 13 var KEY_ESC = 27 var KEY_LEFT = 37 var KEY_UP = 38 var KEY_RIGHT = 39 var KEY_DOWN = 40 var KEY_DELETE = 46 Autocompleter = function() { } Autocompleter.Base = function() { } update(Autocompleter.Base.prototype, { baseInitialize: function(element, update, options) { this.element = $(element); this.update = $(update); this.hasFocus = false; this.changed = false; this.active = false; this.index = 0; this.entryCount = 0; if (this.setOptions) this.setOptions(options); else this.options = options || {}; this.options.paramName = this.options.paramName || this.element.name; this.options.tokens = this.options.tokens || []; this.options.frequency = this.options.frequency || 0.4; this.options.minChars = this.options.minChars || 1; this.options.onShow = this.options.onShow || bind(function(element, update){ if(!update.style.position || update.style.position=='absolute') { update.style.position = 'absolute'; this.clonePosition(element, update); //Position.clone(element, update, {setHeight: false, offsetTop: element.offsetHeight}); } // Todo: make it fade in ? showElement(update); //Effect.Appear(update,{duration:0.15}); }, this); this.options.onHide = this.options.onHide || function(element, update){ // Todo: make if fade out like in the scriptaculous version? //new Effect.Fade(update,{duration:0.15}) hideElement(update); }; if (typeof(this.options.tokens) == 'string') this.options.tokens = new Array(this.options.tokens); this.observer = null; this.element.setAttribute('autocomplete','off'); hideElement(this.update); addToCallStack(this.element, "onblur", bind(this.onBlur, this)); addToCallStack(this.element, "onkeypress", bind(this.onKeyPress, this)); //Event.observe(this.element, "blur", this.onBlur.bindAsEventListener(this)); //Event.observe(this.element, "keypress", this.onKeyPress.bindAsEventListener(this)); }, /** * Copied from prototype.js version 1.4.0_rc3 */ cumulativeOffset: function(element) { var valueT = 0, valueL = 0; do { valueT += element.offsetTop || 0; valueL += element.offsetLeft || 0; element = element.offsetParent; } while (element); return [valueL, valueT]; }, /** * Copied from prototype.js version 1.4.0_rc3 */ clonePosition: function(source, target) { source = $(source); target = $(target); //target.style.position = 'absolute'; var offsets = this.cumulativeOffset(source); //target.style.top = offsets[1] + 'px'; target.style.left = offsets[0] + 'px'; target.style.width = source.offsetWidth + 'px'; //target.style.height = source.offsetHeight + 'px'; }, show: function() { if(this.update.style.display == 'none') this.options.onShow(this.element, this.update); /* if(!this.iefix && (navigator.appVersion.indexOf('MSIE')>0) && (navigator.userAgent.indexOf('Opera')<0) && (this.update.style.position=='absolute')) { appendChildNodes(this.update, ''); this.iefix = $(this.update.id+'_iefix'); } if(this.iefix) setTimeout(this.fixIEOverlapping.bind(this), 50); */ }, fixIEOverlapping: function() { this.clonePosition(this.update, this.iefix); this.iefix.style.zIndex = 1; this.update.style.zIndex = 2; showElement(this.iefix); }, hide: function() { this.stopIndicator(); if(this.update.style.display !='none') this.options.onHide(this.element, this.update); if(this.iefix) hideElement(this.iefix); }, startIndicator: function() { if(this.options.indicator) showElement(this.options.indicator); }, stopIndicator: function() { if(this.options.indicator) hideElement(this.options.indicator); }, onKeyPress: function(event) { if( event == null ) event = window.event; if(this.active) switch(event.keyCode) { case KEY_TAB: case KEY_RETURN: this.selectEntry(); this.stopEvent(event); case KEY_ESC: this.hide(); this.active = false; this.stopEvent(event); return; case KEY_LEFT: case KEY_RIGHT: return; case KEY_UP: this.markPrevious(); this.render(); if(navigator.appVersion.indexOf('AppleWebKit')>0) this.stopEvent(event); return; case KEY_DOWN: this.markNext(); this.render(); if(navigator.appVersion.indexOf('AppleWebKit')>0) this.stopEvent(event); return; } else if(event.keyCode==KEY_TAB || event.keyCode==KEY_RETURN) return; this.changed = true; this.hasFocus = true; if(this.observer) clearTimeout(this.observer); this.observer = setTimeout(bind(this.onObserverEvent, this), this.options.frequency*1000); }, onHover: function(event) { var element = this.findElement(event, 'LI'); if(this.index != element.autocompleteIndex) { this.index = element.autocompleteIndex; this.render(); } this.stopEvent(event); }, // find the first node with the given tagName, starting from the // node the event was triggered on; traverses the DOM upwards findElement: function(event, tagName) { if( event == null ) event = window.event; var element = event.target || event.srcElement; while (element.parentNode && (!element.tagName || (element.tagName.toUpperCase() != tagName.toUpperCase()))) element = element.parentNode; return element; }, stopEvent: function(event) { if( event == null ) event = window.event; if (event.preventDefault) { event.preventDefault(); event.stopPropagation(); } else { event.returnValue = false; event.cancelBubble = true; } }, onClick: function(event) { var element = this.findElement(event, 'LI'); this.index = element.autocompleteIndex; this.selectEntry(); this.hide(); }, onBlur: function(event) { // needed to make click events working setTimeout(bind(this.hide, this), 250); this.hasFocus = false; this.active = false; }, render: function() { if(this.entryCount > 0) { for (var i = 0; i < this.entryCount; i++) this.index==i ? addElementClass(this.getEntry(i),"selected") : removeElementClass(this.getEntry(i),"selected"); if(this.hasFocus) { this.show(); this.active = true; } } else { this.active = false; this.hide(); } }, markPrevious: function() { if(this.index > 0) this.index-- else this.index = this.entryCount-1; }, markNext: function() { if(this.index < this.entryCount-1) this.index++ else this.index = 0; }, getEntry: function(index) { return this.update.firstChild.childNodes[index]; }, getCurrentEntry: function() { return this.getEntry(this.index); }, selectEntry: function() { this.active = false; this.updateElement(this.getCurrentEntry()); }, updateElement: function(selectedElement) { if (this.options.updateElement) { this.options.updateElement(selectedElement); return; } // the reverse, join is a workaround for a bug in the 1.1 version of MochiKit // var value = scrapeText(selectedElement, true).reverse().join(""); var lastTokenPos = this.findLastToken(); if (lastTokenPos != -1) { var newValue = this.element.value.substr(0, lastTokenPos + 1); var whitespace = this.element.value.substr(lastTokenPos + 1).match(/^\s+/); if (whitespace) newValue += whitespace[0]; this.element.value = newValue + value; } else { this.element.value = value; } this.element.focus(); if (this.options.afterUpdateElement) this.options.afterUpdateElement(this.element, selectedElement); }, updateChoices: function(choices) { if(!this.changed && this.hasFocus) { this.update.innerHTML = choices; // TODO: implement this via MochiKit //Element.cleanWhitespace(this.update); //Element.cleanWhitespace(this.update.firstChild); if(this.update.firstChild && this.update.firstChild.childNodes) { this.entryCount = this.update.firstChild.childNodes.length; for (var i = 0; i < this.entryCount; i++) { var entry = this.getEntry(i); entry.autocompleteIndex = i; this.addObservers(entry); } } else { this.entryCount = 0; } this.stopIndicator(); this.index = 0; this.render(); } }, addObservers: function(element) { addToCallStack(element, "onmouseover", bind(this.onHover, this)); addToCallStack(element, "onclick", bind(this.onClick, this)); }, onObserverEvent: function() { this.changed = false; if(this.getToken().length>=this.options.minChars) { this.startIndicator(); this.getUpdatedChoices(); } else { this.active = false; this.hide(); } }, getToken: function() { var tokenPos = this.findLastToken(); if (tokenPos != -1) var ret = this.element.value.substr(tokenPos + 1).replace(/^\s+/,'').replace(/\s+$/,''); else var ret = this.element.value; return /\n/.test(ret) ? '' : ret; }, findLastToken: function() { var lastTokenPos = -1; for (var i=0; i/gi, ""); }, createEditField: function() { var text; if(this.options.loadTextURL) { text = this.options.loadingText; } else { text = this.getText(); } if (this.options.rows == 1 && !this.hasHTMLLineBreaks(text)) { this.options.textarea = false; var textField = document.createElement("input"); textField.type = "text"; textField.name = "value"; textField.value = text; textField.style.backgroundColor = this.options.highlightcolor; var size = this.options.size || this.options.cols || 0; if (size != 0) textField.size = size; this.editField = textField; } else { this.options.textarea = true; var textArea = document.createElement("textarea"); textArea.name = "value"; textArea.value = this.convertHTMLLineBreaks(text); textArea.rows = this.options.rows; textArea.cols = this.options.cols || 40; this.editField = textArea; } if(this.options.loadTextURL) { this.loadExternalText(); } this.form.appendChild(this.editField); }, getText: function() { return this.element.innerHTML; }, loadExternalText: function() { Element.addClassName(this.form, this.options.loadingClassName); this.editField.disabled = true; new Ajax.Request( this.options.loadTextURL, Object.extend({ asynchronous: true, onComplete: this.onLoadedExternalText.bind(this) }, this.options.ajaxOptions) ); }, onLoadedExternalText: function(transport) { Element.removeClassName(this.form, this.options.loadingClassName); this.editField.disabled = false; this.editField.value = transport.responseText.stripTags(); }, onclickCancel: function() { this.onComplete(); this.leaveEditMode(); return false; }, onFailure: function(transport) { this.options.onFailure(transport); if (this.oldInnerHTML) { this.element.innerHTML = this.oldInnerHTML; this.oldInnerHTML = null; } return false; }, onSubmit: function() { // onLoading resets these so we need to save them away for the Ajax call var form = this.form; var value = this.editField.value; // do this first, sometimes the ajax call returns before we get a chance to switch on Saving... // which means this will actually switch on Saving... *after* we've left edit mode causing Saving... // to be displayed indefinitely this.onLoading(); new Ajax.Updater( { success: this.element, // don't update on failure (this could be an option) failure: null }, this.url, Object.extend({ parameters: this.options.callback(form, value), onComplete: this.onComplete.bind(this), onFailure: this.onFailure.bind(this) }, this.options.ajaxOptions) ); // stop the event to avoid a page refresh in Safari if (arguments.length > 1) { Event.stop(arguments[0]); } return false; }, onLoading: function() { this.saving = true; this.removeForm(); this.leaveHover(); this.showSaving(); }, showSaving: function() { this.oldInnerHTML = this.element.innerHTML; this.element.innerHTML = this.options.savingText; Element.addClassName(this.element, this.options.savingClassName); this.element.style.backgroundColor = this.originalBackground; showElement(this.element); }, removeForm: function() { if(this.form) { if (this.form.parentNode) Element.remove(this.form); this.form = null; } }, enterHover: function() { if (this.saving) return; this.element.style.backgroundColor = this.options.highlightcolor; if (this.effect) { this.effect.cancel(); } Element.addClassName(this.element, this.options.hoverClassName) }, leaveHover: function() { if (this.options.backgroundColor) { this.element.style.backgroundColor = this.oldBackground; } Element.removeClassName(this.element, this.options.hoverClassName) if (this.saving) return; this.effect = new Effect.Highlight(this.element, { startcolor: this.options.highlightcolor, endcolor: this.options.highlightendcolor, restorecolor: this.originalBackground }); }, leaveEditMode: function() { Element.removeClassName(this.element, this.options.savingClassName); this.removeForm(); this.leaveHover(); this.element.style.backgroundColor = this.originalBackground; showElement(this.element); if (this.options.externalControl) { showElement(this.options.externalControl); } this.editing = false; this.saving = false; this.oldInnerHTML = null; this.onLeaveEditMode(); }, onComplete: function(transport) { this.leaveEditMode(); this.options.onComplete.bind(this)(transport, this.element); }, onEnterEditMode: function() {}, onLeaveEditMode: function() {}, dispose: function() { if (this.oldInnerHTML) { this.element.innerHTML = this.oldInnerHTML; } this.leaveEditMode(); Event.stopObserving(this.element, 'click', this.onclickListener); Event.stopObserving(this.element, 'mouseover', this.mouseoverListener); Event.stopObserving(this.element, 'mouseout', this.mouseoutListener); if (this.options.externalControl) { Event.stopObserving(this.options.externalControl, 'click', this.onclickListener); Event.stopObserving(this.options.externalControl, 'mouseover', this.mouseoverListener); Event.stopObserving(this.options.externalControl, 'mouseout', this.mouseoutListener); } } }; // Delayed observer, like Form.Element.Observer, // but waits for delay after last key input // Ideal for live-search fields Form.Element.DelayedObserver = Class.create(); Form.Element.DelayedObserver.prototype = { initialize: function(element, delay, callback) { this.delay = delay || 0.5; this.element = $(element); this.callback = callback; this.timer = null; this.lastValue = $F(this.element); Event.observe(this.element,'keyup',this.delayedListener.bindAsEventListener(this)); }, delayedListener: function(event) { if(this.lastValue == $F(this.element)) return; if(this.timer) clearTimeout(this.timer); this.timer = setTimeout(this.onTimerEvent.bind(this), this.delay * 1000); this.lastValue = $F(this.element); }, onTimerEvent: function() { this.timer = null; this.callback(this.element, $F(this.element)); } }; */ }}} There are a few features that I didn't implement: * IE workarounds. I wasn't really sure what the existing code did so I just commented it out. * Fade in/out effects. ---- Attached is a little patch that makes the autocomplete.js above work for me in Firefox; it also makes key up/down work better (works around the lack of cleanWhitespace) and removes the workaround for MochiKit 1.1. It would be great if keys could work in MSIE as well, but I'm not sure how to enable that. Oh, and the problem with overlapping input-elements in MSIE is annoying as well, I'm not sure to reenable the workaround for that. -- asjo