1/**
2 *  $Id: autosuggest.js 147 2006-12-20 22:53:19Z wingedfox $
3 *  $HeadURL: https://svn.debugger.ru/repos/jslibs/BrowserExtensions/tags/BrowserExtensions.003/dom/autosuggest.js $
4 *
5 *  Extends the selectbox interface
6 *
7 *  @author Ilya Lebedev <ilya@lebedev.net>
8 *  @modified $Date: 2006-12-21 01:53:19 +0300 (Чтв, 21 Дек 2006) $
9 *  @version $Rev: 147 $
10 *  @title Selectbox
11 *  @license LGPL 2.1 or later
12 */
13/**
14 *  Autosuggestion control
15 *
16 *  @constructor
17 *  @param {Object} target
18 */
19var Autosuggest = function (target) {
20    this.$VERSION$ = " $Rev: 147 $ ".replace(/\D/g,"");
21    var self = this;
22    /**
23     *  CSS classes
24     */
25    var cssClasses = {
26        'root' : 'Autosuggest'
27    }
28    /**
29     *  Stores selectbox with suggestions
30     *
31     *  @type HTMLElement
32     *  @scope private
33     */
34    var node = null;
35    /**
36     *  Selectbox controller
37     *
38     *  @type Selectbox
39     *  @scope private
40     */
41    var controller = null;
42    /**
43     *  Config settings
44     *
45     *  @type Object
46     *  @access private
47     */
48    var options = {
49        'minlength' : 2      // minimum filter length to show the box
50       ,'delay'     : 50     // delay before displaying the box
51       ,'size'      : 15     // max select size
52       ,'list'      : []     // array of suggestions
53       ,'place'     : 0      // 1: top side
54                             // 0: bottom side
55       ,'match'     : 'start'// how to filter user input
56    }
57    /**
58     *  Stores the open interval ID to clear it, if needed
59     *
60     *  @type Number
61     *  @scope private
62     */
63    var interval = null;
64    //-------------------------------------------------------------------------
65    //  PUBLICS
66    //-----------------------------------------------------
67    //  Setters
68    //---------------------------------
69    /**
70     *  Defines the draw place
71     *
72     *  @param {String} dir 'top' or 'bottom'
73     *  @return {Boolean}
74     *  @scope public
75     */
76    this.setPlace = function (dir) {
77        if (!isString(dir)) return false;
78        switch (dir.toLowerCase()) {
79            case 'top' :
80                options.place = 1;
81                break;
82            case 'bottom' :
83                options.place = 0;
84                break;
85            default :
86                return false;
87        }
88        return true;
89    }
90    /**
91     *  Set the minimum number of the letters to show the box
92     *
93     *  @return {Number}
94     *  @scope public
95     */
96    this.setMinLength = function (d /* :Number */) /* Boolean */ {
97        if (!isNumeric(parseInt(d))) return false;
98        options.minlength = d;
99        return true;
100    }
101    /**
102     *  Set the delay to show the box
103     *
104     *  @param {Number, String} d delay
105     *  @return {Boolean}
106     *  @scope public
107     */
108    this.setDelay = function (d /* :Number */) /* Boolean */ {
109        if (!isNumeric(parseInt(d))) return false;
110        options.delay = d;
111        return true;
112    }
113    /**
114     *  Set suggestions
115     *
116     *  @param {Array} list of the suggestions
117     *  @return {Boolean}
118     *  @scope public
119     */
120    this.setSuggestions = function (list /* :Array */) /* :Boolean */ {
121        if (!isArray(list)) return false;
122        options.list = list;
123        options.rebuildList = true;
124    }
125    /**
126     *  Adds the filter to the suggestions list
127     *
128     *  @param {String} filter string to filter suggestions
129     *  @return {Boolean}
130     *  @scope public
131     */
132    this.setFilter = function (filter /* :String */) /* :Boolean */ {
133        if (!controller) return false;
134        return controller.showOnlyMatchingOptions(filter, options.match);
135    }
136    /**
137     *  Adds the filter to the suggestions list
138     *
139     *  @see Selectbox#showOnlyMatchingOptions
140     *  @param {String} match option to be sent to the selectbox controller
141     *  @scope public
142     */
143    this.setFilterMatch = function (match /* :String */) {
144        options.match = match;
145    }
146    //---------------------------------
147    //  Getters
148    //---------------------------------
149    /**
150     *  Retrieves the delay to show the box
151     *
152     *  @return {Number}
153     *  @scope public
154     */
155    this.getDelay = function () /* Boolean */ {
156        return options.delay;
157    }
158    /**
159     *  Retrieves the minimum number of the letters to show the box
160     *
161     *  @return {Number}
162     *  @scope public
163     */
164    this.getMinLength = function () /* Boolean */ {
165        return options.minlength;
166    }
167    //---------------------------------
168    //  Misc functions
169    //---------------------------------
170    /**
171     *  Shows the suggestions box
172     *
173     *  @return {Boolean}
174     *  @scope public
175     */
176    this.show = function () /* :Boolean */ {
177        if (!node || self.isVisible()) return false;
178        if (!node.parentNode || node.parentNode.nodeType==11) {
179            document.body.appendChild(node);
180            /*
181            *  where to place the box
182            */
183            var xy = DOM.getOffset(target);
184            node.style.left = xy.x+'px';
185            node.style.top  = xy.y+target.offsetHeight+'px';
186            /*
187            *  attach selectbox controller
188            */
189            controller = new Selectbox(node.firstChild.id);
190        }
191        /*
192        *  do things, only if controller is available
193        */
194        if (controller) {
195            if (options.rebuildList) {
196                controller.addOptionsList(options.list);
197                options.rebuildList = false;
198            }
199            controller.showOnlyMatchingOptions(target.value, options.match);
200            var len = controller.getOptionsLength();
201            if (len == 0) return false;
202            else node.firstChild.size = options.size;
203        } else {
204            return false;
205        }
206        setTimeout(doShow, options.delay);
207        return true;
208    }
209
210    /**
211     *  Hides the suggestions box
212     *
213     *  @return {Boolean}
214     *  @scope public
215     */
216    this.hide = function (e) /* :Boolean */{
217        if (!node || self.isHidden()) return false;
218        target.focus();
219        interval = setTimeout(function() {node.style.display = 'none'; interval=null}, options.delay);
220        return true;
221    }
222    /**
223     *  Tells, if suggest box is visible
224     *
225     *  @return {Booolean}
226     *  @scope public
227     */
228    this.isVisible = function () /* :Boolean */ {
229        return !node.style.display;
230    }
231    /**
232     *  Tells, if suggest box is hidden
233     *
234     *  @return {Booolean}
235     *  @scope public
236     */
237    this.isHidden = function () /* :Boolean */ {
238        return !self.isVisible();
239    }
240    //-------------------------------------------------------------------------
241    //  PRIVATES
242    //-----------------------------------------------------
243    //  Misc functions
244    //---------------------------------
245    /**
246     *  Actual method, showing the box
247     *
248     *  @scope private
249     */
250    var doShow = function () {
251        node.style.visibility = 'hidden';
252        node.style.display = '';
253        var tsp = DOM.getOffset(target)['y']-DOM.getBodyScrollTop()
254           ,ht = DOM.getClientHeight()
255           ,bsp = ht - (DOM.getOffset(target)['y'] + target.offsetHeight-DOM.getBodyScrollTop())
256           ,ct = document.getElementById(controller.getId())
257           ,ttop = DOM.getOffset(target)['y']
258           ,tbot = ttop + target.offsetHeight+'px'
259        if (tsp>ct.parentNode.offsetHeight && options.place) {
260            /*
261            *  default place - top and it's enough space there
262            */
263            ct.parentNode.style.top = tsp-ct.parentNode.offsetHeight+DOM.getBodyScrollTop()+'px';
264        } else if (bsp>ct.parentNode.offsetHeight && !options.place) {
265            /*
266            *  default place - bottom and it's enough space there
267            */
268            ct.parentNode.style.top = DOM.getOffset(target)['y'] + target.offsetHeight+'px';
269        } else {
270            /*
271            *  calculate the needed height
272            */
273            while (ct.size>2 && bsp<ct.parentNode.offsetHeight && tsp<ct.parentNode.offsetHeight) {
274                ct.size--;
275            }
276            if (bsp>=ct.parentNode.offsetHeight || bsp>=tsp) {
277                ct.parentNode.style.top = DOM.getOffset(target)['y'] + target.offsetHeight+'px';
278            } else if (tsp>=ct.parentNode.offsetHeight) {
279                ct.parentNode.style.top = tsp-ct.parentNode.offsetHeight+DOM.getBodyScrollTop()+'px';
280            }
281        }
282        node.style.visibility = '';
283        if (controller.getSelectedIndex()<0) controller.selectOption(0);
284        interval=1;
285    }
286    //---------------------------------
287    //  Event handlers
288    //---------------------------------
289    /**
290     *  Keypress event handler, used to track the input
291     *
292     *  @param {EventTarget} e
293     *  @scope protected
294     */
295    var keypress = function (e) {
296        el = e.target||e.srcElement;
297        switch (e.keyCode) {
298            case 33: //page up
299            case 34: //page down
300            case 36: //home
301            case 35: //end
302            case 38: //up arrow
303            case 40: //down arrow
304                self.show();
305            case 27: // escape
306                if (self.isVisible() && !e.shiftKey) __preventDefault(e);
307                break;
308            case 9:
309            case 13: // enter
310                pasteSuggestion(e);
311                break;
312            case 37:  // left arrow
313            case 39:  // right arrow
314                break;
315            default:
316                if (target.value.length >= options.minlength) self.show()
317                if (controller) {
318                    controller.showOnlyMatchingOptions(target.value, options.match);
319                    if (controller.getSelectedIndex()<0) controller.selectOption(0);
320                }
321                break;
322        }
323    }
324    var pasteSuggestion = function (e) {
325        var el = e.target||e.srcElement;
326        /*
327        *  since we use this handler for both click and keypress events, emulate 'Enter' press
328        */
329        if ('click' == e.type) e.keyCode = 13;
330        switch (e.keyCode) {
331            case 9:  // tab
332            case 13: // enter
333                if (self.hide()) {
334                    target.value = node.firstChild.value;
335                    return __preventDefault(e);
336                }
337            default:
338        }
339        target.focus();
340    }
341    /**
342     *  Prevents some keys to generate default actions
343     *
344     *  @param {EventTarget} e
345     *  @scope protected
346     */
347    var __preventKeyPress = function (e) {
348        switch (e.keyCode) {
349            case 27: //esc
350            case 33: //page up
351            case 34: //page down
352            case 36: //home
353            case 35: //end
354            case 27 : // escape
355                if (self.isHidden()) return;
356            case 38: //up arrow
357            case 40: //down arrow
358            case 37: //left arrow
359            case 39: //right arrow
360                if (!e.shiftKey) return;
361//            case 9: //tab
362            case 13: //enter
363                return __preventDefault(e);
364        }
365    }
366    /**
367     *  Prevents default action for the target
368     *
369     *  @param {EventTarget} el
370     *  @scope private
371     */
372    var __preventDefault = function (e) {
373        e.returnValue = false;
374        if (e.preventDefault) e.preventDefault();
375        return false;
376    }
377    /**
378     *  constructor
379     */
380    var __construct = function () {
381        node = document.createElementExt('div', {'class' : cssClasses.root});
382        node.innerHTML = '<select id="Autosuggest'+(new Date).valueOf()+'" size="10" autocomplete="off"></select>';
383        if (!node) return false;
384        node.style.display = 'none';
385
386        /*
387        *  init target
388        */
389        if (isString(target)) target = document.getElementById(target);
390        target.attachEvent('onkeyup',keypress);
391        target.attachEvent('onkeypress',__preventKeyPress);
392        target.attachEvent('onkeydown',function (e) {
393          switch (e.keyCode) {
394            case 9:
395                pasteSuggestion(e);
396                break;
397            case 27:
398                if (self.hide()) {
399                    return __preventDefault(e);
400                }
401                break;
402            case 32:
403                if (!e.ctrlKey) return;
404            case 38: // key_up
405            case 40: // key_down
406            case 33: //page up
407            case 34: //page down
408                if (self.isHidden()) {
409                    self.show();
410                } else {
411                    if (38 == e.keyCode) controller.selectPrev(true);
412                    else if (40 == e.keyCode) controller.selectNext(true);
413                    else if (33 == e.keyCode) controller.selectOption(controller.getSelectedIndex()-node.firstChild.size, true);
414                    else if (34 == e.keyCode) controller.selectOption(controller.getSelectedIndex()-(-node.firstChild.size), true);
415                }
416                __preventDefault(e);
417                break;
418            case 36: //home
419            case 35: //end
420                if (self.isVisible()) {
421                    if (36 == e.keyCode) controller.selectOption(0,true);
422                    else if (35 == e.keyCode) controller.selectOption(controller.getOptionsLength(), true);
423                    __preventDefault(e);
424                }
425                break;
426            }
427
428        });
429        /*
430        *  keep things persistent, while clicking between the boxes
431        */
432        target.attachEvent('onmousedown', function () {interval = 1;});
433        target.attachEvent('onblur', function(){interval==1?self.hide():interval=2});
434        node.firstChild.attachEvent('onmousedown', function () {interval = 2;});
435        node.firstChild.attachEvent('onkeydown', function () {interval = 1;});
436        node.firstChild.attachEvent('onblur', function(){interval==2?self.hide():interval=1});
437        /*
438        *  turn off damn autocomplete
439        */
440        target.autocomplete = 'off';
441        target.setAttribute('autocomplete','off');
442
443        node.firstChild.attachEvent('onclick', pasteSuggestion);
444        node.firstChild.attachEvent('onkeydown', pasteSuggestion);
445
446    }
447    return __construct();
448}
449