Files
server/usr/share/psa-horde/js/prettyautocomplete.js
2026-01-07 20:52:11 +01:00

382 lines
10 KiB
JavaScript

/**
* An autocompleter implementation that provides a UI similar to Apple's Mail
* To: field where entered items are represented as bubbles etc...
*
* @author Michael J Rubinsky <mrubinsk@horde.org>
* @copyright 2008-2015 Horde LLC
* @license LGPL-2.1 (http://www.horde.org/licenses/lgpl21)
*
* @todo H6 Extract common code between this class and IMP's version. Use custom
* events instead of passing handlers.
*/
var PrettyAutocompleter = Class.create({
// Autocompleter
aac: null,
// Delete image element.
dimg: null,
enabled: false,
// For BC. Remove in H6.
selectedItems: [],
// required params:
// deleteImg
// uri
initialize: function(element, params)
{
this.p = Object.extend({
// Outer div/fake input box and CSS class
box: 'HordeACBox',
boxClass: 'hordeACBox',
// <ul> CSS class
listClass: 'hordeACList',
listClassItem: 'hordeACListItem',
// CSS class for real input field
growingInputClass: 'hordeACTrigger',
removeClass: 'hordeACItemRemove',
// Allow for a function that filters the display value
// This function should *always* return escaped HTML
displayFilter: function(t) { return t.escapeHTML(); },
filterCallback: this.filterChoices.bind(this),
onAdd: Prototype.K,
onRemove: Prototype.K,
requireSelection: false,
existing: [],
beforeUpdate: function(i) { return [i]; }
}, params || {});
// The original input element is transformed into the hidden input
// field that hold the text values (this.elm), while p.trigger is
// the borderless input field located in p.box
this.elm = element;
this.p.trigger = element + 'real';
this.initialized = false;
this.enabled = true;
},
/**
* Initializes the autocompleter, builds the dom structure, registers
* events, etc...
*/
init: function()
{
if (this.initialized) {
return;
}
// Build the DOM structure
this.buildStructure();
// Make sure the original input box element is hidden
if (!this.p.debug) {
$(this.elm).hide();
}
// Set the updateElement callback to pass to the Autocompleter.
this.p.onSelect = this.updateElement.bind(this);
// Look for clicks on the box to simulate clicking in an input box
this.box.observe('click', this.clickHandler.bindAsEventListener(this));
this.box.observe('dblclick', this.dblclickHandler.bindAsEventListener(this));
// Remember the bound method to unregister later.
this.boundProcessValue = this.blur.bind(this);
this.input.observe('blur', this.boundProcessValue);
this.input.observe('keydown', this.resize.bind(this));
this.input.observe('keypress', this.resize.bind(this));
this.input.observe('keyup', this.resize.bind(this));
this.input.observe('keydown', this.keyDownHandler.bindAsEventListener(this));
if (this.p.uri) {
// Create the underlaying Autocompleter
this.p.uri += '&input=' + this.p.trigger;
this.p.onShow = this.knlShow.bind(this);
this.p.onHide = this.knlHide.bind(this);
// Make sure the knl is contained in the overlay
this.p.domParent = this.p.box;
this.aac = new Ajax.Autocompleter(this.p.trigger, this.p.uri, this.p);
}
this.initialized = true;
// Prepopulate the items and the container elements?
this.reset(this.p.existing);
},
/**
* Resets the autocompleter's state.
*/
reset: function(existing)
{
if (!this.initialized) {
this.init();
}
this.currentEntries().each(function(elt) {
this.removeItemNode(elt);
}.bind(this));
this.updateInput('');
// Add any initial values
existing = existing || [];
if (existing.length) {
for (var i = 0, l = existing.length; i < l; i++) {
this.addNewItemNode(existing[i]);
}
}
this.enabled = true;
},
buildStructure: function()
{
// Build the outter box
this.box = new Element('div', {
id: this.p.box,
className: this.p.boxClass
}).setStyle({ position: 'relative' });
this.input = new Element('input', {
className: this.p.growingInputClass,
id: this.p.trigger,
name: this.p.trigger,
autocomplete: 'off'
});
this.box.insert(
// The list - where the choosen items are placed as <li> nodes
new Element('ul', { className: this.p.listClass }).insert(
new Element('li').update(this.input)
)
);
// Replace the single input element with the new structure and
// move the old element into the structure while making sure it's
// hidden. (Use the long form to play nice with Opera)
this.box.insert(Element.replace($(this.elm), this.box));
},
processValue: function()
{
var value = $F(this.p.trigger).replace(/^,/, '').strip();
if (value.length) {
if (this.addNewItemNode(value)) {
this.p.onAdd(value);
}
}
},
resize: function()
{
this.input.setStyle({
width: Math.max(80, $F(this.input).length * 9) + 'px'
});
},
// Used as the updateElement callback.
updateElement: function(item)
{
var items = this.p.beforeUpdate(item);
items.each(function(i) {
if (this.addNewItemNode(i)) {
this.p.onAdd(i);
}
}.bind(this));
},
/**
* Adds a new element to the UI, ignoring duplicates.
*
* @return boolean True on success, false on failure/duplicate.
*/
addNewItemNode: function(value)
{
var displayValue;
if (value.empty() || !this.filterChoices([ value ]).size()) {
return false;
}
displayValue = this.p.displayFilter(value);
this.input.up('li').insert({
before: new Element('li', { className: this.p.listClassItem })
.update(displayValue)
.insert(this.deleteImg().clone(true).show())
.store('raw', value)
});
this.updateInput('');
this.updateHiddenInput();
// Remove in H6.
this.updateSelectedItems();
return true;
},
updateSelectedItems: function()
{
this.selectedItems = [];
this.currentValues().each(function(item) {
this.selectedItems.push( { rawValue: item });
}.bind(this));
},
removeItemNode: function(elt)
{
var value = elt.remove().retrieve('raw');
this.updateHiddenInput();
this.p.onRemove(value);
this.updateSelectedItems();
},
deleteImg: function()
{
if (!this.dimg) {
this.dimg = new Element('img', {
className: this.p.removeClass,
src: this.p.deleteIcon
}).hide();
this.box.insert(this.dimg);
}
return this.dimg;
},
updateInput: function(input)
{
var raw;
if (Object.isElement(input)) {
raw = input.retrieve('raw');
this.removeItemNode(input);
} else {
raw = input;
}
this.input.setValue(raw);
this.resize();
},
updateHiddenInput: function()
{
$(this.elm).setValue(this.currentValues().join(', '));
},
currentEntries: function()
{
return this.input.up('ul').select('li.' + this.p.listClassItem);
},
currentValues: function()
{
return this.currentEntries().invoke('retrieve', 'raw');
},
filterChoices: function(c)
{
var cv = this.currentValues();
return c.select(function(v) {
return !cv.include(v);
});
},
disable: function()
{
if (!this.enabled || !this.initialized) {
return;
}
this.enabled = false;
this.box.select('.hordeACItemRemove').invoke('toggle');
this.input.disable();
},
enable: function()
{
if (this.enabled) {
return;
}
this.enabled = true;
this.box.select('.hordeACItemRemove').invoke('toggle');
this.input.enable();
},
honorReturn: function()
{
return this.aac &&
((this.aac.knl && !this.aac.knl.getCurrentEntry()) ||
!this.aac.knl);
},
clickHandler: function(e)
{
var elt = e.element();
if (elt.hasClassName(this.p.removeClass)) {
this.removeItemNode(elt.up('li'));
}
this.input.focus();
},
dblclickHandler: function(e)
{
var elt = e.findElement('li');
if (elt) {
this.addNewItemNode($F(this.input));
this.updateInput(elt);
} else {
this.input.focus();
}
e.stop();
},
keyDownHandler: function(e)
{
var tmp;
switch (e.which || e.keyCode || e.charCode) {
case Event.KEY_DELETE:
case Event.KEY_BACKSPACE:
if (!$F(this.input).length &&
(tmp = this.currentEntries().last())) {
this.updateInput(tmp);
e.stop();
return;
}
}
// Check for a comma or enter
if ((e.keyCode == 188 || (this.honorReturn() && e.keyCode == Event.KEY_RETURN)) && !this.p.requireSelection) {
this.processValue();
e.stop();
} else if (e.keyCode == 188) {
e.stop();
}
},
blur: function()
{
this.processValue();
this.resize();
},
knlShow: function(l)
{
this.input.stopObserving('blur', this.boundProcessValue);
},
knlHide: function(l)
{
this.input.observe('blur', this.boundProcessValue);
},
shutdown: function()
{
this.processValue();
}
});