From e8bcf08c72a18b3bf396e6448d6658227ecb46f2 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Wed, 30 Apr 2014 16:21:29 +0200 Subject: 1. Prepare core and Larry skin for improved accessibility 2. Implement full keyboard navigation in main mail view --- program/js/app.js | 31 +++++++++++++++++--------- program/js/common.js | 20 +++++++++++++++++ program/js/list.js | 60 +++++++++++++++++++++++++++++++++++++++++--------- program/js/treelist.js | 5 +++++ 4 files changed, 95 insertions(+), 21 deletions(-) (limited to 'program/js') diff --git a/program/js/app.js b/program/js/app.js index 2451a6d3d..b2c9209a7 100644 --- a/program/js/app.js +++ b/program/js/app.js @@ -199,6 +199,9 @@ function rcube_webmail() this.enable_command('close', 'logout', 'mail', 'addressbook', 'settings', 'save-pref', 'compose', 'undo', 'about', 'switch-task', 'menu-open', 'menu-save', true); + // set active task button + this.set_button(this.task, 'sel'); + if (this.env.permaurl) this.enable_command('permaurl', 'extwin', true); @@ -233,7 +236,7 @@ function rcube_webmail() }); document.onmouseup = function(e){ return ref.doc_mouse_up(e); }; - this.gui_objects.messagelist.parentNode.onmousedown = function(e){ return ref.click_on_list(e); }; + this.gui_objects.messagelist.parentNode.onclick = function(e){ return ref.click_on_list(e || window.event); }; this.enable_command('toggle_status', 'toggle_flag', 'sort', true); this.enable_command('set-listmode', this.env.threads && !this.is_multifolder_listing()); @@ -1605,9 +1608,9 @@ function rcube_webmail() this.gui_objects.qsearchbox.blur(); if (this.message_list) - this.message_list.focus(); + this.message_list.focus(e); else if (this.contact_list) - this.contact_list.focus(); + this.contact_list.focus(e); return true; }; @@ -1953,10 +1956,12 @@ function rcube_webmail() // build subject link if (cols.subject) { - var action = flags.mbox == this.env.drafts_mailbox ? 'compose' : 'show'; - var uid_param = flags.mbox == this.env.drafts_mailbox ? '_draft_uid' : '_uid'; - cols.subject = ''+cols.subject+''; + var action = flags.mbox == this.env.drafts_mailbox ? 'compose' : 'show', + uid_param = flags.mbox == this.env.drafts_mailbox ? '_draft_uid' : '_uid', + query = { _mbox: flags.mbox }; + query[uid_param] = uid; + cols.subject = ''+cols.subject+''; } // add each submitted col @@ -6182,9 +6187,6 @@ function rcube_webmail() init_button(cmd, this.buttons[cmd][i]); } } - - // set active task button - this.set_button(this.task, 'sel'); }; // set button to a specific state @@ -6197,7 +6199,7 @@ function rcube_webmail() button = a_buttons[n]; obj = document.getElementById(button.id); - if (!obj) + if (!obj || button.status == state) continue; // get default/passive setting of the button @@ -6226,8 +6228,14 @@ function rcube_webmail() obj.disabled = state == 'pas'; } else if (button.type == 'uibutton') { + button.status = state; $(obj).button('option', 'disabled', state == 'pas'); } + else { + $(obj) + .attr('tabindex', state == 'pas' || state == 'sel' ? '-1' : '0') + .attr('aria-disabled', state == 'pas' || state == 'sel' ? 'true' : 'false'); + } } }; @@ -7116,6 +7124,7 @@ function rcube_webmail() this.enable_command('set-listmode', this.env.threads && !is_multifolder); if ((response.action == 'list' || response.action == 'search') && this.message_list) { + this.message_list.focus(); this.msglist_select(this.message_list); this.triggerEvent('listupdate', { folder:this.env.mailbox, rowcount:this.message_list.rowcount }); } diff --git a/program/js/common.js b/program/js/common.js index ff5f9b9bd..e15c34a3b 100644 --- a/program/js/common.js +++ b/program/js/common.js @@ -281,6 +281,26 @@ cancel: function(evt) return false; }, +/** + * Determine whether the given event was trigered from keyboard + */ +is_keyboard: function(e) +{ + return e && ( + (e.mozInputSource && e.mozInputSource == e.MOZ_SOURCE_KEYBOARD) || + (!e.pageX && (e.pageY || 0) <= 0 && !e.clientX && (e.clientY || 0) <= 0) + ); +}, + +/** + * Accept event if triggered from keyboard action (e.g. ) + */ +keyboard_only: function(e) +{ + console.log(e); + return rcube_event.is_keyboard(e) ? true : rcube_event.cancel(e); +}, + touchevent: function(e) { return { pageX:e.pageX, pageY:e.pageY, offsetX:e.pageX - e.target.offsetLeft, offsetY:e.pageY - e.target.offsetTop, target:e.target, istouch:true }; diff --git a/program/js/list.js b/program/js/list.js index 560ee0d9b..b4b775566 100644 --- a/program/js/list.js +++ b/program/js/list.js @@ -98,7 +98,7 @@ init: function() this.rows = {}; this.rowcount = 0; - var r, len, rows = this.tbody.childNodes; + var r, len, rows = this.tbody.childNodes, me = this; for (r=0, len=rows.length; r') + .attr('tabindex', '0') + .attr('style', 'display:block; width:1px; height:1px; line-height:1px; overflow:hidden; position:absolute; top:-1000px') + .html('Select List') + .insertAfter(this.list) + .on('focus', function(e){ me.focus(e); }) + .on('blur', function(e){ me.blur(e); }); + } } return this; @@ -175,9 +186,9 @@ init_header: function() if (this.fixed_header) { // copy (modified) fixed header back to the actual table $(this.list.tHead).replaceWith($(this.fixed_header).find('thead').clone()); - $(this.list.tHead).find('tr td').attr('style', ''); // remove fixed widths + $(this.list.tHead).find('tr td').attr('style', '').find('a.sortcol').attr('tabindex', '-1'); // remove fixed widths } - else if (!bw.touch && this.list.className.indexOf('fixedheader') >= 0) { + else if (!bw.touch && this.list.className.indexOf('fixedheader') >= 0 && 0) { this.init_fixed_header(); } @@ -220,6 +231,12 @@ init_fixed_header: function() $(this.fixed_header).find('thead').replaceWith(clone); } + // avoid scrolling header links being focused + $(this.list.tHead).find('a.sortcol').attr('tabindex', '-1'); + + // set tabindex to fixed header sort links + clone.find('a.sortcol').attr('tabindex', '0'); + this.thead = clone.get(0); this.resize(); }, @@ -265,6 +282,8 @@ clear: function(sel) if (sel) this.clear_selection(); + else + this.last_selected = 0; // reset scroll position (in Opera) if (this.frame) @@ -370,6 +389,9 @@ update_row: function(id, cols, newid, select) */ focus: function(e) { + if (this.focused) + return; + var n, id; this.focused = true; @@ -380,20 +402,26 @@ focus: function(e) } } + if (e) + rcube_event.cancel(e); + // Un-focus already focused elements (#1487123, #1487316, #1488600, #1488620) // It looks that window.focus() does the job for all browsers, but not Firefox (#1489058) - $('iframe,:focus:not(body)').blur(); - window.focus(); + // We now fix this by explicitly assigning focus to a dedicated link element + this.focus_elem.focus(); - if (e || (e = window.event)) - rcube_event.cancel(e); + $(this.list).addClass('focus'); + + // set internal focus pointer to first row + if (!this.last_selected) + this.select_first(CONTROL_KEY); }, /** * remove focus from the list */ -blur: function() +blur: function(e) { var n, id; this.focused = false; @@ -403,6 +431,8 @@ blur: function() $(this.rows[id].obj).removeClass('selected focused').addClass('unfocused'); } } + + $(this.list).removeClass('focus'); }, @@ -1101,8 +1131,10 @@ clear_selection: function(id, no_event) this.selection = []; } - if (num_select && !this.selection.length && !no_event) + if (num_select && !this.selection.length && !no_event) { this.triggerEvent('select'); + this.last_selected = 0; + } }, @@ -1311,9 +1343,17 @@ use_arrow_key: function(keyCode, mod_key) } if (new_row) { + // simulate ctr-key if no rows are selected + if (!mod_key && !this.selection.length) + mod_key = CONTROL_KEY; + this.select_row(new_row.uid, mod_key, false); this.scrollto(new_row.uid); } + else if (!new_row && !selected_row) { + // select the first row if none selected yet + this.select_first(CONTROL_KEY); + } return false; }, diff --git a/program/js/treelist.js b/program/js/treelist.js index 353eb6be7..0dbedd256 100644 --- a/program/js/treelist.js +++ b/program/js/treelist.js @@ -105,6 +105,8 @@ function rcube_treelist_widget(node, p) } }); + container.attr('role', 'tree'); + /////// private methods @@ -425,6 +427,9 @@ function rcube_treelist_widget(node, p) selection = node.id; } + // declare list item as treeitem + li.attr('role', 'treeitem'); + result.push(node); indexbyid[node.id] = node; }) -- cgit v1.2.3 From 2ba491a0f93002424f7848c8a1726a03d857e381 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Thu, 1 May 2014 09:00:55 +0200 Subject: Fix list focus in non-keyboard mode: restore old behavior --- program/js/list.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) (limited to 'program/js') diff --git a/program/js/list.js b/program/js/list.js index b4b775566..14dfde379 100644 --- a/program/js/list.js +++ b/program/js/list.js @@ -406,9 +406,15 @@ focus: function(e) rcube_event.cancel(e); // Un-focus already focused elements (#1487123, #1487316, #1488600, #1488620) - // It looks that window.focus() does the job for all browsers, but not Firefox (#1489058) - // We now fix this by explicitly assigning focus to a dedicated link element - this.focus_elem.focus(); + if (this.focus_elem) { + // We now fix this by explicitly assigning focus to a dedicated link element + this.focus_elem.focus(); + } + else { + // It looks that window.focus() does the job for all browsers, but not Firefox (#1489058) + $('iframe,:focus:not(body)').blur(); + window.focus(); + } $(this.list).addClass('focus'); -- cgit v1.2.3 From eb95518ef9b1bf9160f0e95d45811a4ef8c0e1fb Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Thu, 1 May 2014 10:54:45 +0200 Subject: Enable keyboard navigation on treelist widgets with cursor keys --- program/js/treelist.js | 93 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 4 deletions(-) (limited to 'program/js') diff --git a/program/js/treelist.js b/program/js/treelist.js index 0dbedd256..fa3907894 100644 --- a/program/js/treelist.js +++ b/program/js/treelist.js @@ -52,6 +52,7 @@ function rcube_treelist_widget(node, p) indexbyid = {}, selection = null, drag_active = false, + has_focus = false, box_coords = {}, item_coords = [], autoexpand_timer, @@ -105,8 +106,19 @@ function rcube_treelist_widget(node, p) } }); + 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); + /////// private methods @@ -156,13 +168,13 @@ function rcube_treelist_widget(node, p) function select(id) { if (selection) { - id2dom(selection).removeClass('selected'); + id2dom(selection).removeClass('selected').removeAttr('aria-selected'); selection = null; } var li = id2dom(id); if (li.length) { - li.addClass('selected'); + li.addClass('selected').attr('aria-selected', 'true'); selection = id; // TODO: expand all parent nodes if collapsed scroll_to_node(li); @@ -368,6 +380,7 @@ function rcube_treelist_widget(node, p) var li = $('
  • ') .attr('id', p.id_prefix + (p.id_encode ? p.id_encode(node.id) : node.id)) + .attr('role', 'treeitem') .addClass((node.classes || []).join(' ')); if (replace) @@ -388,7 +401,7 @@ function rcube_treelist_widget(node, p) // add child list and toggle icon if (node.children && node.children.length) { $('
     
    ').appendTo(li); - var ul = $('
      ').appendTo(li).attr('class', node.childlistclass); + var ul = $('
        ').appendTo(li).attr('class', node.childlistclass).attr('role', 'tree'); if (node.collapsed) ul.hide(); @@ -424,15 +437,23 @@ function rcube_treelist_widget(node, p) node.collapsed = sublist.css('display') == 'none'; } if (li.hasClass('selected')) { + li.attr('aria-selected', 'true'); selection = node.id; } // declare list item as treeitem li.attr('role', 'treeitem'); + // 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', 'tree'); return result; } @@ -481,6 +502,70 @@ function rcube_treelist_widget(node, p) scroller.scrollTop(rel_offset + current_offset); } + /** + * 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' || 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 = container.find(':focus').closest('li'); + if (li.length) { + focus_next(li, (mod = keyCode == 38 || keyCode == 63232 ? -1 : 1)); + } + break; + + 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) + toggle(id, rcube_event.get_modifier(e) == SHIFT_KEY); // toggle subtree + } + return false; + } + + 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=tree]:visible').length) { + li.children('ul').children('li:first').children('a:first').focus(); + } + else if (dir < 0 && !from_child && next.children('ul[role=tree]:visible').length) { + next.children('ul').children('li:last').children('a:last').focus(); + } + else if (next.length && next.children('a:first')) { + next.children('a:first').focus(); + } + else { + parent = li.parent().closest('li[role=treeitem]'); + if (parent.length) + if (dir < 0) { + parent.children('a:first').focus(); + } + else { + focus_next(parent, dir, true); + } + } + } + + ///// drag & drop support /** -- cgit v1.2.3 From a539cebdaac8141189f51cb9de87525bb2c8571a Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Thu, 1 May 2014 16:13:58 +0200 Subject: Improve mail view document structure with headings, roles, labels and aria-attributes --- program/js/app.js | 3 +- skins/larry/includes/header.html | 20 +++--- skins/larry/includes/mailtoolbar.html | 20 +++--- skins/larry/mail.css | 10 +++ skins/larry/styles.css | 35 +++++++--- skins/larry/templates/mail.html | 111 +++++++++++++++++------------- skins/larry/templates/message.html | 13 ++-- skins/larry/templates/messagepreview.html | 43 ++++++------ skins/larry/ui.js | 2 + 9 files changed, 156 insertions(+), 101 deletions(-) (limited to 'program/js') diff --git a/program/js/app.js b/program/js/app.js index b2c9209a7..f0aadd970 100644 --- a/program/js/app.js +++ b/program/js/app.js @@ -6359,7 +6359,8 @@ function rcube_webmail() this.messages[key].labels = [{'id': id, 'msg': msg}]; } else { - obj.click(function() { return ref.hide_message(obj); }); + obj.click(function() { return ref.hide_message(obj); }) + .attr('role', 'alert'); } this.triggerEvent('message', { message:msg, type:type, timeout:timeout, object:obj }); diff --git a/skins/larry/includes/header.html b/skins/larry/includes/header.html index 8ce784b02..4692913fa 100644 --- a/skins/larry/includes/header.html +++ b/skins/larry/includes/header.html @@ -1,5 +1,6 @@ -