/*
Autofill dropdown
Copyright Sagan Bolliger 2007

Synopsis:
Modifies a text input element so that with each key press the server is queried with the current
text; the results returned are parsed and placed in a menu of possible options.  The menu can be
navigated using the mouse or the up and down arrow keys.  If only one option is returned, the input
element's text autocompletes with the fieldtext of that option and selects the autocomplete text so
that further keystrokes replace it.  When an option is selected, the option's id is placed in the
element referred to by ID in the custom attribute "hiddenid". Browser autocomplete is foiled by 

Usage:



Dependencies:
Prototype 1.5.1


*/



var AutofillDropdown = { name: 'autofill dropdown',
	init: function() {
		Element.descendants(document.body).each( function(elem) {
			if(elem.readAttribute('autofill') != null) {
				
				// change name to foil browser's autocomplete
				var id = Math.floor(Math.random() * 100000) + AutofillDropdown.generateID();
				var error = $(elem.readAttribute('name')+'Error')
				if(error)
					error.setAttribute('id',id+'Error');
				elem.setAttribute('name',id);
				
				elem.keyuphandler = AutofillDropdown.makeKeyupHandler(elem);
				Event.observe(elem,'keyup',elem.keyuphandler);
				Event.observe(elem,'focus',AutofillDropdown.makeFocusHandler(elem));
				Event.observe(elem,'blur',AutofillDropdown.makeBlurHandler(elem));
				
				elem.hidden = $( elem.readAttribute('hidden') );
				elem.qtype = elem.readAttribute('qtype');
				elem.url = elem.readAttribute('autofill');
				elem.format = elem.readAttribute('format');
				if(elem.format === null)
					elem.format = "1";
				
/*				elem.autocomplete = elem.readAttribute('autocomplete');
				
				if(elem.autocomplete == "true")
					elem.autocomplete = true;
				else
					elem.autocomplete = false;  */
				
				elem.updateDropdown = AutofillDropdown.updateDropdown;
				elem.choose = AutofillDropdown.choose;
				elem.setDropdownVisible = AutofillDropdown.setDropdownVisible;
				elem.createDropdown = AutofillDropdown.createDropdown;
				elem.isDropdownVisible = AutofillDropdown.isDropdownVisible;
				elem.populateDropdown = AutofillDropdown.populateDropdown;
				elem.queryServer = AutofillDropdown.queryServer;
				elem.parseList = AutofillDropdown.parseList;
				elem.setSelected = AutofillDropdown.setSelected;
				elem.getItemsHeight = AutofillDropdown.getItemsHeight;
				elem.autocomplete = AutofillDropdown.autocomplete;
				
				elem.createDropdown();
				elem.sm = new SelectionManager(elem);
				
				
				// handle autofill text
				//if(elem.value != '')
				//	var pe = new PeriodicalExecuter(function() {
				//		pe.stop();
				//		elem.doChoose = true;
				//		elem.queryServer();
				//	}, 0.2);
				
				elem.strict = (elem.hidden.readAttribute('value') != '');
				
				
				elem.doChoose=true;
				elem.queryServer();
				
			}
		});
	},
	/*
	Updates the dropdown to reflect the current contents of this.items.  Hides the menu if the field
	value is empty or this.items contains 0 items.
	*/
	updateDropdown: function() {
		
		this.populateDropdown();

		if(this.value.strip() == '')
			this.setDropdownVisible(false);
		else if(this.items.length > 1) {
			this.setDropdownVisible(true);
		}
		else
			this.setDropdownVisible(false);
				
		
	},
	/*
	Accepts the current selection in the SelectionManager by setting the value of the hidden
	field to the ID attribute the of the selected item.  If no item is selected (it's a custom
	value), the hidden field is set to the contents of the input field and the strict attribute
	is set to false.  Otherwise, it's set to true to indicate that a server-provided item option
	has been chosen.
	*/
	choose: function() {

		if(this.sm.currentSel) {
			this.strict = true;
			this.value = this.sm.currentSel.fieldtext;
			this.hidden.value = this.sm.currentSel.ID;
		} else if(this.sm.initItem && (this.doChoose || this.value == this.sm.initItem.fieldtext)) {
			this.strict = true;
			this.hidden.value = this.sm.initItem.ID;
			this.value = this.sm.initItem.fieldtext;
		} else {
			this.strict = false;
			this.hidden.value = this.value;
		}
		
		this.doChoose = false;
	},
	/*
	If the number of items is 1, this method sets the input field's value to the full value of the
	item's fieldtext but selects the portion of the text that was not explicitly entered by the user
	so that further typing overwrites the autocompleted text.
	*/
	autocomplete: function() {
		
		var val = this.value.strip();
		
		var fcn = function() {
			this.ac.stop();
			this.ac = null;
			if(this.items.length == 1 && this.value.strip() != '' && val == this.value.strip() 
				&& this.lastKeyCode != 8) {
				var len = this.value.strip().length;
				this.value = this.items[0].fieldtext;
				this.setSelected(len);
				this.isSel = true;
			}
		}
		
		if(this.ac) {
			this.ac.stop();
			this.ac = null;
		}
		
		this.ac = new PeriodicalExecuter(fcn.bind(this),0.1);
	
	},
	/*
	Factory method to return a new keyup handler for the field specified in elem.
	*/
	makeKeyupHandler: function(elem) {
		
		var handler = function(event) {
		
			var keyCode = event.keyCode;
			
			// handle Select All
			if(keyCode == 91) {
				Event.stop(event);
				return;
			}			
			// handle a browser bug where listener is triggered incorrectly
			if(keyCode == undefined || keyCode == 0) {
				Event.stop(event);
				return;
			}

			// left and right arrow keys are not processed
			if(keyCode == 37 || keyCode == 39) {
				return;
			}
			
			// return key must be cancelled to avoid the form from submitting when a user selects
			// an item with the return key from the dropdown menu
			if(keyCode == 13) {
				Event.stop(event);
				this.blur();
				return;
			}
			
			/* if the value of the input field is empty, we don't need to bother querying the
			server.  instead, we manually empty the list and update the dropdown menu. */
			if(this.value.strip() == '') {
				this.list = [];
				this.updateDropdown();
				return;
			}
			
			/*
			The up and down arrow keys control the selection on the menu.  If the menu is visible,
			we call the previous and next methods on the selection manager as appropriate.  Also,
			we must stop the event so that the up and down arrow keys don't change the insertion
			point position in the input field.
			*/
			if(keyCode == 38 || keyCode == 40) {
				Event.stop(event);
				if(this.isDropdownVisible()) {
					if(keyCode == 38)
						this.sm.previous();
					else
						this.sm.next();
				}
				return;
			}
			
			/* we need to record the last key event that was processed for use elsewhere */
			this.lastKeyCode = keyCode;

			/* 
			Finally, if the keypress wasn't filtered and isn't a speial case handled above,
			the value of the input field has changed and we need to send a new query to the server.
			*/
			this.queryServer();
			
		};
			
		return handler.bindAsEventListener(elem);
	},
	/*
	Factory method to create a new focus handler that queries the server.
	*/
	makeFocusHandler: function(elem) {
		var handler = function() {
			this.queryServer();
		};
		return handler.bindAsEventListener(elem);
	},
	/*
	Factory method that creates a new blur handler.
	*/
	makeBlurHandler: function(elem) {
		var handler = function() {
						

			if(this.list.length == 0) {
				this.doChoose = true;
				this.queryServer();
			} else
				this.choose();
			
			
			/*
			If we hide the dropdown menu as soon as we blur, there are issues with the AJAX call.
			Hence I've added a 300 ms delay before hiding the menu.
			*/
			var pe = new PeriodicalExecuter( function() {
				elem.setDropdownVisible(false);
				pe.stop();
			},0.3);
		
		};	
		return handler.bindAsEventListener(elem);
	},
	/*
	Method to handle the various tasks or hiding and showing the dropdown menu.
	*/
	setDropdownVisible: function(visible) {
		
		if(visible) {
			this.div.show();
			this.div.style.height = (this.getItemsHeight()) + 'px';
		}
		else {
			this.div.hide();
			this.div.style.height = '0px';
		}

	},
	/*
	Return true iff the dropdown menu is visible.
	*/
	isDropdownVisible: function() {
		return this.div.style.height != '0px';
	},
	/*
	Replaces the contents of the dropdown menu with the formatted contents of the this.items array.
	*/
	populateDropdown: function() {
				
		if(this.items)
			this.items.each( function(item) {
				
				Event.stopObserving(item,'click',item.click);
				Event.stopObserving(item,'mouseover',item.mouseover);
				Event.stopObserving(item,'mouseout',item.mouseout);
				
			});
		
		this.items = new Array();
		
		this.div.update('');
		
		var previousItem = null;
		
		var oThis = this;
		
		this.list.each(function(litem) {

			var ID = AutofillDropdown.generateID();
			
			var contents = '';
			contents += '<div class="returnedItems" id="' + ID + '">';
			contents += '<div class="firstRow">';
			contents += litem[0];
			contents += '</div><div class="secondRow">';
			contents += litem[1];
			contents += '</div></div>';
			
			new Insertion.Bottom(oThis.div, contents);
			
			var item = $( ID ); 
			item.text = litem[0];
			item.subtext = litem[1];
			item.fieldtext = litem[2];
			item.ID = litem[3];
			
			oThis.items.push(item);
			
			item.click = AutofillDropdown.makeItemClickHandler(oThis,item);
			item.mouseover = AutofillDropdown.makeItemMouseoverHandler(oThis,item);
			item.mouseout = AutofillDropdown.makeItemMouseoutHandler(oThis,item);
			
			Event.observe(item,'click',item.click);
			Event.observe(item,'mouseover',item.mouseover);
			Event.observe(item,'mouseout',item.mouseout);
			
			if(previousItem) {
				previousItem.nextItem = item;
				item.previousItem = previousItem;
			}
			previousItem = item;
									
		});
		
		// init the selection manager
		if(this.items.length >= 1)
			this.sm.init(this.items[0]);
		else
			this.sm.init(null);
		
	},
	/*
	Generates the code for the dropdown menu.  This method only needs to be called once during
	init.  From that point on, the contents of the menu merely need to be changed using the 
	populateDropdown() method.
	*/
	createDropdown: function() {
		
		var dropdownid = AutofillDropdown.generateID();
				
		var html = '<br /><div class="dropdown" id="' + dropdownid + '" ></div>';
		
		new Insertion.After(this,html);
		
		this.div = $(dropdownid);
		
		this.div.style.width = this.offsetWidth;
		this.div.style.height = '0px';
				
		this.setDropdownVisible(false);
				
	},
	/*
	Returns an event handler that does returns nothing.  It isn't necessary to explicitly do
	anything since a click on the menu constitutes a blur on the input field, and since the blur
	handler has code to handle selections, it is possible to allow that code to handle this case.
	*/
	makeItemClickHandler: function(field,item) {
		var handler = function(event) {
		};
		return handler.bindAsEventListener(field);
	},
	/*
	Sets the selection when the mouse moves over the element.
	*/
	makeItemMouseoverHandler: function(field,item) {
		var handler = function(event) {
			this.sm.set(item);
		};
		return handler.bindAsEventListener(field);
	},
	/*
	Sets the selection to null when the mouse exists the element.
	*/
	makeItemMouseoutHandler: function(field,item) {
		var handler = function(event) {
			this.sm.set(null);
		};
		return handler.bindAsEventListener(field);
	},
	/*
	Every time this method is called, it returns a different string that is guranteed to be unique.
	This method is used for creating unique IDs for dynamically generated elements that must later
	be referenced.
	*/
	generateID: function() {
		return 'afdd' + String(this.count++) + 'id'; 
	},
	count: 0,
	isSel: false,
	curLen: -1,
	/*
	Sends a new AJAX request to the server.
	Since many requests may be sent in one second and we can't guarantee that the server will be
	highly responsive, a recency management mechanism is necessary in order to avoid situations
	in which an older request returns after a more recent one has returned setting the state back
	from where it used to be.  The solution used here is as follows: every new request is given a 
	sequential numerical id starting at 1.  Furthermore, the element keeps track of the most recent
	request that has returned.  Thus, each time a request is returned, its request number is
	compared with the stored request number.  If the request's number is less than or equal to the
	stored request number, the request is silently ignored.  Otherwise, the request is processed
	and the stored request number is set to the request number of the current request.
	*/
	queryServer: function() { 
		if(!this.lastSent)
			this.lastSent = 1;
		
		if(!this.lastReceived)
			this.lastReceived = 1;
		
		this.lastValue = this.value.strip();
		
		var oThis = this;
		var currentRequest = this.lastSent++;
								
		new Ajax.Request(this.url, {
			method: 'POST',
			parameters: {
				type: this.qtype,
				value: this.value.split(',')[0].strip(),
				format: this.format
			},
			onSuccess: function(transport) {
								
				// expire quietly if a more recent request has returned
				if(oThis.lastReceived > oThis.lastSent)
					return;
								
				// otherwise, update the recency tracker
				oThis.lastReceived = currentRequest;
																						
				oThis.list = oThis.parseList(transport.responseText.evalJSON());
																
				if(oThis.doChoose) {
					
					oThis.populateDropdown(); //window.status += ' prech(' + transport.responseText + ') ';
					oThis.choose(); //window.status += ' postch ';
					
				} else {
				
					oThis.updateDropdown();
					
					// only do autocomplete if the response is for the last key press
					if(currentRequest == oThis.lastSent - 1)
						oThis.autocomplete();	
					
				}
								
			},
			onFailure: function(transport) {
				
				// only relaunch the request if the failed request is the most recent one
				if(oThis.lastReceived <= oThis.lastSent)
					oThis.queryServer();

			}
		});
		
		
	},
	/*
	Parses the provided list as a string into an array of arrays and returns it.
	*/
	parseList: function(list) {
		//var arr = list.evalJSON();
				//alert(list);

		var tmp = list.map(function( item ) { 
			return Array( item.t , item.s , item.f , item.id );
		} );

		return tmp.select( function( item ) { return item && item.length == 4; } );
	},
	/*
	Sets the selection from the point specified with start to the end of the text in the input
	field.  Works in all major browsers.
	*/
	setSelected: function (start) {
		if (this.createTextRange) {
			var range = this.createTextRange(); 
			range.moveStart("character", start); 
			range.moveEnd("character",this.value.length); 
			range.select();
		} else if (this.setSelectionRange) {
			this.setSelectionRange(start, this.value.length);
		}
	},
	/*
	Returns the height of the menu in pixels.
	*/
	getItemsHeight: function() {
		var total = 0;
		this.items.each( function(item) { total += item.offsetHeight; });
		return total;
	}
};


/*
Selection manager keeps track of the state of the selection in the dropdown menu.
*/
var SelectionManager = Class.create();
SelectionManager.prototype = {name: 'Selection Manager',
	initialize: function(elem) {
		this.elem = elem;
	},
	currentSel: null,
	initItem: null,
	// sets the selection to the specified item
	set: function(item) {
		
		if(this.currentSel)
			this.currentSel.style.backgroundColor = '';
		
		this.currentSel = item;
		if(item)
			item.style.backgroundColor = '#000000';

	},
	// initializes the SelectionManager without actually setting the selection
	init: function(item) {
		this.initItem = item;
		this.currentSel = null;
	},
	// sets the selection to the next item
	next: function() {
		if(!this.currentSel)
			this.set(this.initItem);
		else if(this.currentSel.nextItem)
			this.set(this.currentSel.nextItem);

	},
	// sets the selection to the previous item
	previous: function() {
		if(this.currentSel)
			this.set(this.currentSel.previousItem);
		else
			this.set(null);

	}
	
};


Event.observe(window,'load',function() {
	AutofillDropdown.init();
});

