[autocomplete] new from-scratch autocomplete implementation

This commit is contained in:
Shish 2023-12-24 21:21:25 +00:00
parent 85b1e54904
commit b55b5a0a0f
10 changed files with 222 additions and 345 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,69 +0,0 @@
ul.tagit {
padding: 1px 5px;
overflow: auto;
margin-left: inherit; /* usually we don't want the regular ul margins. */
margin-right: inherit;
}
ul.tagit li {
display: block;
float: left;
margin: 2px 5px 2px 0;
}
ul.tagit li.tagit-choice {
position: relative;
line-height: inherit;
}
input.tagit-hidden-field {
display: none;
}
ul.tagit li.tagit-choice-read-only {
padding: .2em .5em .2em .5em;
}
ul.tagit li.tagit-choice-editable {
padding: .2em 18px .2em .5em;
}
ul.tagit li.tagit-new {
padding: .25em 4px .25em 0;
}
ul.tagit li.tagit-choice a.tagit-label {
cursor: pointer;
text-decoration: none;
}
ul.tagit li.tagit-choice .tagit-close {
cursor: pointer;
position: absolute;
right: .1em;
top: 50%;
margin-top: -8px;
line-height: 17px;
}
/* used for some custom themes that don't need image icons */
ul.tagit li.tagit-choice .tagit-close .text-icon {
display: none;
}
ul.tagit li.tagit-choice input {
display: block;
float: left;
margin: 2px 5px 2px 0;
}
ul.tagit input[type="text"] {
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
-moz-box-shadow: none;
-webkit-box-shadow: none;
box-shadow: none;
border: none;
margin: 0;
padding: 0;
width: inherit;
background-color: inherit;
outline: none;
}

View file

@ -1,18 +0,0 @@
//Removed TAB keybind
;(function(b){b.widget("ui.tagit",{options:{allowDuplicates:!1,caseSensitive:!0,fieldName:"tags",placeholderText:null,readOnly:!1,removeConfirmation:!1,tagLimit:null,availableTags:[],autocomplete:{},showAutocompleteOnFocus:!1,allowSpaces:!1,singleField:!1,singleFieldDelimiter:",",singleFieldNode:null,animate:!0,tabIndex:null,beforeTagAdded:null,afterTagAdded:null,beforeTagRemoved:null,afterTagRemoved:null,onTagClicked:null,onTagLimitExceeded:null,onTagAdded:null,onTagRemoved:null,tagSource:null},_create:function(){var a=
this;this.element.is("input")?(this.tagList=b("<ul></ul>").insertAfter(this.element),this.options.singleField=!0,this.options.singleFieldNode=this.element,this.element.addClass("tagit-hidden-field")):this.tagList=this.element.find("ul, ol").andSelf().last();this.tagInput=b('<input type="text" />').addClass("ui-widget-content");this.options.readOnly&&this.tagInput.attr("disabled","disabled");this.options.tabIndex&&this.tagInput.attr("tabindex",this.options.tabIndex);this.options.placeholderText&&this.tagInput.attr("placeholder",
this.options.placeholderText);this.options.autocomplete.source||(this.options.autocomplete.source=function(a,e){var d=a.term.toLowerCase(),c=b.grep(this.options.availableTags,function(a){return 0===a.toLowerCase().indexOf(d)});this.options.allowDuplicates||(c=this._subtractArray(c,this.assignedTags()));e(c)});this.options.showAutocompleteOnFocus&&(this.tagInput.focus(function(b,d){a._showAutocomplete()}),"undefined"===typeof this.options.autocomplete.minLength&&(this.options.autocomplete.minLength=
0));b.isFunction(this.options.autocomplete.source)&&(this.options.autocomplete.source=b.proxy(this.options.autocomplete.source,this));b.isFunction(this.options.tagSource)&&(this.options.tagSource=b.proxy(this.options.tagSource,this));this.tagList.addClass("tagit").addClass("ui-widget ui-widget-content ui-corner-all").append(b('<li class="tagit-new"></li>').append(this.tagInput)).click(function(d){var c=b(d.target);c.hasClass("tagit-label")?(c=c.closest(".tagit-choice"),c.hasClass("removed")||a._trigger("onTagClicked",
d,{tag:c,tagLabel:a.tagLabel(c)})):a.tagInput.focus()});var c=!1;if(this.options.singleField)if(this.options.singleFieldNode){var d=b(this.options.singleFieldNode),f=d.val().split(this.options.singleFieldDelimiter);d.val("");b.each(f,function(b,d){a.createTag(d,null,!0);c=!0})}else this.options.singleFieldNode=b('<input type="hidden" style="display:none;" value="" name="'+this.options.fieldName+'" />'),this.tagList.after(this.options.singleFieldNode);c||this.tagList.children("li").each(function(){b(this).hasClass("tagit-new")||
(a.createTag(b(this).text(),b(this).attr("class"),!0),b(this).remove())});this.tagInput.keydown(function(c){if(c.which==b.ui.keyCode.BACKSPACE&&""===a.tagInput.val()){var d=a._lastTag();!a.options.removeConfirmation||d.hasClass("remove")?a.removeTag(d):a.options.removeConfirmation&&d.addClass("remove ui-state-highlight")}else a.options.removeConfirmation&&a._lastTag().removeClass("remove ui-state-highlight");if(c.which===b.ui.keyCode.COMMA&&!1===c.shiftKey||c.which===b.ui.keyCode.ENTER||c.which==
c.which==b.ui.keyCode.SPACE&&!0!==a.options.allowSpaces&&('"'!=b.trim(a.tagInput.val()).replace(/^s*/,"").charAt(0)||'"'==b.trim(a.tagInput.val()).charAt(0)&&'"'==b.trim(a.tagInput.val()).charAt(b.trim(a.tagInput.val()).length-1)&&0!==b.trim(a.tagInput.val()).length-1))c.which===b.ui.keyCode.ENTER&&""===a.tagInput.val()||c.preventDefault(),a.options.autocomplete.autoFocus&&a.tagInput.data("autocomplete-open")||(a.tagInput.autocomplete("close"),a.createTag(a._cleanedInput()))}).blur(function(b){a.tagInput.data("autocomplete-open")||
a.createTag(a._cleanedInput())});if(this.options.availableTags||this.options.tagSource||this.options.autocomplete.source)d={select:function(b,c){a.createTag(c.item.value);return!1}},b.extend(d,this.options.autocomplete),d.source=this.options.tagSource||d.source,this.tagInput.autocomplete(d).bind("autocompleteopen.tagit",function(b,c){a.tagInput.data("autocomplete-open",!0)}).bind("autocompleteclose.tagit",function(b,c){a.tagInput.data("autocomplete-open",!1)}),this.tagInput.autocomplete("widget").addClass("tagit-autocomplete")},
destroy:function(){b.Widget.prototype.destroy.call(this);this.element.unbind(".tagit");this.tagList.unbind(".tagit");this.tagInput.removeData("autocomplete-open");this.tagList.removeClass("tagit ui-widget ui-widget-content ui-corner-all tagit-hidden-field");this.element.is("input")?(this.element.removeClass("tagit-hidden-field"),this.tagList.remove()):(this.element.children("li").each(function(){b(this).hasClass("tagit-new")?b(this).remove():(b(this).removeClass("tagit-choice ui-widget-content ui-state-default ui-state-highlight ui-corner-all remove tagit-choice-editable tagit-choice-read-only"),
b(this).text(b(this).children(".tagit-label").text()))}),this.singleFieldNode&&this.singleFieldNode.remove());return this},_cleanedInput:function(){return b.trim(this.tagInput.val().replace(/^"(.*)"$/,"$1"))},_lastTag:function(){return this.tagList.find(".tagit-choice:last:not(.removed)")},_tags:function(){return this.tagList.find(".tagit-choice:not(.removed)")},assignedTags:function(){var a=this,c=[];this.options.singleField?(c=b(this.options.singleFieldNode).val().split(this.options.singleFieldDelimiter),
""===c[0]&&(c=[])):this._tags().each(function(){c.push(a.tagLabel(this))});return c},_updateSingleTagsField:function(a){b(this.options.singleFieldNode).val(a.join(this.options.singleFieldDelimiter)).trigger("change")},_subtractArray:function(a,c){for(var d=[],f=0;f<a.length;f++)-1==b.inArray(a[f],c)&&d.push(a[f]);return d},tagLabel:function(a){return this.options.singleField?b(a).find(".tagit-label:first").text():b(a).find("input:first").val()},_showAutocomplete:function(){this.tagInput.autocomplete("search",
"")},_findTagByLabel:function(a){var c=this,d=null;this._tags().each(function(f){if(c._formatStr(a)==c._formatStr(c.tagLabel(this)))return d=b(this),!1});return d},_isNew:function(a){return!this._findTagByLabel(a)},_formatStr:function(a){return this.options.caseSensitive?a:b.trim(a.toLowerCase())},_effectExists:function(a){return Boolean(b.effects&&(b.effects[a]||b.effects.effect&&b.effects.effect[a]))},createTag:function(a,c,d){var f=this;a=b.trim(a);this.options.preprocessTag&&(a=this.options.preprocessTag(a));
if(""===a)return!1;if(!this.options.allowDuplicates&&!this._isNew(a))return a=this._findTagByLabel(a),!1!==this._trigger("onTagExists",null,{existingTag:a,duringInitialization:d})&&this._effectExists("highlight")&&a.effect("highlight"),!1;if(this.options.tagLimit&&this._tags().length>=this.options.tagLimit)return this._trigger("onTagLimitExceeded",null,{duringInitialization:d}),!1;var g=b(this.options.onTagClicked?'<a class="tagit-label"></a>':'<span class="tagit-label"></span>').text(a),e=b("<li></li>").addClass("tagit-choice ui-widget-content ui-state-default ui-corner-all").addClass(c).append(g);
this.options.readOnly?e.addClass("tagit-choice-read-only"):(e.addClass("tagit-choice-editable"),c=b("<span></span>").addClass("ui-icon ui-icon-close"),c=b('<a><span class="text-icon">\u00d7</span></a>').addClass("tagit-close").append(c).click(function(a){f.removeTag(e)}),e.append(c));this.options.singleField||(g=g.html(),e.append('<input type="hidden" value="'+g+'" name="'+this.options.fieldName+'" class="tagit-hidden-field" />'));!1!==this._trigger("beforeTagAdded",null,{tag:e,tagLabel:this.tagLabel(e),
duringInitialization:d})&&(this.options.singleField&&(g=this.assignedTags(),g.push(a),this._updateSingleTagsField(g)),this._trigger("onTagAdded",null,e),this.tagInput.val(""),this.tagInput.parent().before(e),this._trigger("afterTagAdded",null,{tag:e,tagLabel:this.tagLabel(e),duringInitialization:d}),this.options.showAutocompleteOnFocus&&!d&&setTimeout(function(){f._showAutocomplete()},0))},removeTag:function(a,c){c="undefined"===typeof c?this.options.animate:c;a=b(a);this._trigger("onTagRemoved",
null,a);if(!1!==this._trigger("beforeTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})){if(this.options.singleField){var d=this.assignedTags(),f=this.tagLabel(a),d=b.grep(d,function(a){return a!=f});this._updateSingleTagsField(d)}if(c){a.addClass("removed");var d=this._effectExists("blind")?["blind",{direction:"horizontal"},"fast"]:["fast"],g=this;d.push(function(){a.remove();g._trigger("afterTagRemoved",null,{tag:a,tagLabel:g.tagLabel(a)})});a.fadeOut("fast").hide.apply(a,d).dequeue()}else a.remove(),
this._trigger("afterTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})}},removeTagByLabel:function(a,b){var d=this._findTagByLabel(a);if(!d)throw"No such tag exists with the name '"+a+"'";this.removeTag(d,b)},removeAll:function(){var a=this;this._tags().each(function(b,d){a.removeTag(d,!1)})}})})(jQuery);

View file

@ -1,97 +0,0 @@
/* Optional scoped theme for tag-it which mimics the zendesk widget. */
ul.tagit {
border-style: solid;
border-width: 1px;
border-color: #C6C6C6;
background: inherit;
}
ul.tagit li.tagit-choice {
-moz-border-radius: 6px;
border-radius: 6px;
-webkit-border-radius: 6px;
border: 1px solid #CAD8F3;
background: #DEE7F8 none;
font-weight: normal;
}
ul.tagit li.tagit-choice .tagit-label:not(a) {
color: #555;
}
ul.tagit li.tagit-choice a.tagit-close {
text-decoration: none;
}
ul.tagit li.tagit-choice .tagit-close {
right: .4em;
}
ul.tagit li.tagit-choice .ui-icon {
display: none;
}
ul.tagit li.tagit-choice .tagit-close .text-icon {
display: inline;
font-family: arial, sans-serif;
font-size: 16px;
line-height: 16px;
color: #777;
}
ul.tagit li.tagit-choice:hover, ul.tagit li.tagit-choice.remove {
background-color: #bbcef1;
border-color: #6d95e0;
}
ul.tagit li.tagit-choice a.tagLabel:hover,
ul.tagit li.tagit-choice a.tagit-close .text-icon:hover {
color: #222;
}
ul.tagit input[type="text"] {
color: #333333;
background: none;
}
.ui-widget {
font-size: 1.1em;
}
/* Forked from a jQuery UI theme, so that we don't require the jQuery UI CSS as a dependency. */
.tagit-autocomplete.ui-autocomplete { position: absolute; cursor: default; }
* html .tagit-autocomplete.ui-autocomplete { width:1px; } /* without this, the menu expands to 100% in IE6 */
.tagit-autocomplete.ui-menu {
list-style:none;
padding: 2px;
margin: 0;
display:block;
float: left;
}
.tagit-autocomplete.ui-menu .ui-menu {
margin-top: -3px;
}
.tagit-autocomplete.ui-menu .ui-menu-item {
margin:0;
padding: 0;
zoom: 1;
float: left;
clear: left;
width: 100%;
}
.tagit-autocomplete.ui-menu .ui-menu-item a {
text-decoration:none;
display:block;
padding:.2em .4em;
line-height:1.5;
zoom:1;
}
.tagit-autocomplete .ui-menu .ui-menu-item a.ui-state-hover,
.tagit-autocomplete .ui-menu .ui-menu-item a.ui-state-active {
font-weight: normal;
margin: -1px;
}
.tagit-autocomplete.ui-widget-content { border: 1px solid #aaaaaa; background: #ffffff 50% 50% repeat-x; color: #222222; }
.tagit-autocomplete.ui-corner-all, .tagit-autocomplete .ui-corner-all { -moz-border-radius: 4px; -webkit-border-radius: 4px; -khtml-border-radius: 4px; border-radius: 4px; }
.tagit-autocomplete .ui-state-hover, .tagit-autocomplete .ui-state-focus { border: 1px solid #999999; background: #dadada; font-weight: normal; color: #212121; }
.tagit-autocomplete .ui-state-active { border: 1px solid #aaaaaa; }
.tagit-autocomplete .ui-widget-content { border: 1px solid #aaaaaa; }
.tagit .ui-helper-hidden-accessible { position: absolute !important; clip: rect(1px,1px,1px,1px); }

View file

@ -6,9 +6,6 @@ namespace Shimmie2;
class AutoComplete extends Extension
{
/** @var AutoCompleteTheme */
protected Themelet $theme;
public function get_priority(): int
{
return 30;
@ -28,8 +25,6 @@ class AutoComplete extends Extension
$page->set_mime(MimeType::JSON);
$page->set_data(json_encode($res));
}
$this->theme->build_autocomplete($page);
}
private function complete(string $search, int $limit): array

View file

@ -1,115 +1,211 @@
document.addEventListener('DOMContentLoaded', () => {
var metatags = ['order:id', 'order:width', 'order:height', 'order:filesize', 'order:filename', 'order:favorites'];
let completions_el = document.createElement('ul');
completions_el.className = 'autocomplete_completions';
completions_el.id = 'completions';
$('.autocomplete_tags').tagit({
singleFieldDelimiter: ' ',
beforeTagAdded: function(event, ui) {
if(metatags.indexOf(ui.tagLabel) !== -1) {
ui.tag.addClass('tag-metatag');
} else {
console.log(ui.tagLabel);
// give special class to negative tags
if(ui.tagLabel[0] === '-') {
ui.tag.addClass('tag-negative');
}else{
ui.tag.addClass('tag-positive');
}
}
},
autocomplete : ({
source: function (request, response) {
var ac_metatags = $.map(
$.grep(metatags, function(s) {
// Only show metatags for strings longer than one character
return (request.term.length > 1 && s.indexOf(request.term) === 0);
}),
function(item) {
return {
label : item + ' [metatag]',
value : item
};
}
);
var isNegative = (request.term[0] === '-');
$.ajax({
url: base_href + '/api/internal/autocomplete',
data: {'s': (isNegative ? request.term.substring(1) : request.term)},
dataType : 'json',
type : 'GET',
success : function (data) {
response(
$.merge(ac_metatags,
$.map(data, function (count, item) {
item = (isNegative ? '-'+item : item);
return {
label : item + ' ('+count+')',
value : item
};
})
)
);
},
error : function (request, status, error) {
console.log(error);
}
});
},
minLength: 1
})
});
$('#tag_editor,[name="bulk_tags"]').tagit({
singleFieldDelimiter: ' ',
autocomplete : ({
source: function (request, response) {
$.ajax({
url: base_href + '/api/internal/autocomplete',
data: {'s': request.term},
dataType : 'json',
type : 'GET',
success : function (data) {
response(
$.map(data, function (count, item) {
return {
label : item + ' ('+count+')',
value : item
};
})
);
},
error : function (request, status, error) {
console.log(error);
}
});
},
minLength: 1
})
});
$('.ui-autocomplete-input').keydown(function(e) {
var keyCode = e.keyCode || e.which;
//Stop tags containing space.
if(keyCode === 32) {
e.preventDefault();
var el = $('.ui-widget-content:focus');
//Find the correct element in a page with multiple tagit input boxes.
$('.autocomplete_tags').each(function(_,n){
if (n.parentNode.contains(el[0])){
$(n.parentNode).find('.autocomplete_tags').tagit('createTag', el.val());
/**
* Whenever input changes, look at what word is currently
* being typed, and fetch completions for it.
*
* @param {HTMLInputElement} element
*/
function updateCompletions(element) {
highlightCompletion(element, -1);
let text = element.value;
let pos = element.selectionStart;
// get the word before the cursor
var start = text.lastIndexOf(' ', pos-1);
if(start === -1) {
start = 0;
}
else {
start++; // skip the space
}
var word = text.substring(start, pos);
// search for completions
if(element.completer_timeout !== null) {
clearTimeout(element.completer_timeout);
element.completer_timeout = null;
}
if(word === '') {
element.completions = {};
renderCompletions(element);
}
else {
element.completer_timeout = setTimeout(() => {
fetch(base_href + '/api/internal/autocomplete?s=' + word).then(
(response) => response.json()
).then((json) => {
if(element.selected_completion !== -1) {
return; // user has started to navigate the completions, so don't update them
}
element.completions = json;
renderCompletions(element);
});
$(this).autocomplete('close');
} else if (keyCode === 9) {
e.preventDefault();
}, 250);
renderCompletions(element);
}
}
var tag = $('.tagit-autocomplete[style*=\"display: block\"] > li:focus, .tagit-autocomplete[style*=\"display: block\"] > li:first').first();
if(tag.length){
$(tag).click();
$('.ui-autocomplete-input').val(''); //If tag already exists, make sure to remove duplicate.
}
/**
* Highlight the nth completion
*
* @param {HTMLInputElement} element
* @param {number} n
*/
function highlightCompletion(element, n) {
if(!element.completions) return;
element.selected_completion = Math.min(
Math.max(n, -1),
Object.keys(element.completions).length-1
);
renderCompletions(element);
}
/**
* Render the completion block
*
* @param {HTMLInputElement} element
*/
function renderCompletions(element) {
let completions = element.completions;
let selected_completion = element.selected_completion;
// if there are no completions, remove the completion block
if(Object.keys(completions).length === 0) {
completions_el.remove();
return;
}
// remove all children
while(completions_el.firstChild) {
completions_el.removeChild(completions_el.firstChild);
}
// add children for each completion, with the selected one highlighted
Object.keys(completions).forEach((key, i) => {
let value = completions[key];
let li = document.createElement('li');
li.innerHTML = key + ' (' + value + ')';
if(i === selected_completion) {
li.className = 'selected';
}
// on hover, select the completion
li.addEventListener('mouseover', () => {
highlightCompletion(element, i);
});
// on click, set the completion
// (mousedown is used instead of click because click is
// fired after blur, which causes the completion block to
// be removed before the click event is handled)
li.addEventListener('mousedown', () => {
setCompletion(element, key);
});
completions_el.appendChild(li);
});
// insert the completion block after the element
if(element.parentNode) {
element.parentNode.insertBefore(completions_el, element.nextSibling);
completions_el.style.width = element.clientWidth + 'px';
}
}
/**
* Set the current word to the given completion
*
* @param {HTMLInputElement} element
* @param {string} new_word
*/
function setCompletion(element, new_word) {
let text = element.value;
let pos = element.selectionStart;
// get the word before the cursor
var start = text.lastIndexOf(' ', pos-1);
if(start === -1) {
start = 0;
}
else {
start++; // skip the space
}
var end = text.indexOf(' ', pos);
if(end === -1) {
end = text.length;
}
// replace the word with the completion
new_word += ' ';
element.value = text.substring(0, start) + new_word + text.substring(end);
element.selectionStart = start + new_word.length;
element.selectionEnd = start + new_word.length;
// reset metadata
element.completions = {};
element.selected_completion = -1;
if(element.completer_timeout) {
clearTimeout(element.completer_timeout);
element.completer_timeout = null;
}
}
document.addEventListener('DOMContentLoaded', () => {
// Find all elements with class 'autocomplete_tags'
document.querySelectorAll('.autocomplete_tags').forEach((element) => {
// set metadata
element.completions = {};
element.selected_completion = -1;
element.completer_timeout = null;
// disable built-in autocomplete
element.setAttribute('autocomplete', 'off');
// when element is focused, add completion block
element.addEventListener('focus', () => {
updateCompletions(element);
});
// when element is blurred, remove completion block
element.addEventListener('blur', () => {
// if we are blurring because we are clicking on a completion,
// don't remove the completion block until the click event is done
completions_el.remove();
});
// when cursor is moved, change current completion
document.addEventListener('selectionchange', () => {
// if element is focused
if(document.activeElement === element) {
updateCompletions(element);
}
});
element.addEventListener('keydown', (event) => {
// up / down should select previous / next completion
if(event.code === "ArrowUp") {
event.preventDefault();
highlightCompletion(element, element.selected_completion-1);
}
if(event.code === "ArrowDown") {
event.preventDefault();
highlightCompletion(element, element.selected_completion+1);
}
// if enter is pressed, add the selected completion
if(event.code === "Enter" && element.selected_completion !== -1) {
event.preventDefault();
setCompletion(element, Object.keys(element.completions)[element.selected_completion]);
}
// if escape is pressed, hide the completion block
if(event.code === "Escape") {
completions_el.remove();
}
});
// on change, update completions
element.addEventListener('input', () => {
updateCompletions(element);
});
});
});

View file

@ -1,7 +1,21 @@
#Navigationleft .blockbody { overflow: visible; }
.tagit { background: white !important; border: 1px solid grey !important; cursor: text; }
.tagit-choice { cursor: initial; }
.autocomplete_completions {
position: absolute;
z-index: 1000;
border: 1px solid #ccc;
color: #000;
background-color: #fff;
padding: 5px;
list-style: none;
margin: 0;
padding: 0;
}
.autocomplete_completions LI {
padding: 0.15em;
}
.autocomplete_completions .selected {
background-color: #ccc;
outline: none;
}
input[name=search] ~ input[type=submit] { display: inline-block !important; }
.tag-negative { background: #ff8080 !important; }

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
class AutoCompleteTheme extends Themelet
{
public function build_autocomplete(Page $page)
{
$base_href = get_base_href();
// TODO: AJAX test and fallback.
$page->add_html_header("<script defer src='$base_href/ext/autocomplete/lib/jquery-ui.min.js' type='text/javascript'></script>");
$page->add_html_header("<script defer src='$base_href/ext/autocomplete/lib/tag-it.min.js' type='text/javascript'></script>");
$page->add_html_header('<link rel="stylesheet" type="text/css" href="//ajax.googleapis.com/ajax/libs/jqueryui/1/themes/flick/jquery-ui.css">');
$page->add_html_header("<link rel='stylesheet' type='text/css' href='$base_href/ext/autocomplete/lib/jquery.tagit.css' />");
}
}