Source: BKGWebMap/Util/AutoSuggest.js

/*
 * Copyright (c) 2013 Bundesamt für Kartographie und Geodäsie.
 * See license.txt in the BKG WebMap distribution or repository for the
 * full text of the license.
 *
 * Author: Dirk Thalheim
 * derived from Timothy Groves - http://www.brandspankingnew.net
 */

/**
 * @requires OpenLayers/BaseTypes/Class.js
 * @requires OpenLayers/Util.js
 * @requires BKGWebMap/Util.js
 */

/**
 * @classdesc Tool zur Bereitstellung einer AutoSuggest-Funktionalität
 *
 * @constructor BKGWebMap.Util.AutoSuggest
 * @param {node} input - Inputelement für das die Vervollständigung aktiviert werden soll
 * @param {BKGWebMap.Protocol.Geoindex} protocol - Protokoll für Suche
 * @param {object} options - Optionen zur Modifikation der Parameter
 */
BKGWebMap.Util.AutoSuggest = OpenLayers.Class({

    /**
     * Minimale Anzahl an Zeichen, ab der Vorschläge gesucht werden
     * @memberOf BKGWebMap.Util.AutoSuggest
     * @type int
     */
	minchars : 1,

    /**
     * CSS-Klasse für Autocomplete
     * @memberOf BKGWebMap.Util.AutoSuggest
     * @type string
     */
	className: "autosuggest",

    /**
     * Zeit in Millisekunden, ab der Suche gestartet werden soll
     * @memberOf BKGWebMap.Util.AutoSuggest
     * @type int
     */
	delay: 500,

    /**
     * <code>true</code> wenn Hinweis angezeigt werden soll, falls keine Ergebnisse
     * @memberOf BKGWebMap.Util.AutoSuggest
     * @type boolean
     */
	shownoresults: true,

    /**
     * Label für keine Ergebnisse
     * @memberOf BKGWebMap.Util.AutoSuggest
     * @type string
     */
	noresults: "Keine Ergebnisse!",

    /**
     * Inputfeld, auf dem Autovervollständigung angewandt werden soll
     * @memberOf BKGWebMap.Util.AutoSuggest
     * @type node
     */
    input: null,

    /**
     * Eventlistener für Input-Feld
     * @memberOf BKGWebMap.Util.AutoSuggest
     * @type OpenLayers.Events
     */
    events: null,

    /**
     * Protokoll für Vorschlagssuche
     * @memberOf BKGWebMap.Util.AutoSuggest
     * @type BKGWebMap.Protocol.Geoindex
     */
    protocol: null,

    /**
     * <code>true</code> wenn Vorschläge angezeigt werden
     * @memberOf BKGWebMap.Util.AutoSuggest
     * @type boolean
     */
    isVisible: false,

    initialize: function(input, protocol, options) {
      OpenLayers.Util.extend(this, options);

      this.input = OpenLayers.Util.getElement(input);
      this.protocol = protocol;

      // init variables
	    this.sInput 		= "";
	    this.suggestions 	= [];
	    this.highlighted 	= 0;

	    this.input.setAttribute("autocomplete","off");

      // create holding div
      this.div = document.createElement("div");

      OpenLayers.Element.addClass(this.div, this.className);
      OpenLayers.Element.addClass(this.className);
      this.hideSuggestions();

      this.registerEvents();
    },

    destroy: function() {
      this.cancel();
      if(this.divEvents) {
        this.divEvents.destroy();
        this.divEvents = null;
      }
      this.suggestions = null;
    },

    /**
     * Events für Input und Search-Button registrieren.
     * @memberOf BKGWebMap.Control.Search
     * @private
     */
    registerEvents: function() {
	    // Registriere Keyboardevents
	    if(!this.events) {
        // Wenn noch kein Eventlistener erzeugt wurde, erzeuge Neuen mit minimalen Browserevents
          this.events = new OpenLayers.Events(this, this.input, null, true, {BROWSER_EVENTS: ["keydown", "keyup"]});
      } else {
          // Falls Eventlistener übergeben wurde, muss ggf. noch keyup beobachtet werden
          OpenLayers.Event.observe( this.input, "keyup", this.events.eventHandler );
      }

      this.events.registerPriority("keydown", this, this.onKeyDown);
      this.events.registerPriority("keyup", this, this.onKeyUp);

      // default event handling for input
      var stopEvent = function(evt) {OpenLayers.Event.stop(evt, true);};
      this.divEvents = new OpenLayers.Events(this, this.div, null, true);
      // onchange-Event beobachten
      //OpenLayers.Event.observe( this.input, "onchange", this.inputEvents.eventHandler );
      this.divEvents.on({
          "mousedown": stopEvent,
          "mousemove": stopEvent,
          "mouseup": stopEvent,
          "click": stopEvent,
          scope: this
      });

      OpenLayers.Event.observe( document.documentElement, "click", OpenLayers.Function.bindAsEventListener(this.hideSuggestions, this) );
    },

    /**
     * Reagiert auf KeyDown-Events im Input-Feld. Ermöglicht dem Nutzer Einträge auszublenden und auszuwählen.
     * <br/>
     * ENTER: selektierten Vorschlag übernehmen<br/>
     * ESC: Vorschläge verbergen
     *
     * @param {event} evt - KeyDownEvent
     * @memberOf BKGWebMap.Util.AutoSuggest
     */
    onKeyDown: function(evt) {
    	switch(evt.keyCode) {
        case OpenLayers.Event.KEY_RETURN:
            if(!this.visible) return true;

            if(this.suggestions.length == 0 || !this.highlighted) {
              this.hideSuggestions();
              return false;
            }

            this.setHighlightedValue();
                  // Event wird nicht gestoppt und kann weiterverabreitet werden. Z.B. Suche starten.
            return true;
    		case OpenLayers.Event.KEY_ESC:
            this.hideSuggestions();
            // keine weitere Verarbeitung des Events
            OpenLayers.Event.stop(evt, true);
            return true;
        default:
            return true;
    	}
    },

    /**
     * Reafuert auf KeyUp-Events. Ermöglicht dem Nutzer durch Liste der Vorschläge zu scrollen oder die Vorschlagssuche
     * durchzuführen.
     *
     * @param {event} evt - KeyUpEvent
     * @memberOf BKGWebMap.Util.AutoSuggest
     */
    onKeyUp: function(evt) {
    	switch(evt.keyCode) {
    		case OpenLayers.Event.KEY_UP:
            this.showSuggestions();
            this.changeHighlight(evt.keyCode);
    			  return;
    		case OpenLayers.Event.KEY_DOWN:
            this.showSuggestions();
            this.changeHighlight(evt.keyCode);
    			  return;
        case OpenLayers.Event.KEY_RETURN:
            // verhindert erneute Vorschlagssuche bei Auswahl
            return;
    		default:
    			  this.getSuggestions(this.input.value);
            return;
    	}
    },

    /**
     * Verbirgt die Vorschlagliste.
     * @memberOf BKGWebMap.Util.AutoSuggest
     */
    hideSuggestions: function() {
      if(OpenLayers.Element.hasClass(this.div, 'hidden')) return;

      OpenLayers.Element.removeClass(this.div, 'visible');
      OpenLayers.Element.addClass(this.div, 'hidden');
      this.visible = false;
    },

    /**
     * Blendet die Vorschlagliste ein.
     * @memberOf BKGWebMap.Util.AutoSuggest
     */
    showSuggestions: function() {
      if(OpenLayers.Element.hasClass(this.div, 'visible')) return;

      if(!this.ul) {
        this.getSuggestions(this.input.value);
        return;
      }

      OpenLayers.Element.removeClass(this.div, 'hidden');
      OpenLayers.Element.addClass(this.div, 'visible');
      this.visible = true;
    },

    /**
     * Startet die Vorschlagssuche.
     *
     * @param {string} val - der zu suchende Begrif.
     * @memberOf BKGWebMap.Util.AutoSuggest
     */
    getSuggestions: function (val) {
    	// Nur bei Änderungen des Suchbegriffs wird gesucht.
    	if (val == this.sInput)
    		return;

    	// Hat der Suchbegriff die minimale Länge? Wenn nein, dann noch keine Suche.
    	if (val.length < this.minchars) {
    		this.sInput = "";
    		return;
    	}

      // stoppe evtl. laufende Anfragen
      this.cancel();

      // Führe Vorschlagssuche aus
      this.sInput = val;

      // starte Suche verzögert:
    	this.suggestDelayID = setTimeout(
          OpenLayers.Function.bind( function() {
                this.suggestDelayID = null;
                this.response = this.protocol.suggest(this.sInput, {callback: this.setSuggestions, scope: this});
            },
            this
          ),
          this.delay
      );
    },

    /**
     * Setzt die aktuell verfügbaren Vorschläge.
     *
     * @param {BKGWebMap.Protocol.Geoindex} response
     * @memberOf BKGWebMap.Util.AutoSuggest
     */
    setSuggestions: function (response) {
      this.response = null;
    	this.suggestions = response.suggestions;
    	this.createList(this.suggestions);
      this.showSuggestions();
    },

    /**
     * Erzeugt eine HTML-Liste für die Vorschläge
     * @param {Array<object>} arr - Liste der Vorschläge
     * @memberOf BKGWebMap.Util.AutoSuggest
     */
    createList: function(arr) {
    	// Lösche die alte Liste
      this.div.innerHTML = '';

    	// Erzeuge und fülle die neue Liste
    	this.ul = document.createElement("ul");

    	// Fülle die Liste
      var li;
    	for (var i=0;i<arr.length;i++) {
    		var val = arr[i];
    		var span = document.createElement("span");
        span.innerHTML = val.label;

    		var a = document.createElement("a");
        a.href = "#";
        a.name = i+1;
    		a.appendChild(span);

    		li = document.createElement("li");
        li.appendChild(a);

        // registriere Mouseevents
        var events = new OpenLayers.Events(this, a, null, true);
        events.on({
            "click": this.onSuggestionClick,
            "mouseover": this.onMouseHighlight,
            scope: this
        });

        this.ul.appendChild(li);
    	}

    	// no results
    	if (arr.length == 0) {
    		li = document.createElement("li");
        OpenLayers.Element.addClass("as_warning");
        li.appendChild(document.createTextNode(this.noresults));
        this.ul.appendChild( li );
    	}

    	this.div.appendChild( this.ul );

      this.input.parentNode.insertBefore(this.div, this.input.nextSibling);

    	// currently no item is highlighted
    	this.highlighted = 0;
    },

    /**
     * Ändert den ausgewählten Vorschlag per Cursortaste.
     *
     * @param {int} key - Keycode
     * @memberOf BKGWebMap.Util.AutoSuggest
     */
    changeHighlight: function(key) {
	    if (!this.ul || this.suggestions.length == 0) return;

	    var n=0;

	    if (key == OpenLayers.Event.KEY_DOWN)
        n = this.highlighted + 1;
	    else if (key == OpenLayers.Event.KEY_UP)
        n = this.highlighted - 1;

        // gehe an den Anfang der Liste, wenn am Ende
	    if (n > this.ul.childNodes.length) n = 1;
        // deaktiviere Auswahl und stelle
	    if (n < 1) {
		    n = 0;
        this.input.value = this.sInput;
      }

	    this.setHighlight(n);
    },

    /**
     * Event-Handler für Clicks auf Einträge der Vorschlagsliste. Wird auf KeyDown-Enter weitergeleitet.
     * @param {Event} evt - Der Click-Event
     * @memberOf BKGWebMap.Util.AutoSuggest
     */
    onSuggestionClick: function(evt) {
      this.events.triggerEvent('keydown', {keyCode: OpenLayers.Event.KEY_RETURN});
    },

    /**
     * Ändert den ausgewählten Vorschlag per Mouseover.
     *
     * @param {event} evt - Mouseover Event
     * @memberOf BKGWebMap.Util.AutoSuggest
     */
    onMouseHighlight: function(evt) {
      // hole das Listenelement für den Mousover Event
      var li = BKGWebMap.Util.getEventTarget(evt);
      while( li.tagName.toLowerCase() != 'li')
        li = li.parentNode;

      this.setHighlight( BKGWebMap.Util.getIndex(li) + 1 );

      OpenLayers.Event.stop(evt, true);
      return false;
    },

    /**
     * Setzt den hervorgehobenen Vorschlag
     * @param {int} n - Index des ausgewählten Vorschlag
     * @memberOf BKGWebMap.Util.AutoSuggest
     */
    setHighlight: function(n) {
    	if (!this.ul || this.suggestions.length == 0) return;

      if(this.highlighted == n)
        return;

      this.clearHighlight();
      this.highlighted = n;

      if(this.highlighted == 0) return;

      var li = this.ul.childNodes[this.highlighted-1];
      OpenLayers.Element.addClass(li, "highlight");
    },

    /**
     * Löscht die Hervorhebungen in der Vorschlagsliste.
     * @memberOf BKGWebMap.Util.AutoSuggest
     */
    clearHighlight: function() {
	    if (!this.ul) return;

	    if (this.highlighted > 0) {
		    OpenLayers.Element.removeClass(this.ul.childNodes[this.highlighted-1], "highlight");
		    this.highlighted = 0;
	    }
    },

    /**
     * Übernimmt den hervorgehobenen Vorschlag als Suchbegriff
     * @memberOf BKGWebMap.Util.AutoSuggest
     */
    setHighlightedValue: function () {
      if (!this.highlighted) {
        return;
      }

      this.sInput = this.input.value = this.suggestions[ this.highlighted - 1 ].value;

      // Setze Cursor ans Ende (safari)
      this.input.focus();

      if (this.input.selectionStart)
        this.input.setSelectionRange(this.sInput.length, this.sInput.length);

      this.clearSuggestions();
    },

    /**
     * Leert die Vorschlagliste
     * @memberOf BKGWebMap.Util.AutoSuggest
     */
    clearSuggestions: function () {
      this.hideSuggestions();
      this.div.innerHTML = '';
      this.ul = null;
      this.suggestions = [];
      //this.sInput = '';
    },

    /**
     * Stoppt evtl. laufende Anfragen
     * @memberOf BKGWebMap.Util.AutoSuggest
     */
    cancel: function() {
      if(this.suggestDelayID) clearTimeout(this.suggestDelayID);
      if(this.response) this.protocol.abort(this.response);
    },

    CLASS_NAME: "BKGWebMap.Util.AutoSuggest"
});