Originally we pursued using the
Autocompleter.Local from the
script.aculo.us library. This had some promising results. However I wanted to be able to do a listing of all of the tags in a scrollable field not just the matches to what I typed. For example:
I typed in 'r' and matched 'ruby' but I can also see 'sql' which might also be a good tag to add to the posting I am working on.
This led me to extend
Autocompleter.Base instead and add the desired functionality myself. Adding scrolling proved a bit tricky, mainly because the offsetHeight property of the element tags always returns zero. I think this is because it is inside of an absolutely positioned DIV, but I am not a
CSS guru (believe it or not:-P). I created an AdminAutotag class that is a replica of the
Autocompleter.Local class. The selector function needs to be radically altered to support showing all tags and not just matches. Also in this setup it wouldn't make sense to support partial matches so we will remove that. We can still handle case sensitivity though. Since we want this to eventually to end up in
Typo, instead of putting all of the JavaScript directly on the _form.rhtml partial, lets move it instead to the typo.js file in /public.
public/javascript/typo.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
| AdminAutotag = Class.create();
AdminAutotag.prototype = Object.extend(new Autocompleter.Base(), {
initialize: function(element, update, array, options) {
this.baseInitialize(element, update, options);
this.options.array = array;
},
getUpdatedChoices: function() {
this.updateChoices(this.options.selector(this));
},
setOptions: function(options) {
this.options = Object.extend({
ignoreCase: true,
selector: function(instance) {
var ret = []; var entry = instance.getToken();
var firstMatch= -1;
for (var i = 0; i < instance.options.array.length; i++) {
var wasMatch = false;
var elem = instance.options.array[i];
var foundPos = instance.options.ignoreCase ?
elem.toLowerCase().indexOf(entry.toLowerCase()) : elem.indexOf(entry);
while (foundPos != -1) {
if (foundPos == 0 && elem.length != entry.length) {
wasMatch = true;
if (firstMatch < 0){
firstMatch=i;
instance.index=i;
ret.push("<li class=\"selected\"><strong>" + elem.substr(0, entry.length) + "</strong>" +
elem.substr(entry.length) + "</li>");
} else {
ret.push("<li><strong>" + elem.substr(0, entry.length) + "</strong>" +
elem.substr(entry.length) + "</li>");
}
break;
}
foundPos = instance.options.ignoreCase ?
elem.toLowerCase().indexOf(entry.toLowerCase(), foundPos + 1) :
elem.indexOf(entry, foundPos + 1);
}
if(wasMatch==false){
ret.push("<li>" + elem + "</li>");
}
}
instance.element.autocompleteIndex=firstMatch;
instance.index=firstMatch;
return "<ul>" + ret.join('') + "</ul>";
}
}, options || {});
}
});
|
Besides removing the partial matching code, the primary difference here is that we are pushing out elements whether or not they match (#43-#45). Notice also lines 47 and 48 that set our first match. This how we will keep track of where to scroll to. In fact how are we going to handle that? We will have to control the rendering of the component so we will need to override that method. Our render function will accept a parameter, unlike the base version. This is to handle the
auto-scrolling via mouse pointer capability of the DIV I mentioned before. This will allow us to selectively allow scrolling, i.e. not onMouseover events.
Besides removing the partial matching code, the primary difference here is that we are pushing out elements whether or not they match (#43-#45). Notice also lines 47 and 48 that set our first match. This how we will keep track of where to scroll to. In fact how are we going to handle that? We will have to control the rendering of the component so we will need to override that method. Our render function will accept a parameter, unlike the base version. This is to handle the
auto-scrolling via mouse pointer capability of the DIV I mentioned before. This will allow us to selectively allow scrolling, i.e. not onMouseover events.
Inside AdminAutotag.prototype
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| render: function(scrollActive) {
if(this.index==0 && this.options.picked!=0) this.index = this.element.autocompleteIndex;
if(this.entryCount > 0) {
for (var i = 0; i < this.entryCount; i++)
i==this.index ?
Element.addClassName(this.getEntry(i),"selected") :
Element.removeClassName(this.getEntry(i),"selected");
if(this.hasFocus) {
this.show();
this.active = true;
if(scrollActive==null || scrollActive==true) {
var scrollAmt = this.index > 0 ? (this.index * 24) - 2 : 0;
this.update.scrollTop=scrollAmt;
}
} else {
this.active = false;
this.hide();
}
}
},
|
Due to the aforementioned difficulty in getting the offsetHeight of a single LI in our list there is a bit of a kludge at line #14. The index of the currently selected item times twenty four minus two seemed to work well in the browsers I tested with fairly large numbers of tags. Post a comment if you know the right way to do it. Also at line #2 there is a reference to a field we haven't seen yet (this.options.picked) we need to use this field to keep up with keyboard events...which we will need to override as well. Speaking of events we need to make sure and take care of mouse events by overriding onHover:
Inside AdminAutotag.prototype
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| render: function(scrollActive) {
if(this.index==0 && this.options.picked!=0) this.index = this.element.autocompleteIndex;
if(this.entryCount > 0) {
for (var i = 0; i < this.entryCount; i++)
i==this.index ?
Element.addClassName(this.getEntry(i),"selected") :
Element.removeClassName(this.getEntry(i),"selected");
if(this.hasFocus) {
this.show();
this.active = true;
if(scrollActive==null || scrollActive==true) {
var scrollAmt = this.index > 0 ? (this.index * 24) - 2 : 0;
this.update.scrollTop=scrollAmt;
}
} else {
this.active = false;
this.hide();
}
}
},
|
These are almost the same as their counterparts in Base, but the mark functions now update our this.options.picked, and our onHover passes false to the render method so as not to have you chasing the scrolling list of tags when you use the mouse. Back to our _form.rhtml for the keywords field, it can now be simplified to just this:
<p>
<label for="article_keywords">Keywords:</label><br/>
<%= text_field 'article', 'keywords' %>
<div id="auto_complete"></div>
<script type="text/javascript" language="javascript" charset="utf-8">
opts = new Array(<%= @tags.sort_by{|t| t.display_name}.collect!{|t1| '"' + t1.display_name + '",'} -%>'');
new AdminAutotag('article_keywords','auto_complete', opts , { tokens: new Array(' ',',','\n')});
</script>
</p>
Since we use this functionality in admin, I put the CSS for the autocomplete there:
#auto_complete {
width: 355px;
height: 150px;
background: #ffffff;
overflow: auto;
}
#auto_complete ul {
border: 1px solid #888888;
margin:0;
padding:0;
width: 98%;
list-style-type:none;
}
#auto_complete ul li {
margin:0;
padding:0;
}
#auto_complete ul li.selected {
background-color: #d7dfe4;
}
#auto_complete ul strong.highlight {
color: #880000;
margin:0;
padding:0;
}
That is all there is too it. Seems simple now huh? The above code has been packed up as a patch against
Typo and is detailed in
ticket #727.