diff options
Diffstat (limited to 'program/js/treelist.js')
-rw-r--r-- | program/js/treelist.js | 588 |
1 files changed, 555 insertions, 33 deletions
diff --git a/program/js/treelist.js b/program/js/treelist.js index 353eb6be7..a7cd7cd43 100644 --- a/program/js/treelist.js +++ b/program/js/treelist.js @@ -44,7 +44,11 @@ function rcube_treelist_widget(node, p) scroll_delay: 500, scroll_step: 5, scroll_speed: 20, - check_droptarget: function(node){ return !node.virtual } + save_state: false, + keyboard: true, + tabexit: true, + parent_focus: false, + check_droptarget: function(node) { return !node.virtual; } }, p || {}); var container = $(node), @@ -52,6 +56,9 @@ function rcube_treelist_widget(node, p) indexbyid = {}, selection = null, drag_active = false, + search_active = false, + last_search = '', + has_focus = false, box_coords = {}, item_coords = [], autoexpand_timer, @@ -59,6 +66,13 @@ function rcube_treelist_widget(node, p) body_scroll_top = 0, list_scroll_top = 0, scroll_timer, + searchfield, + tree_state, + ui_droppable, + ui_draggable, + draggable_opts, + droppable_opts, + list_id = (container.attr('id') || p.id_prefix || '0'), me = this; @@ -73,11 +87,16 @@ function rcube_treelist_widget(node, p) this.drag_start = drag_start; this.drag_end = drag_end; this.intersects = intersects; + this.droppable = droppable; + this.draggable = draggable; this.update = update_node; this.insert = insert; this.remove = remove; this.get_item = get_item; + this.get_node = get_node; this.get_selection = get_selection; + this.is_search = is_search; + this.reset_search = reset_search; /////// startup code (constructor) @@ -97,7 +116,11 @@ function rcube_treelist_widget(node, p) e.stopPropagation(); }); - container.on('click', 'li', function(e){ + container.on('click', 'li', function(e) { + // do not select record on checkbox/input click + if ($(e.target).is('input')) + return true; + var node = p.selectable ? indexbyid[dom2id($(this))] : null; if (node && !node.virtual) { select(node.id); @@ -105,6 +128,77 @@ function rcube_treelist_widget(node, p) } }); + // mute clicks on virtual folder links (they need tabindex="0" in order to be selectable by keyboard) + container.on('mousedown', 'a', function(e) { + var link = $(e.target), node = indexbyid[dom2id(link.closest('li'))]; + if (node && node.virtual && !link.attr('href')) { + e.preventDefault(); + e.stopPropagation(); + return false; + } + }); + + // activate search function + if (p.searchbox) { + searchfield = $(p.searchbox).on('keyup', function(e) { + var key = rcube_event.get_keycode(e), + mod = rcube_event.get_modifier(e); + + switch (key) { + case 9: // tab + break; + + case 13: // enter + search(this.value, true); + return rcube_event.cancel(e); + + case 27: // escape + reset_search(); + break; + + case 38: // arrow up + case 37: // left + case 39: // right + case 40: // arrow down + return; // ignore arrow keys + + default: + search(this.value, false); + break; + } + }).attr('autocomplete', 'off'); + + // find the reset button for this search field + searchfield.parent().find('a.reset').click(function(e) { + reset_search(); + return false; + }) + } + + container.on('focusin', function(e){ + // TODO: only accept focus on virtual nodes from keyboard events + has_focus = true; + }) + .on('focusout', function(e){ + has_focus = false; + }); + + container.attr('role', 'tree'); + + $(document.body) + .bind('keydown', keypress); + + // catch focus when clicking the list container area + if (p.parent_focus) { + container.parent(':not(body)').click(function(e) { + if (!has_focus && selection) { + $(get_item(selection)).find(':focusable').first().focus(); + } + else if (!has_focus) { + container.children('li:has(:focusable)').first().find(':focusable').first().focus(); + } + }); + } /////// private methods @@ -126,6 +220,7 @@ function rcube_treelist_widget(node, p) } me.triggerEvent(node.collapsed ? 'collapse' : 'expand', node); + save_state(id, node.collapsed); } } @@ -154,15 +249,24 @@ function rcube_treelist_widget(node, p) function select(id) { if (selection) { - id2dom(selection).removeClass('selected'); + id2dom(selection, true).removeClass('selected').removeAttr('aria-selected'); + if (search_active) + id2dom(selection).removeClass('selected').removeAttr('aria-selected'); selection = null; } - var li = id2dom(id); + if (!id) + return; + + var li = id2dom(id, true); if (li.length) { - li.addClass('selected'); + li.addClass('selected').attr('aria-selected', 'true'); selection = id; // TODO: expand all parent nodes if collapsed + + if (search_active) + id2dom(id).addClass('selected').attr('aria-selected', 'true'); + scroll_to_node(li); } @@ -180,9 +284,17 @@ function rcube_treelist_widget(node, p) /** * Return the DOM element of the list item with the given ID */ - function get_item(id) + function get_node(id) + { + return indexbyid[id]; + } + + /** + * Return the DOM element of the list item with the given ID + */ + function get_item(id, real) { - return id2dom(id).get(0); + return id2dom(id, real).get(0); } /** @@ -191,13 +303,27 @@ function rcube_treelist_widget(node, p) function insert(node, parent_id, sort) { var li, parent_li, - parent_node = parent_id ? indexbyid[parent_id] : null; + parent_node = parent_id ? indexbyid[parent_id] : null + search_ = search_active; + + // ignore, already exists + if (indexbyid[node.id]) { + return; + } + + // apply saved state + state = get_state(node.id, node.collapsed); + if (state !== undefined) { + node.collapsed = state; + } // insert as child of an existing node if (parent_node) { + node.level = parent_node.level + 1; if (!parent_node.children) parent_node.children = []; + search_active = false; parent_node.children.push(node); parent_li = id2dom(parent_id); @@ -210,9 +336,25 @@ function rcube_treelist_widget(node, p) // append new node to parent's child list li = render_node(node, parent_li.children('ul').first()); } + + // list is in search mode + if (search_) { + search_active = search_; + + // add clone to current search results (top level) + if (!li.is(':visible')) { + $('<li>') + .attr('id', li.attr('id') + '--xsR') + .attr('class', li.attr('class')) + .addClass('searchresult__') + .append(li.children().first().clone(true, true)) + .appendTo(container); + } + } } // insert at top level else { + node.level = 0; data.push(node); li = render_node(node, container); } @@ -229,14 +371,32 @@ function rcube_treelist_widget(node, p) */ function update_node(id, updates, sort) { - var li, node = indexbyid[id]; + var li, parent_ul, parent_node, old_parent, + node = indexbyid[id]; if (node) { li = id2dom(id); + parent_ul = li.parent(); + + if (updates.id || updates.html || updates.children || updates.classes || updates.parent) { + if (updates.parent && (parent_node = indexbyid[updates.parent])) { + // remove reference from old parent's child list + if (parent_ul.closest('li').length && (old_parent = indexbyid[dom2id(parent_ul.closest('li'))])) { + old_parent.children = $.grep(old_parent.children, function(elem, i){ return elem.id != node.id; }); + } + + // append to new parent node + parent_ul = id2dom(updates.parent).children('ul').first(); + if (!parent_node.children) + parent_node.children = []; + parent_node.children.push(node); + } + else if (updates.parent !== undefined) { + parent_ul = container; + } - if (updates.id || updates.html || updates.children || updates.classes) { $.extend(node, updates); - render_node(node, li.parent(), li); + li = render_node(node, parent_ul, li); } if (node.id != id) { @@ -276,7 +436,7 @@ function rcube_treelist_widget(node, p) if (sibling) { li.insertAfter(sibling); } - else if (first.id != myid) { + else if (first && first.id != myid) { li.insertBefore(first); } @@ -292,7 +452,7 @@ function rcube_treelist_widget(node, p) var node, li; if (node = indexbyid[id]) { - li = id2dom(id); + li = id2dom(id, true); li.remove(); node.deleted = true; @@ -309,7 +469,7 @@ function rcube_treelist_widget(node, p) */ function update_data() { - data = walk_list(container); + data = walk_list(container, 0); } /** @@ -318,6 +478,7 @@ function rcube_treelist_widget(node, p) function update_dom(node) { var li = id2dom(node.id); + li.attr('aria-expanded', node.collapsed ? 'false' : 'true'); li.children('ul').first()[(node.collapsed ? 'hide' : 'show')](); li.children('div.treetoggle').removeClass('collapsed expanded').addClass(node.collapsed ? 'collapsed' : 'expanded'); me.triggerEvent('toggle', node); @@ -326,7 +487,7 @@ function rcube_treelist_widget(node, p) /** * */ - function reset() + function reset(keep_content) { select(''); @@ -334,7 +495,112 @@ function rcube_treelist_widget(node, p) indexbyid = {}; drag_active = false; - container.html(''); + if (keep_content) { + if (draggable_opts) { + if (ui_draggable) + draggable('destroy'); + draggable(draggable_opts); + } + + if (droppable_opts) { + if (ui_droppable) + droppable('destroy'); + droppable(droppable_opts); + } + + update_data(); + } + else { + container.html(''); + } + + reset_search(); + } + + /** + * + */ + function search(q, enter) + { + q = String(q).toLowerCase(); + + if (!q.length) + return reset_search(); + else if (q == last_search && !enter) + return 0; + + var hits = []; + var search_tree = function(items) { + $.each(items, function(i, node) { + var li, sli; + if (!node.virtual && !node.deleted && String(node.text).toLowerCase().indexOf(q) >= 0 && hits.indexOf(node.id) < 0) { + li = id2dom(node.id); + + // skip already filtered nodes + if (li.data('filtered')) + return; + + sli = $('<li>') + .attr('id', li.attr('id') + '--xsR') + .attr('class', li.attr('class')) + .addClass('searchresult__') + // append all elements like links and inputs, but not sub-trees + .append(li.children(':not(div.treetoggle,ul)').clone(true, true)) + .appendTo(container); + hits.push(node.id); + } + + if (node.children && node.children.length) { + search_tree(node.children); + } + }); + }; + + // reset old search results + if (search_active) { + $(container).children('li.searchresult__').remove(); + search_active = false; + } + + // hide all list items + $(container).children('li').hide().removeClass('selected'); + + // search recursively in tree (to keep sorting order) + search_tree(data); + search_active = true; + last_search = q; + + me.triggerEvent('search', { query: q, last: last_search, count: hits.length, ids: hits, execute: enter||false }); + + return hits.count; + } + + /** + * + */ + function reset_search() + { + if (searchfield) + searchfield.val(''); + + $(container).children('li.searchresult__').remove(); + $(container).children('li').filter(function() { return !$(this).data('filtered'); }).show(); + + search_active = false; + + me.triggerEvent('search', { query: false, last: last_search }); + last_search = ''; + + if (selection) + select(selection); + } + + /** + * + */ + function is_search() + { + return search_active; } /** @@ -350,6 +616,7 @@ function rcube_treelist_widget(node, p) // render child nodes for (var i=0; i < data.length; i++) { + data[i].level = 0; render_node(data[i], container); } @@ -366,10 +633,15 @@ function rcube_treelist_widget(node, p) var li = $('<li>') .attr('id', p.id_prefix + (p.id_encode ? p.id_encode(node.id) : node.id)) - .addClass((node.classes || []).join(' ')); + .attr('role', 'treeitem') + .addClass((node.classes || []).join(' ')) + .data('id', node.id); - if (replace) + if (replace) { replace.replaceWith(li); + if (parent) + li.appendTo(parent); + } else li.appendTo(parent); @@ -378,6 +650,9 @@ function rcube_treelist_widget(node, p) else if (typeof node.html == 'object') li.append(node.html); + if (!node.text) + node.text = li.children().first().text(); + if (node.virtual) li.addClass('virtual'); if (node.id == selection) @@ -385,12 +660,14 @@ function rcube_treelist_widget(node, p) // add child list and toggle icon if (node.children && node.children.length) { + li.attr('aria-expanded', node.collapsed ? 'false' : 'true'); $('<div class="treetoggle '+(node.collapsed ? 'collapsed' : 'expanded') + '"> </div>').appendTo(li); - var ul = $('<ul>').appendTo(li).attr('class', node.childlistclass); + var ul = $('<ul>').appendTo(li).attr('class', node.childlistclass).attr('role', 'group'); if (node.collapsed) ul.hide(); for (var i=0; i < node.children.length; i++) { + node.children[i].level = node.level + 1; render_node(node.children[i], ul); } } @@ -402,17 +679,19 @@ function rcube_treelist_widget(node, p) * Recursively walk the DOM tree and build an internal data structure * representing the skeleton of this tree list. */ - function walk_list(ul) + function walk_list(ul, level) { var result = []; ul.children('li').each(function(i,e){ - var li = $(e), sublist = li.children('ul'); + var state, li = $(e), sublist = li.children('ul'); var node = { id: dom2id(li), - classes: li.attr('class').split(' '), + classes: String(li.attr('class')).split(' '), virtual: li.hasClass('virtual'), + level: level, html: li.children().first().get(0).outerHTML, - children: walk_list(sublist) + text: li.children().first().text(), + children: walk_list(sublist, level+1) } if (sublist.length) { @@ -420,14 +699,39 @@ function rcube_treelist_widget(node, p) } if (node.children.length) { node.collapsed = sublist.css('display') == 'none'; + + // apply saved state + state = get_state(node.id, node.collapsed); + if (state !== undefined) { + node.collapsed = state; + sublist[(state?'hide':'show')](); + } + + if (!li.children('div.treetoggle').length) + $('<div class="treetoggle '+(node.collapsed ? 'collapsed' : 'expanded') + '"> </div>').appendTo(li); + + li.attr('aria-expanded', node.collapsed ? 'false' : 'true'); } if (li.hasClass('selected')) { + li.attr('aria-selected', 'true'); selection = node.id; } + li.data('id', node.id); + + // declare list item as treeitem + li.attr('role', 'treeitem').attr('aria-level', node.level+1); + + // allow virtual nodes to receive focus + if (node.virtual) { + li.children('a:first').attr('tabindex', '0'); + } + result.push(node); indexbyid[node.id] = node; - }) + }); + + ul.attr('role', level == 0 ? 'tree' : 'group'); return result; } @@ -450,17 +754,19 @@ function rcube_treelist_widget(node, p) */ function dom2id(li) { - var domid = li.attr('id').replace(new RegExp('^' + (p.id_prefix) || '%'), ''); + var domid = String(li.attr('id')).replace(new RegExp('^' + (p.id_prefix) || '%'), '').replace(/--xsR$/, ''); return p.id_decode ? p.id_decode(domid) : domid; } /** * Get the <li> element for the given node ID */ - function id2dom(id) + function id2dom(id, real) { - var domid = p.id_encode ? p.id_encode(id) : id; - return $('#' + p.id_prefix + domid); + var domid = p.id_encode ? p.id_encode(id) : id, + suffix = search_active && !real ? '--xsR' : ''; + + return $('#' + p.id_prefix + domid + suffix, container); } /** @@ -476,6 +782,124 @@ function rcube_treelist_widget(node, p) scroller.scrollTop(rel_offset + current_offset); } + /** + * Save node collapse state to localStorage + */ + function save_state(id, collapsed) + { + if (p.save_state && window.rcmail) { + var key = 'treelist-' + list_id; + if (!tree_state) { + tree_state = rcmail.local_storage_get_item(key, {}); + } + + if (tree_state[id] != collapsed) { + tree_state[id] = collapsed; + rcmail.local_storage_set_item(key, tree_state); + } + } + } + + /** + * Read node collapse state from localStorage + */ + function get_state(id) + { + if (p.save_state && window.rcmail) { + if (!tree_state) { + tree_state = rcmail.local_storage_get_item('treelist-' + list_id, {}); + } + return tree_state[id]; + } + + return undefined; + } + + /** + * Handler for keyboard events on treelist + */ + function keypress(e) + { + var target = e.target || {}, + keyCode = rcube_event.get_keycode(e); + + if (!has_focus || target.nodeName == 'INPUT' && keyCode != 38 && keyCode != 40 || target.nodeName == 'TEXTAREA' || target.nodeName == 'SELECT') + return true; + + switch (keyCode) { + case 38: + case 40: + case 63232: // 'up', in safari keypress + case 63233: // 'down', in safari keypress + var li = p.keyboard ? container.find(':focus').closest('li') : []; + if (li.length) { + focus_next(li, (mod = keyCode == 38 || keyCode == 63232 ? -1 : 1)); + } + return rcube_event.cancel(e); + + case 37: // Left arrow key + case 39: // Right arrow key + var id, node, li = container.find(':focus').closest('li'); + if (li.length) { + id = dom2id(li); + node = indexbyid[id]; + if (node && node.children.length && node.collapsed != (keyCode == 37)) + toggle(id, rcube_event.get_modifier(e) == SHIFT_KEY); // toggle subtree + } + return false; + + case 9: // Tab + if (p.keyboard && p.tabexit) { + // jump to last/first item to move focus away from the treelist widget by tab + var limit = rcube_event.get_modifier(e) == SHIFT_KEY ? 'first' : 'last'; + focus_noscroll(container.find('li[role=treeitem]:has(a)')[limit]().find('a:'+limit)); + } + break; + } + + return true; + } + + function focus_next(li, dir, from_child) + { + var mod = dir < 0 ? 'prev' : 'next', + next = li[mod](), limit, parent; + + if (dir > 0 && !from_child && li.children('ul[role=group]:visible').length) { + li.children('ul').children('li:first').find('a:first').focus(); + } + else if (dir < 0 && !from_child && next.children('ul[role=group]:visible').length) { + next.children('ul').children('li:last').find('a:first').focus(); + } + else if (next.length && next.find('a:first').focus().length) { + // focused + } + else { + parent = li.parent().closest('li[role=treeitem]'); + if (parent.length) + if (dir < 0) { + parent.find('a:first').focus(); + } + else { + focus_next(parent, dir, true); + } + } + } + + /** + * Focus the given element without scrolling the list container + */ + function focus_noscroll(elem) + { + if (elem.length) { + var frame = container.parent().get(0) || { scrollTop:0 }, + y = frame.scrollTop || frame.scrollY; + elem.focus(); + frame.scrollTop = y; + } + } + + ///// drag & drop support /** @@ -484,6 +908,11 @@ function rcube_treelist_widget(node, p) */ function drag_start() { + if (drag_active) + return; + + drag_active = true; + var li, item, height, pos = container.offset(); @@ -491,7 +920,6 @@ function rcube_treelist_widget(node, p) list_scroll_top = container.parent().scrollTop(); pos.top += list_scroll_top; - drag_active = true; box_coords = { x1: pos.left, y1: pos.top, @@ -554,6 +982,9 @@ function rcube_treelist_widget(node, p) */ function drag_end() { + if (!drag_active) + return; + drag_active = false; scroll_timer = null; @@ -584,7 +1015,7 @@ function rcube_treelist_widget(node, p) } /** - * Determine if the given mouse coords intersect the list and one if its items + * Determine if the given mouse coords intersect the list and one of its items */ function intersects(mouse, highlight) { @@ -598,13 +1029,14 @@ function rcube_treelist_widget(node, p) // no intersection with list bounding box if (mouse.x < box_coords.x1 || mouse.x >= box_coords.x2 || mouse.top < box_coords.y1 || mouse.top >= box_coords.y2) { // TODO: optimize performance for this operation - $('li.droptarget', container).removeClass('droptarget'); + if (highlight) + $('li.droptarget', container).removeClass('droptarget'); return result; } // check intersection with visible list items - var pos, node; - for (var id in item_coords) { + var id, pos, node; + for (id in item_coords) { pos = item_coords[id]; if (mouse.x >= pos.x1 && mouse.x < pos.x2 && mouse.top >= pos.y1 && mouse.top < pos.y2) { node = indexbyid[id]; @@ -619,6 +1051,8 @@ function rcube_treelist_widget(node, p) expand(autoexpand_item); drag_start(); // re-calculate item coords autoexpand_item = null; + if (ui_droppable) + $.ui.ddmanager.prepareOffsets($.ui.ddmanager.current, null); }, p.autoexpand); } else if (autoexpand_timer && autoexpand_item != id) { @@ -647,6 +1081,94 @@ function rcube_treelist_widget(node, p) return result; } + + /** + * Wrapper for jQuery.UI.droppable() activation on this widget + * + * @param object Options as passed to regular .droppable() function + */ + function droppable(opts) + { + if (!opts) opts = {}; + + if ($.type(opts) == 'string') { + if (opts == 'destroy') { + ui_droppable = null; + } + $('li:not(.virtual)', container).droppable(opts); + return this; + } + + droppable_opts = opts; + + var my_opts = $.extend({ + greedy: true, + tolerance: 'pointer', + hoverClass: 'droptarget', + addClasses: false + }, opts); + + my_opts.activate = function(e, ui) { + drag_start(); + ui_droppable = ui; + if (opts.activate) + opts.activate(e, ui); + }; + + my_opts.deactivate = function(e, ui) { + drag_end(); + ui_droppable = null; + if (opts.deactivate) + opts.deactivate(e, ui); + }; + + my_opts.over = function(e, ui) { + intersects(rcube_event.get_mouse_pos(e), false); + if (opts.over) + opts.over(e, ui); + }; + + $('li:not(.virtual)', container).droppable(my_opts); + + return this; + } + + /** + * Wrapper for jQuery.UI.draggable() activation on this widget + * + * @param object Options as passed to regular .draggable() function + */ + function draggable(opts) + { + if (!opts) opts = {}; + + if ($.type(opts) == 'string') { + if (opts == 'destroy') { + ui_draggable = null; + } + $('li:not(.virtual)', container).draggable(opts); + return this; + } + + draggable_opts = opts; + + var my_opts = $.extend({ + appendTo: 'body', + revert: 'invalid', + iframeFix: true, + addClasses: false, + cursorAt: {left: -20, top: 5}, + create: function(e, ui) { ui_draggable = ui; }, + helper: function(e) { + return $('<div>').attr('id', 'rcmdraglayer') + .text($.trim($(e.target).first().text())); + } + }, opts); + + $('li:not(.virtual)', container).draggable(my_opts); + + return this; + } } // use event processing functions from Roundcube's rcube_event_engine |