aboutsummaryrefslogblamecommitdiffstats
path: root/railties/html/javascripts/controls.js
blob: cece0a914b0bf14c11cddd1970adb8f933b07529 (plain) (tree)
1
2
                                                                                
                                                                     



















                                                                         
















                                                                                  




























                                                                   





                                   
                         
 





                                                                   

                                                              



                                                                       



                                                                         


                                                
                                                 
                                                                         


                                                 


                                                           







                                                                                       
 




                                                                                                           

                                                                                           





















                                                                                     
 


































































































                                                                                                     
                              
                         








































































































































































































                                                                                               
   
   
// Copyright (c) 2005 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us)
//           (c) 2005 Ivan Krstic (http://blogs.law.harvard.edu/ivan)
// 
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
// 
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
// 
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Element.collectTextNodesIgnoreClass = function(element, ignoreclass) {
  var children = $(element).childNodes;
  var text     = "";
  var classtest = new RegExp("^([^ ]+ )*" + ignoreclass+ "( [^ ]+)*$","i");
  
  for (var i = 0; i < children.length; i++) {
    if(children[i].nodeType==3) {
      text+=children[i].nodeValue;
    } else {
      if((!children[i].className.match(classtest)) && children[i].hasChildNodes())
        text += Element.collectTextNodesIgnoreClass(children[i], ignoreclass);
    }
  }
  
  return text;
}

// 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.getEntry(), 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: new Array (',', '\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 Autocompleter = {}
Autocompleter.Base = function() {};
Autocompleter.Base.prototype = {
  base_initialize: function(element, update, options) {
    this.element     = $(element); 
    this.update      = $(update);  
    this.has_focus   = false; 
    this.changed     = false; 
    this.active      = false; 
    this.index       = 0;     
    this.entry_count = 0;

    if (this.setOptions)
      this.setOptions(options);
    else
      this.options = {}
     
    this.options.tokens       = this.options.tokens || new Array();
    this.options.frequency    = this.options.frequency || 0.4;
    this.options.min_chars    = this.options.min_chars || 1;
    this.options.onShow       = this.options.onShow || 
    function(element, update){ 
      if(!update.style.position || update.style.position=='absolute') {
        update.style.position = 'absolute';
          var offsets = Position.cumulativeOffset(element);
          update.style.left = offsets[0] + 'px';
          update.style.top  = (offsets[1] + element.offsetHeight) + 'px';
          update.style.width = element.offsetWidth + 'px';
      }
      new Effect.Appear(update,{duration:0.15});
    };
    this.options.onHide = this.options.onHide || 
    function(element, update){ new Effect.Fade(update,{duration:0.15}) };
    
    if(this.options.indicator)
      this.indicator = $(this.options.indicator);

    if (typeof(this.options.tokens) == 'string') 
      this.options.tokens = new Array(this.options.tokens);
       
    this.observer = null;
    
    Element.hide(this.update);
    
    Event.observe(this.element, "blur", this.onBlur.bindAsEventListener(this));
    Event.observe(this.element, "keypress", this.onKeyPress.bindAsEventListener(this));
  },

  show: function() {
    if(this.update.style.display=='none') this.options.onShow(this.element, this.update);
    if(!this.iefix && (navigator.appVersion.indexOf('MSIE')>0) && this.update.style.position=='absolute') {
      new Insertion.After(this.update, 
       '<iframe id="' + this.update.id + '_iefix" '+
       'style="display:none;filter:progid:DXImageTransform.Microsoft.Alpha(opacity=0);" ' +
       'src="javascript:false;" frameborder="0" scrolling="no"></iframe>');
      this.iefix = $(this.update.id+'_iefix');
    }
    if(this.iefix) {
      Position.clone(this.update, this.iefix);
      this.iefix.style.zIndex = 1;
      this.update.style.zIndex = 2;
      Element.show(this.iefix);
    }
  },
  
  hide: function() {
    if(this.update.style.display=='') this.options.onHide(this.element, this.update);
    if(this.iefix) Element.hide(this.iefix);
  },
  
  startIndicator: function() {
    if(this.indicator) Element.show(this.indicator);
  },
  
  stopIndicator: function() {
    if(this.indicator) Element.hide(this.indicator);
  },

  onKeyPress: function(event) {
    if(this.active)
      switch(event.keyCode) {
       case Event.KEY_TAB:
       case Event.KEY_RETURN:
         this.select_entry();
         Event.stop(event);
       case Event.KEY_ESC:
         this.hide();
         this.active = false;
         return;
       case Event.KEY_LEFT:
       case Event.KEY_RIGHT:
         return;
       case Event.KEY_UP:
         this.mark_previous();
         this.render();
         if(navigator.appVersion.indexOf('AppleWebKit')>0) Event.stop(event);
         return;
       case Event.KEY_DOWN:
         this.mark_next();
         this.render();
         if(navigator.appVersion.indexOf('AppleWebKit')>0) Event.stop(event);
         return;
      }
     else 
      if(event.keyCode==Event.KEY_TAB || event.keyCode==Event.KEY_RETURN) 
        return;
    
    this.changed = true;
    this.has_focus = true;
    
    if(this.observer) clearTimeout(this.observer);
      this.observer = 
        setTimeout(this.onObserverEvent.bind(this), this.options.frequency*1000);
  },
  
  onHover: function(event) {
    var element = Event.findElement(event, 'LI');
    if(this.index != element.autocompleteIndex) 
    {
        this.index = element.autocompleteIndex;
        this.render();
    }
    Event.stop(event);
  },
  
  onClick: function(event) {
    var element = Event.findElement(event, 'LI');
    this.index = element.autocompleteIndex;
    this.select_entry();
    Event.stop(event);
  },
  
  onBlur: function(event) {
    // needed to make click events working
    setTimeout(this.hide.bind(this), 250);
    this.has_focus = false;
    this.active = false;     
  }, 
  
  render: function() {
    if(this.entry_count > 0) {
      for (var i = 0; i < this.entry_count; i++)
        this.index==i ? 
          Element.addClassName(this.get_entry(i),"selected") : 
          Element.removeClassName(this.get_entry(i),"selected");
        
      if(this.has_focus) { 
        if(this.get_current_entry().scrollIntoView) 
          this.get_current_entry().scrollIntoView(false);
        
        this.show();
        this.active = true;
      }
    } else this.hide();
  },
  
  mark_previous: function() {
    if(this.index > 0) this.index--
      else this.index = this.entry_count-1;
  },
  
  mark_next: function() {
    if(this.index < this.entry_count-1) this.index++
      else this.index = 0;
  },
  
  get_entry: function(index) {
    return this.update.firstChild.childNodes[index];
  },
  
  get_current_entry: function() {
    return this.get_entry(this.index);
  },
  
  select_entry: function() {
    this.active = false;
    value = Element.collectTextNodesIgnoreClass(this.get_current_entry(), 'informal').unescapeHTML();
    this.updateElement(value);
    this.element.focus();
  },

  updateElement: function(value) {
    var last_token_pos = this.findLastToken();
    if (last_token_pos != -1) {
      var new_value = this.element.value.substr(0, last_token_pos + 1);
      var whitespace = this.element.value.substr(last_token_pos + 1).match(/^\s+/);
      if (whitespace)
        new_value += whitespace[0];
      this.element.value = new_value + value;
    } else {
      this.element.value = value;
    } 
  },
  
  updateChoices: function(choices) {
    if(!this.changed && this.has_focus) {
      this.update.innerHTML = choices;
      Element.cleanWhitespace(this.update);
      Element.cleanWhitespace(this.update.firstChild);

      if(this.update.firstChild && this.update.firstChild.childNodes) {
        this.entry_count = 
          this.update.firstChild.childNodes.length;
        for (var i = 0; i < this.entry_count; i++) {
          entry = this.get_entry(i);
          entry.autocompleteIndex = i;
          this.addObservers(entry);
        }
      } else { 
        this.entry_count = 0;
      }
      
      this.stopIndicator();
      
      this.index = 0;
      this.render();
    }
  },

  addObservers: function(element) {
    Event.observe(element, "mouseover", this.onHover.bindAsEventListener(this));
    Event.observe(element, "click", this.onClick.bindAsEventListener(this));
  },

  onObserverEvent: function() {
    this.changed = false;   
    if(this.getEntry().length>=this.options.min_chars) {
      this.startIndicator();
      this.getUpdatedChoices();
    } else {
      this.active = false;
      this.hide();
    }
  },

  getEntry: function() {
    var token_pos = this.findLastToken();
    if (token_pos != -1)
      var ret = this.element.value.substr(token_pos + 1).replace(/^\s+/,'').replace(/\s+$/,'');
    else
      var ret = this.element.value;
    
    return /\n/.test(ret) ? '' : ret;
  },

  findLastToken: function() {
    var last_token_pos = -1;

    for (var i=0; i<this.options.tokens.length; i++) {
      var this_token_pos = this.element.value.lastIndexOf(this.options.tokens[i]);
      if (this_token_pos > last_token_pos)
        last_token_pos = this_token_pos;
    }
    return last_token_pos;
  }
}

Ajax.Autocompleter = Class.create();
Ajax.Autocompleter.prototype = Object.extend(new Autocompleter.Base(), 
Object.extend(new Ajax.Base(), {
  initialize: function(element, update, url, options) {
	  this.base_initialize(element, update, options);
    this.options.asynchronous  = true;
    this.options.onComplete    = this.onComplete.bind(this)
    this.options.method        = 'post';
    this.options.defaultParams = this.options.parameters || null;
    this.url                   = url;
  },
  
  getUpdatedChoices: function() {
    entry = encodeURIComponent(this.element.name) + '=' + 
      encodeURIComponent(this.getEntry());
      
    this.options.parameters = this.options.callback ?
      this.options.callback(this.element, entry) : entry;
        
    if(this.options.defaultParams) 
      this.options.parameters += '&' + this.options.defaultParams;
    
    new Ajax.Request(this.url, this.options);
  },
  
  onComplete: function(request) {
    this.updateChoices(request.responseText);
  }

}));

// The local array autocompleter. Used when you'd prefer to
// inject an array of autocompletion options into the page, rather
// than sending out Ajax queries, which can be quite slow sometimes.
//
// The constructor takes four parameters. The first two are, as usual,
// the id of the monitored textbox, and id of the autocompletion menu.
// The third is the array you want to autocomplete from, and the fourth
// is the options block.
//
// Extra local autocompletion options:
// - choices - How many autocompletion choices to offer
//
// - partial_search - If false, the autocompleter will match entered
//                    text only at the beginning of strings in the 
//                    autocomplete array. Defaults to true, which will
//                    match text at the beginning of any *word* in the
//                    strings in the autocomplete array. If you want to
//                    search anywhere in the string, additionally set
//                    the option full_search to true (default: off).
//
// - full_search - Search anywhere in autocomplete array strings.
//
// - partial_chars - How many characters to enter before triggering
//                   a partial match (unlike min_chars, which defines
//                   how many characters are required to do any match
//                   at all). Defaults to 2.
//
// - ignore_case - Whether to ignore case when autocompleting.
//                 Defaults to true.
//
// It's possible to pass in a custom function as the 'selector' 
// option, if you prefer to write your own autocompletion logic.
// In that case, the other options above will not apply unless
// you support them.

Autocompleter.Local = Class.create();
Autocompleter.Local.prototype = Object.extend(new Autocompleter.Base(), {
  initialize: function(element, update, array, options) {
    this.base_initialize(element, update, options);
    this.options.array = array;
  },

  getUpdatedChoices: function() {
    this.updateChoices(this.options.selector(this));
  },

  setOptions: function(options) {
    this.options = Object.extend({
      choices: 10,
      partial_search: true,
      partial_chars: 2,
      ignore_case: true,
      full_search: false,
      selector: function(instance) {
        var ret       = new Array(); // Beginning matches
        var partial   = new Array(); // Inside matches
        var entry     = instance.getEntry();
        var count     = 0;
        
        for (var i = 0; i < instance.options.array.length &&  
            ret.length < instance.options.choices ; i++) { 
          var elem = instance.options.array[i];
          var found_pos = instance.options.ignore_case ? 
            elem.toLowerCase().indexOf(entry.toLowerCase()) : 
            elem.indexOf(entry);

          while (found_pos != -1) {
            if (found_pos == 0 && elem.length != entry.length) { 
              ret.push("<li><strong>" + elem.substr(0, entry.length) + "</strong>" + 
                elem.substr(entry.length) + "</li>");
              break;
            } else if (entry.length >= instance.options.partial_chars && 
              instance.options.partial_search && found_pos != -1) {
              if (instance.options.full_search || /\s/.test(elem.substr(found_pos-1,1))) {
                partial.push("<li>" + elem.substr(0, found_pos) + "<strong>" +
                  elem.substr(found_pos, entry.length) + "</strong>" + elem.substr(
                  found_pos + entry.length) + "</li>");
                break;
              }
            }

            found_pos = instance.options.ignore_case ? 
              elem.toLowerCase().indexOf(entry.toLowerCase(), found_pos + 1) : 
              elem.indexOf(entry, found_pos + 1);

          }
        }
        if (partial.length)
          ret = ret.concat(partial.slice(0, instance.options.choices - ret.length))
        return "<ul>" + ret.join('') + "</ul>";
      }
    }, options || {});
  }
});