blob: 1cf34553ff47a9ef9d424daf66ba88b8d6f3d5a4 [file] [log] [blame]
var KorAP = KorAP || {};
/**
* Create scrollable drop-down menus.
*
* @author Nils Diewald
*/
(function (KorAP) {
"use strict";
// Don't let events bubble up
if (Event.halt === undefined) {
// Don't let events bubble up
Event.prototype.halt = function () {
this.stopPropagation();
this.preventDefault();
};
};
// Default maximum number of menu items
KorAP.menuLimit = 8;
/**
* List of items for drop down menu (complete).
* Only a sublist of the menu is filtered (live).
* Only a sublist of the filtered menu is visible (shown).
*/
KorAP.Menu = {
/**
* Create new Menu based on the action prefix
* and a list of menu items.
*
* @this {Menu}
* @constructor
* @param {string} Context prefix
* @param {Array.<Array.<string>>} List of menu items
*/
create : function (params) {
return Object.create(KorAP.Menu)._init(params);
},
/**
* Destroy this menu
* (in case you don't trust the
* mark and sweep GC)!
*/
destroy : function () {
if (this._element != undefined)
delete this._element["menu"];
for (var i = 0; i < this._items.length; i++) {
delete this._items[i]["_menu"];
};
delete this._prefix['_menu'];
},
focus : function () {
this._element.focus();
},
// mouse wheel treatment
_mousewheel : function (e) {
var delta = 0;
delta = e.deltaY / 120;
if (delta > 0)
this.next();
else if (delta < 0)
this.prev();
e.halt();
},
// Arrow key and prefix treatment
_keypress : function (e) {
var code = _codeFromEvent(e);
switch (code) {
case 27: // 'Esc'
e.halt();
this.hide();
break;
case 38: // 'Up'
e.halt();
this.prev();
break;
case 33: // 'Page up'
e.halt();
this.prev();
break;
case 40: // 'Down'
e.halt();
this.next();
break;
case 34: // 'Page down'
e.halt();
this.next();
break;
case 39: // 'Right'
if (this._prefix.active())
break;
var item = this.liveItem(this._position);
if (item["further"] !== undefined) {
item["further"].bind(item).apply();
e.halt();
};
break;
case 13: // 'Enter'
// Click on prefix
if (this._prefix.active())
this._prefix.onclick(e);
// Click on item
else
this.liveItem(this._position).onclick(e);
e.halt();
break;
case 8: // 'Backspace'
this._prefix.backspace();
this.show();
e.halt();
break;
default:
if (e.key !== undefined &&
e.key.length != 1)
return;
// Add prefix
this._prefix.add(e.key.toLowerCase());
if (!this.show()) {
this.prefix('').show();
};
};
},
// Initialize list
_init : function (itemClass, prefixClass, params) {
var that = this;
this._itemClass = itemClass;
if (prefixClass !== undefined)
this._prefix = prefixClass.create();
else
this._prefix = KorAP.MenuPrefix.create();
this._prefix._menu = this;
var e = document.createElement("ul");
e.style.opacity = 0;
e.style.outline = 0;
e.setAttribute('tabindex', 0);
e.classList.add('menu');
e.classList.add('roll');
e.appendChild(this._prefix.element());
// This has to be cleaned up later on
e["menu"] = this;
// Arrow keys
e.addEventListener(
'keypress',
function (ev) {
that._keypress(ev)
},
false
);
// Mousewheel
e.addEventListener(
'wheel',
function (ev) {
that._mousewheel(ev)
},
false
);
this._element = e;
this.active = false;
this._items = new Array();
var i;
// Initialize item list based on parameters
for (i in params) {
var obj = itemClass.create(params[i]);
// This may become circular
obj["_menu"] = this;
this._items.push(obj);
};
this._limit = KorAP.menuLimit;
this._position = 0; // position in the active list
this._active = -1; // active item in the item list
this._reset();
return this;
},
/**
* Get the instantiated HTML element
*/
element : function () {
return this._element;
},
/**
* Get the creator object for items
*/
itemClass : function () {
return this._itemClass;
},
/**
* Get and set numerical value for limit,
* i.e. the number of items visible.
*/
limit : function (limit) {
if (arguments.length === 1) {
this._limit = limit;
return this;
};
return this._limit;
},
/**
* Upgrade this object to another object,
* while private data stays intact.
*
* @param {Object} An object with properties.
*/
upgradeTo : function (props) {
for (var prop in props) {
this[prop] = props[prop];
};
return this;
},
// Reset chosen item and prefix
_reset : function () {
this._offset = 0;
this._pos = 0;
this._prefix.value('');
},
/**
* Filter the list and make it visible
*
* @param {string} Prefix for filtering the list
*/
show : function () {
// Initialize the list
if (!this._initList())
return false;
// show based on initial offset
this._showItems(0);
// Set the first element to active
// Todo: Or the last element chosen
this.liveItem(0).active(true);
this._prefix.active(false);
this._active = this._list[0];
this._position = 0;
this._element.style.opacity = 1;
// Add classes for rolling menus
this._boundary(true);
return true;
},
hide : function () {
this.active = false;
this.delete();
this._element.style.opacity = 0;
this.onHide();
/* this._element.blur(); */
},
// To be override
onHide : function () {},
// Initialize the list
_initList : function () {
// Create a new list
if (this._list === undefined) {
this._list = [];
}
else if (this._list.length != 0) {
this._boundary(false);
this._list.length = 0;
};
// Offset is initially zero
this._offset = 0;
// There is no prefix set
if (this.prefix().length <= 0) {
var i = 0;
for (; i < this._items.length; i++)
this._list.push(i);
while (this._items[++i] !== undefined) {
this._items[i].lowlight();
console.log(this._item);
};
return true;
};
// There is a prefix set, so filter the list
var pos;
var paddedPrefix = " " + this.prefix();
// Iterate over all items and choose preferred matching items
// i.e. the matching happens at the word start
for (pos = 0; pos < this._items.length; pos++) {
if ((this.item(pos).lcField().indexOf(paddedPrefix)) >= 0)
this._list.push(pos);
};
// The list is empty - so lower your expectations
// Iterate over all items and choose matching items
// i.e. the matching happens anywhere in the word
if (this._list.length == 0) {
for (pos = 0; pos < this._items.length; pos++) {
if ((this.item(pos).lcField().indexOf(this.prefix())) >= 0)
this._list.push(pos);
};
};
// Filter was successful - yeah!
return this._list.length > 0 ? true : false;
},
// Set boundary for viewport
_boundary : function (bool) {
this.item(this._list[0]).noMore(bool);
this.item(this._list[this._list.length - 1]).noMore(bool);
},
/**
* Get the prefix for filtering,
* e.g. &quot;ve&quot; for &quot;verb&quot;
*/
prefix : function (pref) {
if (arguments.length === 1) {
this._prefix.value(pref);
return this;
};
return this._prefix.value();
},
// Append Items that should be shown
_showItems : function (offset) {
this.delete();
// Use list
var shown = 0;
var i;
for (i in this._list) {
// Don't show - it's before offset
if (shown++ < offset)
continue;
this._append(this._list[i]);
if (shown >= (this.limit() + this._offset))
break;
};
},
/**
* Delete all visible items from the menu element
*/
delete : function () {
var child;
/*
// Iterate over all visible items
for (var i = 0; i <= this.limit(); i++) {
// there is a visible element
// unhighlight!
if (child = this.shownItem(i)) {
child.lowlight();
child.active(false);
};
};
*/
for (var i in this._list) {
var item = this._items[this._list[i]];
item.lowlight();
item.active(false);
};
// Remove all children
var children = this._element.childNodes;
for (var i = children.length - 1; i >= 1; i--) {
this._element.removeChild(
children[i]
);
};
},
// Append item to the shown list based on index
_append : function (i) {
var item = this.item(i);
// Highlight based on prefix
if (this.prefix().length > 0)
item.highlight(this.prefix());
// Append element
this.element().appendChild(item.element());
},
// Prepend item to the shown list based on index
_prepend : function (i) {
var item = this.item(i);
// Highlight based on prefix
if (this.prefix().length > 0)
item.highlight(this.prefix());
var e = this.element();
// Append element
e.insertBefore(
item.element(),
e.children[1]
);
},
/**
* Get a specific item from the complete list
*
* @param {number} index of the list item
*/
item : function (index) {
return this._items[index]
},
/**
* Get a specific item from the filtered list
*
* @param {number} index of the list item
* in the filtered list
*/
liveItem : function (index) {
if (this._list === undefined)
if (!this._initList())
return;
return this._items[this._list[index]];
},
/**
* Get a specific item from the visible list
*
* @param {number} index of the list item
* in the visible list
*/
shownItem : function (index) {
if (index >= this.limit())
return;
return this.liveItem(this._offset + index);
},
/**
* Get the length of the full list
*/
length : function () {
return this._items.length;
},
/**
* Make the next item in the filtered menu active
*/
next : function () {
// No active element set
if (this._position === -1)
return;
var newItem;
// Set new live item
if (!this._prefix.active()) {
var oldItem = this.liveItem(this._position);
oldItem.active(false);
};
this._position++;
newItem = this.liveItem(this._position);
// The next element is undefined - roll to top or to prefix
if (newItem === undefined) {
// Activate prefix
var prefix = this._prefix;
// Mark prefix
if (prefix.isSet() && !prefix.active()) {
this._position--;
prefix.active(true);
return;
}
else {
this._offset = 0;
this._position = 0;
newItem = this.liveItem(0);
this._showItems(0);
};
}
// The next element is outside the view - roll down
else if (this._position >= (this.limit() + this._offset)) {
this._removeFirst();
this._offset++;
this._append(this._list[this._position]);
};
this._prefix.active(false);
newItem.active(true);
},
/*
* Page down to the first item on the next page
*/
/*
nextPage : function () {
// Prefix is active
if (this._prefix.active()) {
this._prefix.active(false);
}
// Last item is chosen
else if (this._position >= this.limit() + this._offset) {
this._position = this.limit() + this._offset - 1;
newItem = this.liveItem(this._position);
var oldItem = this.liveItem(this._position--);
oldItem.active(false);
}
// Last item of page is chosen
else if (0) {
// Jump to last item
else {
var oldItem = this.liveItem(this._position);
oldItem.active(false);
this._position = this.limit() + this._offset - 1;
newItem = this.liveItem(this._position);
};
newItem.active(true);
},
*/
/*
* Make the previous item in the menu active
*/
prev : function () {
// No active element set
if (this._position === -1) {
return;
// TODO: Choose last item
};
var newItem;
// Set new live item
if (!this._prefix.active()) {
var oldItem = this.liveItem(this._position--);
oldItem.active(false);
};
newItem = this.liveItem(this._position);
// The previous element is undefined - roll to bottom
if (newItem === undefined) {
// Activate prefix
var prefix = this._prefix;
this._offset = this.liveLength() - this.limit();
// Normalize offset
this._offset = this._offset < 0 ? 0 : this._offset;
this._position = this.liveLength() - 1;
if (prefix.isSet() && !prefix.active()) {
this._position++;
prefix.active(true);
return;
}
else {
newItem = this.liveItem(this._position);
this._showItems(this._offset);
};
}
// The previous element is outside the view - roll up
else if (this._position < this._offset) {
this._removeLast();
this._offset--;
this._prepend(this._list[this._position]);
};
this._prefix.active(false);
newItem.active(true);
},
// Length of the filtered list
liveLength : function () {
if (this._list === undefined)
this._initList();
return this._list.length;
},
// Remove the HTML node from the first item
_removeFirst : function () {
this.item(this._list[this._offset]).lowlight();
this._element.removeChild(this._element.children[1]);
},
// Remove the HTML node from the last item
_removeLast : function () {
this.item(this._list[this._offset + this.limit() - 1]).lowlight();
this._element.removeChild(this._element.lastChild);
}
};
/**
* Item in the Dropdown menu
*/
KorAP.MenuItem = {
/**
* Create a new MenuItem object.
*
* @constructor
* @this {MenuItem}
* @param {Array.<string>} An array object of name, action and
* optionally a description
*/
create : function (params) {
return Object.create(KorAP.MenuItem)._init(params);
},
/**
* Upgrade this object to another object,
* while private data stays intact.
*
* @param {Object] An object with properties.
*/
upgradeTo : function (props) {
for (var prop in props) {
this[prop] = props[prop];
};
return this;
},
content : function (content) {
if (arguments.length === 1)
this._content = document.createTextNode(content);
return this._content;
},
lcField : function () {
return this._lcField;
},
action : function (action) {
if (arguments.length === 1)
this._action = action;
return this._action;
},
/**
* Check or set if the item is active
*
* @param {boolean|null} State of activity
*/
active : function (bool) {
var cl = this.element().classList;
if (bool === undefined)
return cl.contains("active");
else if (bool)
cl.add("active");
else
cl.remove("active");
},
/**
* Check or set if the item is
* at the boundary of the menu
* list
*
* @param {boolean|null} State of activity
*/
noMore : function (bool) {
var cl = this.element().classList;
if (bool === undefined)
return cl.contains("no-more");
else if (bool)
cl.add("no-more");
else
cl.remove("no-more");
},
/**
* Get the document element of the menu item
*/
element : function () {
// already defined
if (this._element !== undefined)
return this._element;
// Create list item
var li = document.createElement("li");
// Connect action
if (this["onclick"] !== undefined) {
li["onclick"] = this.onclick.bind(this);
};
// Append template
li.appendChild(this.content());
return this._element = li;
},
/**
* Highlight parts of the item
*
* @param {string} Prefix string for highlights
*/
highlight : function (prefix) {
var children = this.element().childNodes;
for (var i = children.length -1; i >= 0; i--) {
this._highlight(children[i], prefix);
};
},
// Highlight a certain substring of the menu item
_highlight : function (elem, prefix) {
if (elem.nodeType === 3) {
var text = elem.nodeValue;
var textlc = text.toLowerCase();
var pos = textlc.indexOf(prefix);
if (pos >= 0) {
// First element
if (pos > 0) {
elem.parentNode.insertBefore(
document.createTextNode(text.substr(0, pos)),
elem
);
};
// Second element
var hl = document.createElement("mark");
hl.appendChild(
document.createTextNode(text.substr(pos, prefix.length))
);
elem.parentNode.insertBefore(hl, elem);
// Third element
var third = text.substr(pos + prefix.length);
if (third.length > 0) {
var thirdE = document.createTextNode(third);
elem.parentNode.insertBefore(
thirdE,
elem
);
this._highlight(thirdE, prefix);
};
var p = elem.parentNode;
p.removeChild(elem);
};
}
else {
var children = elem.childNodes;
for (var i = children.length -1; i >= 0; i--) {
this._highlight(children[i], prefix);
};
};
},
/**
* Remove highlight of the menu item
*/
lowlight : function () {
var e = this.element();
var marks = e.getElementsByTagName("mark");
for (var i = marks.length - 1; i >= 0; i--) {
// Create text node clone
var x = document.createTextNode(
marks[i].firstChild.nodeValue
);
// Replace with content
marks[i].parentNode.replaceChild(
x,
marks[i]
);
};
// Remove consecutive textnodes
e.normalize();
},
// Initialize menu item
_init : function (params) {
if (params[0] === undefined)
throw new Error("Missing parameters");
this.content(params[0]);
if (params.length === 2)
this._action = params[1];
this._lcField = ' ' + this.content().textContent.toLowerCase();
return this;
},
/**
* Return menu list.
*/
menu : function () {
return this._menu;
}
};
KorAP.MenuPrefix = {
create : function (params) {
return Object.create(KorAP.MenuPrefix)._init();
},
_init : function () {
this._string = '';
// Add prefix span
this._element = document.createElement('span');
this._element.classList.add('pref');
// Connect action
if (this["onclick"] !== undefined)
this._element["onclick"] = this.onclick.bind(this);
return this;
},
_update : function () {
this._element.innerHTML
= this._string;
},
/**
* Upgrade this object to another object,
* while private data stays intact.
*
* @param {Object} An object with properties.
*/
upgradeTo : function (props) {
for (var prop in props) {
this[prop] = props[prop];
};
return this;
},
active : function (bool) {
var cl = this.element().classList;
if (bool === undefined)
return cl.contains("active");
else if (bool)
cl.add("active");
else
cl.remove("active");
},
element : function () {
return this._element;
},
isSet : function () {
return this._string.length > 0 ?
true : false;
},
value : function (string) {
if (arguments.length === 1) {
this._string = string;
this._update();
};
return this._string;
},
add : function (string) {
this._string += string;
this._update();
},
onclick : function () {},
backspace : function () {
if (this._string.length > 1) {
this._string = this._string.substring(
0, this._string.length - 1
);
}
else {
this._string = '';
};
this._update();
},
/**
* Return menu list.
*/
menu : function () {
return this._menu;
}
};
function _codeFromEvent (e) {
if (e.charCode && (e.keyCode == 0))
return e.charCode
return e.keyCode;
};
}(this.KorAP));
/**
* MenuItems may define:
*
* onclick: action happen on click and enter.
* further: action happen on right arrow
*/