diff options
Diffstat (limited to 'program')
-rw-r--r-- | program/include/rcmail.php | 9 | ||||
-rw-r--r-- | program/include/rcmail_output_html.php | 33 | ||||
-rw-r--r-- | program/js/app.js | 595 | ||||
-rw-r--r-- | program/js/common.js | 24 | ||||
-rw-r--r-- | program/js/list.js | 171 | ||||
-rw-r--r-- | program/js/treelist.js | 114 | ||||
-rw-r--r-- | program/lib/Roundcube/html.php | 21 | ||||
-rw-r--r-- | program/localization/en_US/labels.inc | 57 | ||||
-rw-r--r-- | program/steps/addressbook/edit.inc | 3 | ||||
-rw-r--r-- | program/steps/addressbook/func.inc | 14 | ||||
-rw-r--r-- | program/steps/addressbook/show.inc | 1 | ||||
-rw-r--r-- | program/steps/mail/compose.inc | 5 | ||||
-rw-r--r-- | program/steps/mail/func.inc | 31 | ||||
-rw-r--r-- | program/steps/mail/list_contacts.inc | 2 | ||||
-rw-r--r-- | program/steps/mail/search_contacts.inc | 2 | ||||
-rw-r--r-- | program/steps/mail/show.inc | 7 | ||||
-rw-r--r-- | program/steps/settings/edit_folder.inc | 3 | ||||
-rw-r--r-- | program/steps/settings/func.inc | 2 | ||||
-rw-r--r-- | program/steps/settings/responses.inc | 2 |
19 files changed, 879 insertions, 217 deletions
diff --git a/program/include/rcmail.php b/program/include/rcmail.php index a9e717b86..1a227927e 100644 --- a/program/include/rcmail.php +++ b/program/include/rcmail.php @@ -1077,14 +1077,17 @@ class rcmail extends rcube } else { foreach ($table_data as $row_data) { - $class = !empty($row_data['class']) ? $row_data['class'] : ''; + $class = !empty($row_data['class']) ? $row_data['class'] : null; + if (!empty($attrib['rowclass'])) + $class = trim($class . ' ' . $attrib['rowclass']); $rowid = 'rcmrow' . rcube_utils::html_identifier($row_data[$id_col]); $table->add_row(array('id' => $rowid, 'class' => $class)); // format each col foreach ($a_show_cols as $col) { - $table->add($col, $this->Q(is_array($row_data[$col]) ? $row_data[$col][0] : $row_data[$col])); + $val = is_array($row_data[$col]) ? $row_data[$col][0] : $row_data[$col]; + $table->add($col, empty($attrib['ishtml']) ? $this->Q($val) : $val); } } } @@ -1490,7 +1493,7 @@ class rcmail extends rcube $html_name = $this->Q($foldername) . ($unread ? html::span('unreadcount', sprintf($attrib['unreadwrap'], $unread)) : ''); $link_attrib = $folder['virtual'] ? array() : array( 'href' => $this->url(array('_mbox' => $folder['id'])), - 'onclick' => sprintf("return %s.command('list','%s',this)", rcmail_output::JS_OBJECT_NAME, $js_name), + 'onclick' => sprintf("return %s.command('list','%s',this,event)", rcmail_output::JS_OBJECT_NAME, $js_name), 'rel' => $folder['id'], 'title' => $title, ); diff --git a/program/include/rcmail_output_html.php b/program/include/rcmail_output_html.php index 43d73a6b4..6594209f6 100644 --- a/program/include/rcmail_output_html.php +++ b/program/include/rcmail_output_html.php @@ -893,6 +893,14 @@ EOF; return ''; } + // localize title and summary attributes + if ($command != 'button' && !empty($attrib['title']) && $this->app->text_exists($attrib['title'])) { + $attrib['title'] = $this->app->gettext($attrib['title']); + } + if ($command != 'button' && !empty($attrib['summary']) && $this->app->text_exists($attrib['summary'])) { + $attrib['summary'] = $this->app->gettext($attrib['summary']); + } + // execute command switch ($command) { // return a button @@ -1165,6 +1173,17 @@ EOF; $attrib['alt'] = html::quote($this->app->gettext($attrib['alt'], $attrib['domain'])); } + // set accessibility attributes + if (!$attrib['role']) { + $attrib['role'] = 'button'; + } + if (!empty($attrib['class']) && !empty($attrib['classact']) || !empty($attrib['imagepas']) && !empty($attrib['imageact'])) { + if (array_key_exists('tabindex', $attrib)) + $attrib['data-tabindex'] = $attrib['tabindex']; + $attrib['tabindex'] = '-1'; // disable button by default + $attrib['aria-disabled'] = 'true'; + } + // set title to alt attribute for IE browsers if ($this->browser->ie && !$attrib['title'] && $attrib['alt']) { $attrib['title'] = $attrib['alt']; @@ -1353,6 +1372,20 @@ EOF; $is_empty = true; } + // set default page title + if (empty($this->pagetitle)) { + $this->pagetitle = 'Roundcube Mail'; + } + + // declare page language + if (!empty($_SESSION['language'])) { + $lang = substr($_SESSION['language'], 0, 2); + $output = preg_replace('/<html/', '<html lang="' . html::quote($lang) . '"', $output, 1); + if (!headers_sent()) { + header('Content-Language: ' . $lang); + } + } + // replace specialchars in content $page_title = html::quote($this->pagetitle); $page_header = ''; diff --git a/program/js/app.js b/program/js/app.js index 11204ffb2..d12dd81ca 100644 --- a/program/js/app.js +++ b/program/js/app.js @@ -46,6 +46,7 @@ function rcube_webmail() this.messages = {}; this.group2expand = {}; this.http_request_jobs = {}; + this.menu_stack = new Array(); // webmail client settings this.dblclick_time = 500; @@ -197,7 +198,10 @@ function rcube_webmail() // enable general commands this.enable_command('close', 'logout', 'mail', 'addressbook', 'settings', 'save-pref', - 'compose', 'undo', 'about', 'switch-task', 'menu-open', 'menu-save', true); + 'compose', 'undo', 'about', 'switch-task', 'menu-open', 'menu-close', 'menu-save', true); + + // set active task button + this.set_button(this.task, 'sel'); if (this.env.permaurl) this.enable_command('permaurl', 'extwin', true); @@ -232,8 +236,7 @@ function rcube_webmail() return ref.command('sort', $(this).attr('rel'), this); }); - 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()); @@ -276,7 +279,7 @@ function rcube_webmail() this.env.address_group_stack = []; this.env.compose_commands = ['send-attachment', 'remove-attachment', 'send', 'cancel', 'toggle-editor', 'list-adresses', 'pushgroup', 'search', 'reset-search', 'extwin', - 'insert-response', 'save-response']; + 'insert-response', 'save-response', 'menu-open', 'menu-close']; if (this.env.drafts_mailbox) this.env.compose_commands.push('savedraft') @@ -302,10 +305,12 @@ function rcube_webmail() $('a.insertresponse', this.gui_objects.responseslist) .attr('unselectable', 'on') .mousedown(function(e){ return rcube_event.cancel(e); }) - .mouseup(function(e){ - ref.command('insert-response', $(this).attr('rel')); - $(document.body).trigger('mouseup'); // hides the menu - return rcube_event.cancel(e); + .bind('mouseup keypress', function(e){ + if (e.type == 'mouseup' || rcube_event.get_keycode(e) == 13) { + ref.command('insert-response', $(this).attr('rel')); + $(document.body).trigger('mouseup'); // hides the menu + return rcube_event.cancel(e); + } }); // avoid textarea loosing focus when hitting the save-response button/link @@ -314,8 +319,6 @@ function rcube_webmail() } } - document.onmouseup = function(e){ return ref.doc_mouse_up(e); }; - // init message compose form this.init_messageform(); } @@ -339,11 +342,21 @@ function rcube_webmail() // init address book widget if (this.gui_objects.contactslist) { this.contact_list = new rcube_list_widget(this.gui_objects.contactslist, - { multiselect:true, draggable:false, keyboard:false }); + { multiselect:true, draggable:false, keyboard:true }); this.contact_list .addEventListener('initrow', function(o) { ref.triggerEvent('insertrow', { cid:o.uid, row:o }); }) .addEventListener('select', function(o) { ref.compose_recipient_select(o); }) .addEventListener('dblclick', function(o) { ref.compose_add_recipient('to'); }) + .addEventListener('keypress', function(o) { + if (o.key_pressed == o.ENTER_KEY) { + if (!ref.compose_add_recipient('to')) { + // execute link action on <enter> if not a recipient entry + if (o.last_selected && String(o.last_selected).charAt(0) == 'G') { + $(o.rows[o.last_selected].obj).find('a').first().click(); + } + } + } + }) .init(); } @@ -394,7 +407,6 @@ function rcube_webmail() this.contact_list.highlight_row(this.env.cid); this.gui_objects.contactslist.parentNode.onmousedown = function(e){ return ref.click_on_list(e); }; - document.onmouseup = function(e){ return ref.doc_mouse_up(e); }; $(this.gui_objects.qsearchbox).focusin(function() { ref.contact_list.blur(); }); @@ -449,9 +461,14 @@ function rcube_webmail() if (this.gui_objects.identitieslist) { this.identity_list = new rcube_list_widget(this.gui_objects.identitieslist, - {multiselect:false, draggable:false, keyboard:false}); + {multiselect:false, draggable:false, keyboard:true}); this.identity_list .addEventListener('select', function(o) { ref.identity_select(o); }) + .addEventListener('keypress', function(o) { + if (o.key_pressed == o.ENTER_KEY) { + ref.identity_select(o); + } + }) .init() .focus(); @@ -459,9 +476,10 @@ function rcube_webmail() this.identity_list.highlight_row(this.env.iid); } else if (this.gui_objects.sectionslist) { - this.sections_list = new rcube_list_widget(this.gui_objects.sectionslist, {multiselect:false, draggable:false, keyboard:false}); + this.sections_list = new rcube_list_widget(this.gui_objects.sectionslist, {multiselect:false, draggable:false, keyboard:true}); this.sections_list .addEventListener('select', function(o) { ref.section_select(o); }) + .addEventListener('keypress', function(o) { if (o.key_pressed == o.ENTER_KEY) ref.section_select(o); }) .init() .focus(); } @@ -469,7 +487,7 @@ function rcube_webmail() this.init_subscription_list(); } else if (this.gui_objects.responseslist) { - this.responses_list = new rcube_list_widget(this.gui_objects.responseslist, {multiselect:false, draggable:false, keyboard:false}); + this.responses_list = new rcube_list_widget(this.gui_objects.responseslist, {multiselect:false, draggable:false, keyboard:true}); this.responses_list .addEventListener('select', function(list) { var win, id = list.get_single_selection(); @@ -559,6 +577,18 @@ function rcube_webmail() .get(0).addEventListener('drop', function(e){ return ref.file_dropped(e); }, false); } + // catch document (and iframe) mouse clicks + var body_mouseup = function(e){ return ref.doc_mouse_up(e); }; + $(document.body) + .bind('mouseup', body_mouseup) + .bind('keydown', function(e){ return ref.doc_keypress(e); }); + + $('iframe').load(function(e) { + try { $(this.contentDocument || this.contentWindow).on('mouseup', body_mouseup); } + catch (e) {/* catch possible "Permission denied" error in IE */ } + }) + .contents().on('mouseup', body_mouseup); + // trigger init event hook this.triggerEvent('init', { task:this.task, action:this.env.action }); @@ -591,7 +621,7 @@ function rcube_webmail() { var ret, uid, cid, url, flag, aborted = false; - if (obj && obj.blur) + if (obj && obj.blur && !(event || rcube_event.is_keyboard(event))) obj.blur(); // do nothing if interface is locked by other command (with exception for searching reset) @@ -634,8 +664,8 @@ function rcube_webmail() } // trigger plugin hooks - this.triggerEvent('actionbefore', {props:props, action:command}); - ret = this.triggerEvent('before'+command, props); + this.triggerEvent('actionbefore', {props:props, action:command, originalEvent:event}); + ret = this.triggerEvent('before'+command, props || event); if (ret !== undefined) { // abort if one of the handlers returned false if (ret === false) @@ -707,9 +737,15 @@ function rcube_webmail() var mimetype = this.env.attachments[props.id]; this.enable_command('open-attachment', mimetype && this.env.mimetypes && $.inArray(mimetype, this.env.mimetypes) >= 0); } + this.show_menu(props, props.show || undefined, event); + break; + + case 'menu-close': + this.hide_menu(props, event); + break; case 'menu-save': - this.triggerEvent(command, {props:props}); + this.triggerEvent(command, {props:props, originalEvent:event}); return false; case 'open': @@ -887,14 +923,14 @@ function rcube_webmail() case 'move': case 'moveto': // deprecated if (this.task == 'mail') - this.move_messages(props, obj); + this.move_messages(props, event); else if (this.task == 'addressbook') this.move_contacts(props); break; case 'copy': if (this.task == 'mail') - this.copy_messages(props, obj); + this.copy_messages(props, event); else if (this.task == 'addressbook') this.copy_contacts(props); break; @@ -1450,7 +1486,8 @@ function rcube_webmail() if (menu && modkey == SHIFT_KEY && this.commands['copy']) { var pos = rcube_event.get_mouse_pos(e); this.env.drag_target = target; - $(menu).css({top: (pos.y-10)+'px', left: (pos.x-10)+'px'}).show(); + this.show_menu(this.gui_objects.dragmenu.id, true, e); + $(menu).css({top: (pos.y-10)+'px', left: (pos.x-10)+'px'}); return true; } @@ -1566,17 +1603,22 @@ function rcube_webmail() } }; + // global mouse-click handler to cleanup some UI elements this.doc_mouse_up = function(e) { - var list, id; + var list, id, target = rcube_event.get_target(e); // ignore event if jquery UI dialog is open - if ($(rcube_event.get_target(e)).closest('.ui-dialog, .ui-widget-overlay').length) + if ($(target).closest('.ui-dialog, .ui-widget-overlay').length) return; - list = this.message_list || this.contact_list; - if (list && !rcube_mouse_is_over(e, list.list.parentNode)) - list.blur(); + // remove focus from list widgets + if (window.rcube_list_widget && rcube_list_widget._instances.length) { + $.each(rcube_list_widget._instances, function(i,list){ + if (list && !rcube_mouse_is_over(e, list.list.parentNode)) + list.blur(); + }); + } // reset 'pressed' buttons if (this.buttons_sel) { @@ -1585,17 +1627,87 @@ function rcube_webmail() this.button_out(this.buttons_sel[id], id); this.buttons_sel = {}; } + + // reset popup menus; delayed to have updated menu_stack data + window.setTimeout(function(e){ + var obj, skip, config, id, i, parents = $(target).parents(); + for (i = ref.menu_stack.length - 1; i >= 0; i--) { + id = ref.menu_stack[i]; + obj = $('#' + id); + + if (obj.is(':visible') + && target != obj.data('opener') + && target != obj.get(0) // check if scroll bar was clicked (#1489832) + && !parents.is(obj.data('opener')) + && id != skip + && (obj.attr('data-editable') != 'true' || !$(target).parents('#' + id).length) + && (obj.attr('data-sticky') != 'true' || !rcube_mouse_is_over(e, obj.get(0))) + ) { + ref.hide_menu(id, e); + } + skip = obj.data('parent'); + } + }, 10); }; + // global keypress event handler + this.doc_keypress = function(e) + { + // Helper method to move focus to the next/prev active menu item + var focus_menu_item = function(dir) { + var obj, item, mod = dir < 0 ? 'prevAll' : 'nextAll', limit = dir < 0 ? 'last' : 'first'; + if (ref.focused_menu && (obj = $('#'+ref.focused_menu))) { + item = obj.find(':focus').closest('li')[mod](':has(:not([aria-disabled=true]))').find('a,input')[limit](); + if (!item.length) + item = obj.find(':focus').closest('ul')[mod](':has(:not([aria-disabled=true]))').find('a,input')[limit](); + return item.focus().length; + } + + return 0; + }; + + var target = e.target || {}, + keyCode = rcube_event.get_keycode(e); + + if (e.keyCode != 27 && (!this.menu_keyboard_active || 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 + focus_menu_item(mod = keyCode == 38 || keyCode == 63232 ? -1 : 1); + break; + + case 9: // tab + if (this.focused_menu) { + var mod = rcube_event.get_modifier(e); + if (!focus_menu_item(mod == SHIFT_KEY ? -1 : 1)) { + this.hide_menu(this.focused_menu, e); + } + } + return rcube_event.cancel(e); + + case 27: // esc + if (this.menu_stack.length) + this.hide_menu(this.menu_stack[this.menu_stack.length-1], e); + break; + } + + return true; + } + this.click_on_list = function(e) { if (this.gui_objects.qsearchbox) 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; }; @@ -1880,7 +1992,7 @@ function rcube_webmail() flags: flags.extra_flags }); - var c, n, col, html, css_class, + var c, n, col, html, css_class, label, status_class = '', status_label = '', tree = '', expando = '', list = this.message_list, rows = list.rows, @@ -1897,17 +2009,26 @@ function rcube_webmail() css_class = 'msgicon'; if (this.env.status_col === null) { css_class += ' status'; - if (flags.deleted) - css_class += ' deleted'; - else if (!flags.seen) - css_class += ' unread'; - else if (flags.unread_children > 0) - css_class += ' unreadchildren'; + if (flags.deleted) { + status_class += ' deleted'; + status_label += this.get_label('deleted') + ' '; + } + else if (!flags.seen) { + status_class += ' unread'; + status_label += this.get_label('unread') + ' '; + } + else if (flags.unread_children > 0) { + status_class += ' unreadchildren'; + } + } + if (flags.answered) { + status_class += ' replied'; + status_label += this.get_label('replied') + ' '; + } + if (flags.forwarded) { + status_class += ' forwarded'; + status_label += this.get_label('replied') + ' '; } - if (flags.answered) - css_class += ' replied'; - if (flags.forwarded) - css_class += ' forwarded'; // update selection if (message.selected && !list.in_selection(uid)) @@ -1944,15 +2065,17 @@ function rcube_webmail() row_class += ' unroot'; } - tree += '<span id="msgicn'+row.id+'" class="'+css_class+'"> </span>'; + tree += '<span id="msgicn'+row.id+'" class="'+css_class+status_class+'" title="'+status_label+'"></span>'; row.className = row_class; // 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 = '<a href="./?_task=mail&_action='+action+'&_mbox='+urlencode(flags.mbox)+'&'+uid_param+'='+urlencode(uid)+'"'+ - ' onclick="return rcube_event.cancel(event)" onmouseover="rcube_webmail.long_subject_title(this,'+(message.depth+1)+')"><span>'+cols.subject+'</span></a>'; + 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 = '<a href="' + this.url(action, query) + '" onclick="return rcube_event.keyboard_only(event)"' + + ' onmouseover="rcube_webmail.long_subject_title(this,'+(message.depth+1)+')" tabindex="-1"><span>'+cols.subject+'</span></a>'; } // add each submitted col @@ -1966,28 +2089,36 @@ function rcube_webmail() if (c == 'flag') { css_class = (flags.flagged ? 'flagged' : 'unflagged'); - html = '<span id="flagicn'+row.id+'" class="'+css_class+'"> </span>'; + label = this.get_label(css_class); + html = '<span id="flagicn'+row.id+'" class="'+css_class+'" title="'+label+'"></span>'; } else if (c == 'attachment') { + label = this.get_label('withattachment'); if (flags.attachmentClass) - html = '<span class="'+flags.attachmentClass+'"> </span>'; + html = '<span class="'+flags.attachmentClass+'" title="'+label+'"></span>'; else if (/application\/|multipart\/(m|signed)/.test(flags.ctype)) - html = '<span class="attachment"> </span>'; + html = '<span class="attachment" title="'+label+'"></span>'; else if (/multipart\/report/.test(flags.ctype)) - html = '<span class="report"> </span>'; - else + html = '<span class="report"></span>'; + else html = ' '; } else if (c == 'status') { - if (flags.deleted) + label = ''; + if (flags.deleted) { css_class = 'deleted'; - else if (!flags.seen) + label = this.get_label('deleted'); + } + else if (!flags.seen) { css_class = 'unread'; - else if (flags.unread_children > 0) + label = this.get_label('unread'); + } + else if (flags.unread_children > 0) { css_class = 'unreadchildren'; + } else css_class = 'msgicon'; - html = '<span id="statusicn'+row.id+'" class="'+css_class+'"> </span>'; + html = '<span id="statusicn'+row.id+'" class="'+css_class+status_class+'" title="'+label+'"></span>'; } else if (c == 'threads') html = expando; @@ -1997,8 +2128,10 @@ function rcube_webmail() html = tree + cols[c]; } else if (c == 'priority') { - if (flags.prio > 0 && flags.prio < 6) - html = '<span class="prio'+flags.prio+'"> </span>'; + if (flags.prio > 0 && flags.prio < 6) { + label = this.get_label('priority') + ' ' + flags.prio; + html = '<span class="prio'+flags.prio+'" title="'+label+'"></span>'; + } else html = ' '; } @@ -2308,7 +2441,6 @@ function rcube_webmail() this.clear_message_list = function() { this.env.messages = {}; - this.last_selected = 0; this.show_contentframe(false); if (this.message_list) @@ -2327,6 +2459,7 @@ function rcube_webmail() url._page = page; this.http_request('list', url, lock); + this.update_state({ _mbox: mbox, _page: (page && page > 1 ? page : null) }); }; // removes messages that doesn't exists from list selection array @@ -2600,7 +2733,7 @@ function rcube_webmail() // set message icon this.set_message_icon = function(uid) { - var css_class, + var css_class, label = '', row = this.message_list.rows[uid]; if (!row) @@ -2608,38 +2741,55 @@ function rcube_webmail() if (row.icon) { css_class = 'msgicon'; - if (row.deleted) + if (row.deleted) { css_class += ' deleted'; - else if (row.unread) + label += this.get_label('deleted') + ' '; + } + else if (row.unread) { css_class += ' unread'; + label += this.get_label('unread') + ' '; + } else if (row.unread_children) css_class += ' unreadchildren'; if (row.msgicon == row.icon) { - if (row.replied) + if (row.replied) { css_class += ' replied'; - if (row.forwarded) + label += this.get_label('replied') + ' '; + } + if (row.forwarded) { css_class += ' forwarded'; + label += this.get_label('forwarded') + ' '; + } css_class += ' status'; } - row.icon.className = css_class; + $(row.icon).attr('class', css_class).attr('title', label); } if (row.msgicon && row.msgicon != row.icon) { + label = ''; css_class = 'msgicon'; - if (!row.unread && row.unread_children) + if (!row.unread && row.unread_children) { css_class += ' unreadchildren'; - if (row.replied) + } + if (row.replied) { css_class += ' replied'; - if (row.forwarded) + label += this.get_label('replied') + ' '; + } + if (row.forwarded) { css_class += ' forwarded'; + label += this.get_label('forwarded') + ' '; + } - row.msgicon.className = css_class; + $(row.msgicon).attr('class', css_class).attr('title', label); } if (row.flagicon) { css_class = (row.flagged ? 'flagged' : 'unflagged'); - row.flagicon.className = css_class; + label = this.get_label(css_class); + $(row.flagicon).attr('class', css_class) + .attr('aria-label', label) + .attr('title', label); } }; @@ -2693,12 +2843,12 @@ function rcube_webmail() }; // copy selected messages to the specified mailbox - this.copy_messages = function(mbox, obj) + this.copy_messages = function(mbox, event) { if (mbox && typeof mbox === 'object') mbox = mbox.id; else if (!mbox) - return this.folder_selector(obj, function(folder) { ref.command('copy', folder); }); + return this.folder_selector(event, function(folder) { ref.command('copy', folder); }); // exit if current or no mailbox specified if (!mbox || mbox == this.env.mailbox) @@ -2715,12 +2865,12 @@ function rcube_webmail() }; // move selected messages to the specified mailbox - this.move_messages = function(mbox, obj) + this.move_messages = function(mbox, event) { if (mbox && typeof mbox === 'object') mbox = mbox.id; else if (!mbox) - return this.folder_selector(obj, function(folder) { ref.command('move', folder); }); + return this.folder_selector(event, function(folder) { ref.command('move', folder); }); // exit if current or no mailbox specified if (!mbox || (mbox == this.env.mailbox && !this.is_multifolder_listing())) @@ -3286,7 +3436,7 @@ function rcube_webmail() this.env.recipients_delimiter = this.env.recipients_separator + ' '; obj.keydown(function(e) { return ref.ksearch_keydown(e, this, props); }) - .attr('autocomplete', 'off'); + .attr({ 'autocomplete': 'off', 'aria-autocomplete': 'list', 'aria-expanded': 'false', 'role': 'combobox' }); }; this.submit_messageform = function(draft) @@ -3357,6 +3507,8 @@ function rcube_webmail() input.val(oldval + recipients.join(delim + ' ') + delim + ' '); this.triggerEvent('add-recipient', { field:field, recipients:recipients }); } + + return recipients.length; }; // checks the input fields before sending a message @@ -3507,15 +3659,18 @@ function rcube_webmail() $('<a>').addClass('insertresponse active') .attr('href', '#') .attr('rel', key) + .attr('tabindex', '0') .html(this.quote_html(response.name)) .appendTo(li) .mousedown(function(e){ return rcube_event.cancel(e); }) - .mouseup(function(e){ - ref.command('insert-response', key); - $(document.body).trigger('mouseup'); // hides the menu - return rcube_event.cancel(e); + .bind('mouseup keypress', function(e){ + if (e.type == 'mouseup' || rcube_event.get_keycode(e) == 13) { + ref.command('insert-response', $(this).attr('rel')); + $(document.body).trigger('mouseup'); // hides the menu + return rcube_event.cancel(e); + } }); } }; @@ -3787,7 +3942,7 @@ function rcube_webmail() // cleanup rx = new RegExp(rx_delim + '\\s*' + rx_delim, 'g'); - input_val = input_val.replace(rx, delim); + input_val = String(input_val).replace(rx, delim); rx = new RegExp('^[\\s' + rx_delim + ']+'); input_val = input_val.replace(rx, ''); @@ -4155,7 +4310,7 @@ function rcube_webmail() return; var dir = key == 38 ? 1 : 0, - highlight = document.getElementById('rcmksearchSelected'); + highlight = document.getElementById('rcmkSearchItem' + this.ksearch_selected); if (!highlight) highlight = this.ksearch_pane.__ul.firstChild; @@ -4204,14 +4359,14 @@ function rcube_webmail() this.ksearch_select = function(node) { - var current = $('#rcmksearchSelected'); - if (current[0] && node) { - current.removeAttr('id').removeClass('selected'); + if (this.ksearch_pane && node) { + this.ksearch_pane.find('li.selected').removeClass('selected').removeAttr('aria-selected'); } if (node) { - $(node).attr('id', 'rcmksearchSelected').addClass('selected'); + $(node).addClass('selected').attr('aria-selected', 'true'); this.ksearch_selected = node._rcm_id; + $(this.ksearch_input).attr('aria-activedescendant', 'rcmkSearchItem' + this.ksearch_selected); } }; @@ -4340,16 +4495,20 @@ function rcube_webmail() return; // display search results - var i, len, ul, li, text, type, init, + var i, id, len, ul, text, type, init, value = this.ksearch_value, maxlen = this.env.autocomplete_max ? this.env.autocomplete_max : 15; // create results pane if not present if (!this.ksearch_pane) { ul = $('<ul>'); - this.ksearch_pane = $('<div>').attr('id', 'rcmKSearchpane') + this.ksearch_pane = $('<div>').attr('id', 'rcmKSearchpane').attr('role', 'listbox') .css({ position:'absolute', 'z-index':30000 }).append(ul).appendTo(document.body); this.ksearch_pane.__ul = ul[0]; + + // register (delegate) event handlers + ul.on('mouseover', 'li', function(e){ ref.ksearch_select(e.target); }) + .on('onmouseup', 'li', function(e){ ref.ksearch_click(e.target); }) } ul = this.ksearch_pane.__ul; @@ -4374,23 +4533,29 @@ function rcube_webmail() for (i=0; i < len && maxlen > 0; i++) { text = typeof results[i] === 'object' ? results[i].name : results[i]; type = typeof results[i] === 'object' ? results[i].type : ''; - li = document.createElement('LI'); - li.innerHTML = text.replace(new RegExp('('+RegExp.escape(value)+')', 'ig'), '##$1%%').replace(/</g, '<').replace(/>/g, '>').replace(/##([^%]+)%%/g, '<b>$1</b>'); - li.onmouseover = function(){ ref.ksearch_select(this); }; - li.onmouseup = function(){ ref.ksearch_click(this) }; - li._rcm_id = this.env.contacts.length + i; - if (type) li.className = type; - ul.appendChild(li); + id = i + this.env.contacts.length; + $('<li>').attr('id', 'rcmkSearchItem' + id) + .attr('role', 'option') + .html(this.quote_html(text.replace(new RegExp('('+RegExp.escape(value)+')', 'ig'), '##$1%%')).replace(/##([^%]+)%%/g, '<b>$1</b>')) + .addClass(type || '') + .appendTo(ul) + .get(0)._rcm_id = id; maxlen -= 1; } } if (ul.childNodes.length) { + // set the right aria-* attributes to the input field + $(this.ksearch_input) + .attr('aria-haspopup', 'true') + .attr('aria-expanded', 'true') + .attr('aria-owns', 'rcmKSearchpane'); + this.ksearch_pane.show(); + // select the first if (!this.env.contacts.length) { - $('li:first', ul).attr('id', 'rcmksearchSelected').addClass('selected'); - this.ksearch_selected = 0; + this.ksearch_select($('li:first', ul).get(0)); } } @@ -4427,6 +4592,12 @@ function rcube_webmail() if (this.ksearch_pane) this.ksearch_pane.hide(); + $(this.ksearch_input) + .attr('aria-haspopup', 'false') + .attr('aria-expanded', 'false') + .removeAttr('aria-activedescendant') + .removeAttr('aria-owns'); + this.ksearch_destroy(); }; @@ -4631,6 +4802,7 @@ function rcube_webmail() // add link to pop back to parent group if (this.env.address_group_stack.length > 1) { $('<a href="#list">...</a>') + .attr('title', this.gettext('uponelevel')) .addClass('poplink') .appendTo(boxtitle) .click(function(e){ return ref.command('popgroup','',this); }); @@ -5174,6 +5346,7 @@ function rcube_webmail() if (appendcontainer.length && appendcontainer.get(0).nodeName == 'FIELDSET') { var input, colprop = this.env.coltypes[col], + input_id = 'ff_' + col + (colprop.count || 0), row = $('<div>').addClass('row'), cell = $('<div>').addClass('contactfieldcontent data'), label = $('<div>').addClass('contactfieldlabel label'); @@ -5181,14 +5354,14 @@ function rcube_webmail() if (colprop.subtypes_select) label.html(colprop.subtypes_select); else - label.html(colprop.label); + label.html('<label for="' + input_id + '">' + colprop.label + '</label>'); var name_suffix = colprop.limit != 1 ? '[]' : ''; if (colprop.type == 'text' || colprop.type == 'date') { input = $('<input>') .addClass('ff_'+col) - .attr({type: 'text', name: '_'+col+name_suffix, size: colprop.size}) + .attr({type: 'text', name: '_'+col+name_suffix, size: colprop.size, id: input_id}) .appendTo(cell); this.init_edit_field(col, input); @@ -5199,7 +5372,7 @@ function rcube_webmail() else if (colprop.type == 'textarea') { input = $('<textarea>') .addClass('ff_'+col) - .attr({ name: '_'+col+name_suffix, cols:colprop.size, rows:colprop.rows }) + .attr({ name: '_'+col+name_suffix, cols:colprop.size, rows:colprop.rows, id: input_id }) .appendTo(cell); this.init_edit_field(col, input); @@ -5235,7 +5408,7 @@ function rcube_webmail() else if (colprop.type == 'select') { input = $('<select>') .addClass('ff_'+col) - .attr('name', '_'+col+name_suffix) + .attr({ 'name': '_'+col+name_suffix, id: input_id }) .appendTo(cell); var options = input.attr('options'); @@ -5542,7 +5715,7 @@ function rcube_webmail() this.last_sub_rx = RegExp('['+delim+']?[^'+delim+']+$'); this.subscription_list = new rcube_list_widget(this.gui_objects.subscriptionlist, - {multiselect:false, draggable:true, keyboard:false, toggleselect:true}); + {multiselect:false, draggable:true, keyboard:true, toggleselect:true}); this.subscription_list .addEventListener('select', function(o){ ref.subscription_select(o); }) .addEventListener('dragstart', function(o){ ref.drag_active = true; }) @@ -5551,7 +5724,8 @@ function rcube_webmail() row.obj.onmouseover = function() { ref.focus_subscription(row.id); }; row.obj.onmouseout = function() { ref.unfocus_subscription(row.id); }; }) - .init(); + .init() + .focus(); $('#mailboxroot') .mouseover(function(){ ref.focus_subscription(this.id); }) @@ -5976,15 +6150,12 @@ 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 this.set_button = function(command, state) { - var n, button, obj, a_buttons = this.buttons[command], + var n, button, obj, $obj, a_buttons = this.buttons[command], len = a_buttons ? a_buttons.length : 0; for (n=0; n<len; n++) { @@ -6019,8 +6190,15 @@ function rcube_webmail() obj.disabled = state == 'pas'; } else if (button.type == 'uibutton') { + button.status = state; $(obj).button('option', 'disabled', state == 'pas'); } + else { + $obj = $(obj); + $obj + .attr('tabindex', state == 'pas' || state == 'sel' ? '-1' : ($obj.attr('data-tabindex') || '0')) + .attr('aria-disabled', state == 'pas' || state == 'sel' ? 'true' : 'false'); + } } }; @@ -6144,7 +6322,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 }); @@ -6274,10 +6453,8 @@ function rcube_webmail() this.treelist.select(name); } else if (this.gui_objects.folderlist) { - $('li.selected', this.gui_objects.folderlist) - .removeClass('selected').addClass('unfocused'); - $(this.get_folder_li(name, prefix, encode)) - .removeClass('unfocused').addClass('selected'); + $('li.selected', this.gui_objects.folderlist).removeClass('selected'); + $(this.get_folder_li(name, prefix, encode)).addClass('selected'); // trigger event hook this.triggerEvent('selectfolder', { folder:name, prefix:prefix }); @@ -6327,7 +6504,7 @@ function rcube_webmail() tr = document.createElement('tr'); for (c=0, len=repl.length; c < len; c++) { - cell = document.createElement('td'); + cell = document.createElement('th'); cell.innerHTML = repl[c].html || ''; if (repl[c].id) cell.id = repl[c].id; if (repl[c].className) cell.className = repl[c].className; @@ -6511,17 +6688,15 @@ function rcube_webmail() }; // create folder selector popup, position and display it - this.folder_selector = function(obj, callback) + this.folder_selector = function(event, callback) { var container = this.folder_selector_element; if (!container) { var rows = [], delim = this.env.delimiter, - ul = $('<ul class="toolbarmenu iconized">'), - li = document.createElement('li'), - link = document.createElement('a'), - span = document.createElement('span'); + ul = $('<ul class="toolbarmenu">'), + link = document.createElement('a'); container = $('<div id="folder-selector" class="popupmenu"></div>'); link.href = '#'; @@ -6529,33 +6704,30 @@ function rcube_webmail() // loop over sorted folders list $.each(this.env.mailboxes_list, function() { - var tmp, n = 0, s = 0, + var n = 0, s = 0, folder = ref.env.mailboxes[this], id = folder.id, - a = link.cloneNode(false), row = li.cloneNode(false); + a = $(link.cloneNode(false)), + row = $('<li>'); if (folder.virtual) - a.className += ' virtual'; - else { - a.className += ' active'; - a.onclick = function() { container.hide().data('callback')(folder.id); }; - } + a.addClass('virtual').attr('aria-disabled', 'true').attr('tabindex', '-1'); + else + a.addClass('active').data('id', folder.id); if (folder['class']) - a.className += ' ' + folder['class']; + a.addClass(folder['class']); // calculate/set indentation level while ((s = id.indexOf(delim, s)) >= 0) { n++; s++; } - a.style.paddingLeft = n ? (n * 16) + 'px' : 0; + a.css('padding-left', n ? (n * 16) + 'px' : 0); // add folder name element - tmp = span.cloneNode(false); - $(tmp).text(folder.name); - a.appendChild(tmp); + a.append($('<span>').text(folder.name)); - row.appendChild(a); + row.append(a); rows.push(row); }); @@ -6567,23 +6739,157 @@ function rcube_webmail() // set max-height if the list is long if (rows.length > 10) - container.css('max-height', $('li', container)[0].offsetHeight * 10 + 9) + container.css('max-height', $('li', container)[0].offsetHeight * 10 + 9); - // hide selector on click out of selector element - var fn = function(e) { if (e.target != container.get(0)) container.hide(); }; - $(document.body).on('mouseup', fn); - $('iframe').contents().on('mouseup', fn) - .load(function(e) { try { $(this).contents().on('mouseup', fn); } catch(e) {}; }); + // register delegate event handler for folder item clicks + container.on('click', 'a.active', function(e){ + container.data('callback')($(this).data('id')); + return false; + }); this.folder_selector_element = container; } + container.data('callback', callback); + // position menu on the screen - this.element_position(container, obj); + this.show_menu('folder-selector', true, event); + }; + - container.show().data('callback', callback); + /***********************************************/ + /********* popup menu functions *********/ + /***********************************************/ + + // Show/hide a specific popup menu + this.show_menu = function(prop, show, event) + { + var name = typeof prop == 'object' ? prop.menu : prop, + obj = $('#'+name), + ref = event && event.target ? $(event.target) : $(obj.attr('rel') || '#'+name+'link'), + keyboard = rcube_event.is_keyboard(event), + align = obj.attr('data-align') || '', + stack = false; + + // find "real" button element + if (ref.get(0).tagName != 'A' && ref.closest('a').length) + ref = ref.closest('a'); + + if (typeof prop == 'string') + prop = { menu:name }; + + // let plugins or skins provide the menu element + if (!obj.length) { + obj = this.triggerEvent('menu-get', { name:name, props:prop, originalEvent:event }); + } + + if (!obj || !obj.length) { + // just delegate the action to subscribers + return this.triggerEvent(show === false ? 'menu-close' : 'menu-open', { name:name, props:prop, originalEvent:event }); + } + + // move element to top for proper absolute positioning + obj.appendTo(document.body); + + if (typeof show == 'undefined') + show = obj.is(':visible') ? false : true; + + if (show && ref.length) { + var win = $(window), + pos = ref.offset(), + above = align.indexOf('bottom') >= 0; + + stack = ref.attr('role') == 'menuitem' || ref.closest('[role=menuitem]').length > 0; + + ref.offsetWidth = ref.outerWidth(); + ref.offsetHeight = ref.outerHeight(); + if (!above && pos.top + ref.offsetHeight + obj.height() > win.height()) { + above = true; + } + if (align.indexOf('right') >= 0) { + pos.left = pos.left + ref.outerWidth() - obj.width(); + } + else if (stack) { + pos.left = pos.left + ref.offsetWidth - 5; + pos.top -= ref.offsetHeight; + } + if (pos.left + obj.width() > win.width()) { + pos.left = win.width() - obj.width() - 12; + } + pos.top = Math.max(0, pos.top + (above ? -obj.height() : ref.offsetHeight)); + obj.css({ left:pos.left+'px', top:pos.top+'px' }); + } + + // add menu to stack + if (show) { + // truncate stack down to the one containing the ref link + for (var i = this.menu_stack.length - 1; stack && i >= 0; i--) { + if (!$(ref).parents('#'+this.menu_stack[i]).length) + this.hide_menu(this.menu_stack[i]); + } + if (stack && this.menu_stack.length) { + obj.data('parent', $.last(this.menu_stack)); + obj.css('z-index', ($('#'+$.last(this.menu_stack)).css('z-index') || 0) + 1); + } + else if (!stack && this.menu_stack.length) { + this.hide_menu(this.menu_stack[0], event); + } + + obj.show().attr('aria-hidden', 'false').data('opener', ref.attr('aria-expanded', 'true').get(0)); + this.triggerEvent('menu-open', { name:name, obj:obj, props:prop, originalEvent:event }); + this.menu_stack.push(name); + + this.menu_keyboard_active = show && keyboard; + if (this.menu_keyboard_active) { + this.focused_menu = name; + obj.find('a,input:not(:disabled)').not('[aria-disabled=true]').first().focus(); + } + } + else { // close menu + this.hide_menu(name, event); + } + + return show; }; + // hide the given popup menu (and it's childs) + this.hide_menu = function(name, event) + { + if (!this.menu_stack.length) { + // delegate to subscribers + this.triggerEvent('menu-close', { name:name, props:{ menu:name }, originalEvent:event }); + return; + } + + var obj, keyboard = rcube_event.is_keyboard(event); + for (var j=this.menu_stack.length-1; j >= 0; j--) { + obj = $('#' + this.menu_stack[j]).hide().attr('aria-hidden', 'true').data('parent', false); + this.triggerEvent('menu-close', { name:this.menu_stack[j], obj:obj, props:{ menu:this.menu_stack[j] }, originalEvent:event }); + if (this.menu_stack[j] == name) { + j = -1; // stop loop + if (obj.data('opener')) { + $(obj.data('opener')).attr('aria-expanded', 'false'); + if (keyboard) + obj.data('opener').focus(); + } + } + this.menu_stack.pop(); + } + + // focus previous menu in stack + if (this.menu_stack.length && keyboard) { + this.menu_keyboard_active = true; + this.focused_menu = $.last(this.menu_stack); + if (!obj || !obj.data('opener')) + $('#'+this.focused_menu).find('a,input:not(:disabled)').not('[aria-disabled=true]').first().focus(); + } + else { + this.focused_menu = null; + this.menu_keyboard_active = false; + } + } + + // position a menu element on the screen in relation to other object this.element_position = function(element, obj) { @@ -6753,6 +7059,13 @@ function rcube_webmail() this.start_keepalive(); }; + // update browser location to remember current view + this.update_state = function(query) + { + if (window.history.replaceState) + window.history.replaceState({}, document.title, rcmail.url('', query)); + }; + // send a http request to the server this.http_request = function(action, query, lock) { @@ -6938,6 +7251,8 @@ function rcube_webmail() if ((response.action == 'list' || response.action == 'search') && this.message_list) { this.enable_command('set-listmode', this.env.threads && !is_multifolder); + if (this.message_list.rowcount > 0) + this.message_list.focus(); this.msglist_select(this.message_list); this.triggerEvent('listupdate', { folder:this.env.mailbox, rowcount:this.message_list.rowcount }); } @@ -6949,10 +7264,18 @@ function rcube_webmail() this.enable_command('search-create', this.env.source == ''); this.enable_command('search-delete', this.env.search_id); this.update_group_commands(); + if (this.contact_list.rowcount > 0) + this.contact_list.focus(); this.triggerEvent('listupdate', { folder:this.env.source, rowcount:this.contact_list.rowcount }); } } break; + + case 'list-contacts': + case 'search-contacts': + if (this.contact_list && this.contact_list.rowcount > 0) + this.contact_list.focus(); + break; } if (response.unlock) diff --git a/program/js/common.js b/program/js/common.js index 48e85558f..5ac4febce 100644 --- a/program/js/common.js +++ b/program/js/common.js @@ -281,6 +281,25 @@ 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. <Enter>) + */ +keyboard_only: function(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 }; @@ -593,6 +612,11 @@ if (!String.prototype.startsWith) { }; } +// array utility function +jQuery.last = function(arr) { + return arr && arr.length ? arr[arr.length-1] : undefined; +} + // jQuery plugin to emulate HTML5 placeholder attributes on input elements jQuery.fn.placeholder = function(text) { return this.each(function() { diff --git a/program/js/list.js b/program/js/list.js index 04aec1c99..5492c0ad4 100644 --- a/program/js/list.js +++ b/program/js/list.js @@ -51,7 +51,7 @@ function rcube_list_widget(list, p) this.rowcount = 0; this.colcount = 0; - this.subject_col = -1; + this.subject_col = 0; this.modkey = 0; this.multiselect = false; this.multiexpand = false; @@ -60,6 +60,7 @@ function rcube_list_widget(list, p) this.column_movable = false; this.keyboard = false; this.toggleselect = false; + this.aria_listbox = false; this.drag_active = false; this.col_drag_active = false; @@ -75,6 +76,9 @@ function rcube_list_widget(list, p) if (p && typeof p === 'object') for (var n in p) this[n] = p[n]; + + // register this instance + rcube_list_widget._instances.push(this); }; @@ -94,11 +98,17 @@ init: function() this.tbody = this.list; } + if ($(this.list).attr('role') == 'listbox') { + this.aria_listbox = true; + if (this.multiselect) + $(this.list).attr('aria-multiselectable', 'true'); + } + if (this.tbody) { 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<len; r++) { this.rowcount += this.init_row(rows[r]) ? 1 : 0; @@ -108,8 +118,13 @@ init: function() this.frame = this.list.parentNode; // set body events - if (this.keyboard) + if (this.keyboard) { rcube_event.add_listener({event:'keydown', object:this, method:'key_press'}); + + // allow the table element to receive focus. + $(this.list).attr('tabindex', '0') + .on('focus', function(e){ me.focus(e); }); + } } return this; @@ -154,6 +169,15 @@ init_row: function(row) }, false); } + // label the list row with the subject col as descriptive label + if (this.aria_listbox) { + var lbl_id = 'l:' + row.id; + $(row) + .attr('role', 'option') + .attr('aria-labelledby', lbl_id) + .find(this.col_tagname()).eq(this.subject_col).attr('id', lbl_id); + } + if (document.all) row.onselectstart = function() { return false; }; @@ -175,7 +199,7 @@ 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('th,td').attr('style', '').find('a').attr('tabindex', '-1'); // remove fixed widths } else if (!bw.touch && this.list.className.indexOf('fixedheader') >= 0) { this.init_fixed_header(); @@ -202,6 +226,7 @@ init_fixed_header: function() if (!this.fixed_header) { this.fixed_header = $('<table>') .attr('class', this.list.className + ' fixedcopy') + .attr('role', 'presentation') .css({ position:'fixed' }) .append(clone) .append('<tbody></tbody>'); @@ -220,6 +245,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(); }, @@ -262,6 +293,7 @@ clear: function(sel) this.rows = {}; this.rowcount = 0; + this.last_selected = 0; if (sel) this.clear_selection(); @@ -370,41 +402,68 @@ update_row: function(id, cols, newid, select) */ focus: function(e) { - var n, id; + if (this.focused) + return; + this.focused = true; - for (n in this.selection) { - id = this.selection[n]; - if (this.rows[id] && this.rows[id].obj) { - $(this.rows[id].obj).addClass('selected').removeClass('unfocused'); - } + if (e) + rcube_event.cancel(e); + + var focus_elem = null; + + if (this.last_selected && this.rows[this.last_selected]) { + focus_elem = $(this.rows[this.last_selected].obj).find(this.col_tagname()).eq(this.subject_col).attr('tabindex', '0'); } // 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(); + if (focus_elem && focus_elem.length) { + // We now fix this by explicitly assigning focus to a dedicated link element + this.focus_noscroll(focus_elem); + } + else { + // It looks that window.focus() does the job for all browsers, but not Firefox (#1489058) + $('iframe,:focus:not(body)').blur(); + window.focus(); + } - if (e || (e = window.event)) - rcube_event.cancel(e); + $(this.list).addClass('focus').removeAttr('tabindex'); + + // 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; - for (n in this.selection) { - id = this.selection[n]; - if (this.rows[id] && this.rows[id].obj) { - $(this.rows[id].obj).removeClass('selected focused').addClass('unfocused'); - } + + // avoid the table getting focus right again + var me = this; + setTimeout(function(){ + $(me.list).removeClass('focus').attr('tabindex', '0'); + }, 20); + + if (this.last_selected && this.rows[this.last_selected]) { + $(this.rows[this.last_selected].obj) + .find(this.col_tagname()).eq(this.subject_col).removeAttr('tabindex'); } }, +/** + * Focus the given element without scrolling the list container + */ +focus_noscroll: function(elem) +{ + var y = this.frame.scrollTop || this.frame.scrollY; + elem.focus(); + this.frame.scrollTop = y; +}, + /** * Set/unset the given column as hidden @@ -522,6 +581,8 @@ click_row: function(e, id) } this.rows[id].clicked = now; + this.focus(); + return false; }, @@ -794,9 +855,9 @@ get_prev_row: function() get_first_row: function() { if (this.rowcount) { - var i, len, uid, rows = this.tbody.childNodes; + var i, uid, rows = this.tbody.childNodes; - for (i=0, len=rows.length-1; i<len; i++) + for (i=0; i<rows.length; i++) if (rows[i].id && (uid = this.get_row_uid(rows[i]))) return uid; } @@ -839,9 +900,10 @@ get_cell: function(row, index) */ select_row: function(id, mod_key, with_mouse) { - var select_before = this.selection.join(','); + var select_before = this.selection.join(','), + in_selection_before = this.in_selection(id); - if (!this.multiselect) + if (!this.multiselect && with_mouse) mod_key = 0; if (!this.shift_start) @@ -877,20 +939,26 @@ select_row: function(id, mod_key, with_mouse) this.multi_selecting = true; } - // trigger event if selection changed - if (this.selection.join(',') != select_before) - this.triggerEvent('select'); - - if (this.last_selected != 0 && this.rows[this.last_selected]) - $(this.rows[this.last_selected].obj).removeClass('focused'); + if (this.last_selected != 0 && this.rows[this.last_selected]) { + $(this.rows[this.last_selected].obj).removeClass('focused') + .find(this.col_tagname()).eq(this.subject_col).removeAttr('tabindex'); + } // unselect if toggleselect is active and the same row was clicked again - if (this.toggleselect && this.last_selected == id) { + if (this.toggleselect && in_selection_before) { this.clear_selection(); - id = null; } - else + // trigger event if selection changed + else if (this.selection.join(',') != select_before) { + this.triggerEvent('select'); + } + + if (this.rows[id]) { $(this.rows[id].obj).addClass('focused'); + // set cursor focus to link inside selected row + if (this.focused) + this.focus_noscroll($(this.rows[id].obj).find(this.col_tagname()).eq(this.subject_col).attr('tabindex', '0')); + } if (!this.selection.length) this.shift_start = null; @@ -1038,7 +1106,7 @@ select_all: function(filter) this.highlight_row(n, true, true); } else { - $(this.rows[n].obj).removeClass('selected').removeClass('unfocused'); + $(this.rows[n].obj).removeClass('selected').removeAttr('aria-selected'); } } @@ -1095,14 +1163,16 @@ clear_selection: function(id, no_event) else { for (n in this.selection) if (this.rows[this.selection[n]]) { - $(this.rows[this.selection[n]].obj).removeClass('selected').removeClass('unfocused'); + $(this.rows[this.selection[n]].obj).removeClass('selected').removeAttr('aria-selected'); } 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; + } }, @@ -1156,13 +1226,13 @@ highlight_row: function(id, multiple, norecur) if (this.selection.length > 1 || !this.in_selection(id)) { this.clear_selection(null, true); this.selection[0] = id; - $(this.rows[id].obj).addClass('selected'); + $(this.rows[id].obj).addClass('selected').attr('aria-selected', 'true'); } } else { if (!this.in_selection(id)) { // select row this.selection.push(id); - $(this.rows[id].obj).addClass('selected'); + $(this.rows[id].obj).addClass('selected').attr('aria-selected', 'true'); if (!norecur && !this.rows[id].expanded) this.highlight_children(id, true); } @@ -1172,7 +1242,7 @@ highlight_row: function(id, multiple, norecur) a_post = this.selection.slice(p+1, this.selection.length); this.selection = a_pre.concat(a_post); - $(this.rows[id].obj).removeClass('selected').removeClass('unfocused'); + $(this.rows[id].obj).removeClass('selected').removeAttr('aria-selected'); if (!norecur && !this.rows[id].expanded) this.highlight_children(id, false); } @@ -1252,6 +1322,14 @@ key_press: function(e) return rcube_event.cancel(e); + case 9: // Tab + this.blur(); + break; + + case 13: // Enter + if (!this.selection.length) + this.select_row(this.last_selected, mod_key, false); + default: this.key_pressed = keyCode; this.modkey = mod_key; @@ -1311,9 +1389,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; }, @@ -1708,3 +1794,6 @@ column_replace: function(from, to) rcube_list_widget.prototype.addEventListener = rcube_event_engine.prototype.addEventListener; rcube_list_widget.prototype.removeEventListener = rcube_event_engine.prototype.removeEventListener; rcube_list_widget.prototype.triggerEvent = rcube_event_engine.prototype.triggerEvent; + +// static +rcube_list_widget._instances = []; diff --git a/program/js/treelist.js b/program/js/treelist.js index 90eeeeae7..65f5fd4f4 100644 --- a/program/js/treelist.js +++ b/program/js/treelist.js @@ -55,6 +55,7 @@ function rcube_treelist_widget(node, p) drag_active = false, search_active = false, last_search = '', + has_focus = false, box_coords = {}, item_coords = [], autoexpand_timer, @@ -151,6 +152,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 @@ -201,13 +215,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); @@ -262,6 +276,7 @@ function rcube_treelist_widget(node, p) // insert as child of an existing node if (parent_node) { + node.level = parent_node.level + 1; if (!parent_node.children) parent_node.children = []; @@ -296,6 +311,7 @@ function rcube_treelist_widget(node, p) } // insert at top level else { + node.level = 0; data.push(node); li = render_node(node, container); } @@ -392,7 +408,7 @@ function rcube_treelist_widget(node, p) */ function update_data() { - data = walk_list(container); + data = walk_list(container, 0); } /** @@ -401,6 +417,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); @@ -507,6 +524,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); } @@ -523,6 +541,7 @@ function rcube_treelist_widget(node, p) var li = $('<li>') .attr('id', p.id_prefix + (p.id_encode ? p.id_encode(node.id) : node.id)) + .attr('role', 'treeitem') .addClass((node.classes || []).join(' ')) .data('id', node.id); @@ -546,12 +565,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); } } @@ -563,7 +584,7 @@ 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){ @@ -572,9 +593,10 @@ function rcube_treelist_widget(node, p) id: dom2id(li), classes: String(li.attr('class')).split(' '), virtual: li.hasClass('virtual'), + level: level, html: li.children().first().get(0).outerHTML, text: li.children().first().text(), - children: walk_list(sublist) + children: walk_list(sublist, level+1) } if (sublist.length) { @@ -592,15 +614,29 @@ function rcube_treelist_widget(node, p) 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; } @@ -683,6 +719,70 @@ function rcube_treelist_widget(node, p) 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' || 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=group]:visible').length) { + li.children('ul').children('li:first').children('a:first').focus(); + } + else if (dir < 0 && !from_child && next.children('ul[role=group]: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); + } + } +>>>>>>> dev-accessibility + } + ///// drag & drop support diff --git a/program/lib/Roundcube/html.php b/program/lib/Roundcube/html.php index a88570d75..0209d1bf2 100644 --- a/program/lib/Roundcube/html.php +++ b/program/lib/Roundcube/html.php @@ -32,7 +32,7 @@ class html public static $doctype = 'xhtml'; public static $lc_tags = true; - public static $common_attrib = array('id','class','style','title','align','unselectable'); + public static $common_attrib = array('id','class','style','title','align','unselectable','tabindex','role'); public static $containers = array('iframe','div','span','p','h1','h2','h3','ul','form','textarea','table','thead','tbody','tr','th','td','style','script'); @@ -285,7 +285,9 @@ class html // ignore not allowed attributes, except data-* if (!empty($allowed)) { - if (!isset($allowed_f[$key]) && @substr_compare($key, 'data-', 0, 5) !== 0) { + $is_data_attr = @substr_compare($key, 'data-', 0, 5) === 0; + $is_aria_attr = @substr_compare($key, 'aria-', 0, 5) === 0; + if (!$is_aria_attr && !isset($allowed_f[$key]) && (!$is_data_attr || !isset($allowed_f['data-*']))) { continue; } } @@ -835,7 +837,7 @@ class html_table extends html if (!empty($this->header)) { $rowcontent = ''; foreach ($this->header as $c => $col) { - $rowcontent .= self::tag($this->_col_tagname(), $col->attrib, $col->content); + $rowcontent .= self::tag($this->_head_tagname(), $col->attrib, $col->content); } $thead = $this->tagname == 'table' ? self::tag('thead', null, self::tag('tr', null, $rowcontent, parent::$common_attrib)) : self::tag($this->_row_tagname(), array('class' => 'thead'), $rowcontent, parent::$common_attrib); @@ -888,7 +890,16 @@ class html_table extends html private function _row_tagname() { static $row_tagnames = array('table' => 'tr', 'ul' => 'li', '*' => 'div'); - return $row_tagnames[$this->tagname] ? $row_tagnames[$this->tagname] : $row_tagnames['*']; + return $row_tagnames[$this->tagname] ?: $row_tagnames['*']; + } + + /** + * Getter for the corresponding tag name for table row elements + */ + private function _head_tagname() + { + static $head_tagnames = array('table' => 'th', '*' => 'span'); + return $head_tagnames[$this->tagname] ?: $head_tagnames['*']; } /** @@ -897,7 +908,7 @@ class html_table extends html private function _col_tagname() { static $col_tagnames = array('table' => 'td', '*' => 'span'); - return $col_tagnames[$this->tagname] ? $col_tagnames[$this->tagname] : $col_tagnames['*']; + return $col_tagnames[$this->tagname] ?: $col_tagnames['*']; } } diff --git a/program/localization/en_US/labels.inc b/program/localization/en_US/labels.inc index 2da99105f..e7d385fe6 100644 --- a/program/localization/en_US/labels.inc +++ b/program/localization/en_US/labels.inc @@ -157,16 +157,24 @@ $labels['more'] = 'More'; $labels['back'] = 'Back'; $labels['options'] = 'Options'; +$labels['first'] = 'First'; +$labels['last'] = 'Last'; +$labels['previous'] = 'Previous'; +$labels['next'] = 'Next'; $labels['select'] = 'Select'; $labels['all'] = 'All'; $labels['none'] = 'None'; $labels['currpage'] = 'Current page'; +$labels['isread'] = 'Read'; $labels['unread'] = 'Unread'; $labels['flagged'] = 'Flagged'; +$labels['unflagged'] = 'Not Flagged'; $labels['unanswered'] = 'Unanswered'; $labels['withattachment'] = 'With attachment'; $labels['deleted'] = 'Deleted'; $labels['undeleted'] = 'Not deleted'; +$labels['replied'] = 'Replied'; +$labels['forwarded'] = 'Forwarded'; $labels['invert'] = 'Invert'; $labels['filter'] = 'Filter'; $labels['list'] = 'List'; @@ -259,6 +267,7 @@ $labels['upload'] = 'Upload'; $labels['uploadprogress'] = '$percent ($current from $total)'; $labels['close'] = 'Close'; $labels['messageoptions'] = 'Message options...'; +$labels['togglecomposeoptions'] = 'Toggle composition options'; $labels['low'] = 'Low'; $labels['lowest'] = 'Lowest'; @@ -348,7 +357,9 @@ $labels['addcontact'] = 'Add new contact'; $labels['editcontact'] = 'Edit contact'; $labels['contacts'] = 'Contacts'; $labels['contactproperties'] = 'Contact properties'; +$labels['contactnameandorg'] = 'Name and Organization'; $labels['personalinfo'] = 'Personal information'; +$labels['contactphoto'] = 'Contact photo'; $labels['edit'] = 'Edit'; $labels['cancel'] = 'Cancel'; @@ -372,6 +383,7 @@ $labels['newcontactgroup'] = 'Create new contact group'; $labels['grouprename'] = 'Rename group'; $labels['groupdelete'] = 'Delete group'; $labels['groupremoveselected'] = 'Remove selected contacts from group'; +$labels['uponelevel'] = 'Up one level'; $labels['previouspage'] = 'Show previous page'; $labels['firstpage'] = 'Show first page'; @@ -468,6 +480,7 @@ $labels['miscfolding'] = 'RFC 2047/2231 (MS Outlook)'; $labels['2047folding'] = 'Full RFC 2047 (other)'; $labels['force7bit'] = 'Use MIME encoding for 8-bit characters'; $labels['advancedoptions'] = 'Advanced options'; +$labels['toggleadvancedoptions'] = 'Toggle advanced options'; $labels['focusonnewmessage'] = 'Focus browser window on new message'; $labels['checkallfolders'] = 'Check all folders for new messages'; $labels['displaynext'] = 'After message delete/move display the next message'; @@ -569,4 +582,48 @@ $labels['japanese'] = 'Japanese'; $labels['korean'] = 'Korean'; $labels['chinese'] = 'Chinese'; +// accessibility (voice-only) headings and descriptions +$labels['arialabeltopnav'] = 'Window control'; +$labels['arialabeltasknav'] = 'Application tasks'; +$labels['arialabeltoolbar'] = 'Application toolbar'; +$labels['arialabelmessagessearchfilter'] = 'Email listing filter'; +$labels['arialabelmailsearchform'] = 'Email message search form'; +$labels['arialabelcontactsearchform'] = 'Contacts search form'; +$labels['arialabelmailquicksearchbox'] = 'Email search input'; +$labels['arialabelquicksearchbox'] = 'Search input'; +$labels['arialabelfolderlist'] = 'Email folder selection'; +$labels['arialabelmessagelist'] = 'Email Messages Listing'; +$labels['arialabelmailpreviewframe'] = 'Message preview'; +$labels['arialabelmailboxmenu'] = 'Folder actions menu'; +$labels['arialabellistselectmenu'] = 'List selection menu'; +$labels['arialabelthreadselectmenu'] = 'Threads listing menu'; +$labels['arialabelmessagelistoptions'] = 'Message list display and sorting options'; +$labels['arialabelmailimportdialog'] = 'Message import dialog'; +$labels['arialabelmessagenav'] = 'Message navigation'; +$labels['arialabelmessagebody'] = 'Message Body'; +$labels['arialabelmessageactions'] = 'Message actions'; +$labels['arialabelcontactquicksearch'] = 'Contacts search form'; +$labels['arialabelcontactsearchbox'] = 'Contact search input'; +$labels['arialabelmessageheaders'] = 'Message headers'; +$labels['arialabelcomposeoptions'] = 'Composition options'; +$labels['arialabelresponsesmenu'] = 'Canned responses menu'; +$labels['arialabelattachmentuploadform'] = 'Attachment upload form'; +$labels['arialabelattachmentpreview'] = 'Attachment preview'; +$labels['ariasummarycomposecontacts'] = 'List of contacts and groups to select as recipients'; +$labels['arialabelcontactexportoptions'] = 'Contact export options'; +$labels['arialabelabookgroupoptions'] = 'Addressbook/group options'; +$labels['arialabelpreferencesform'] = 'Preferences form'; +$labels['arialabelidentityeditfrom'] = 'Identity edit form'; +$labels['arialabelresonseeditfrom'] = 'Response edit form'; + +$labels['helplistnavigation'] = 'List keyboard navigation'; +$labels['helplistkeyboardnavigation'] = "Arrows up/down: Move row focus/selection. +Space: Select focused row. +Shift + up/down: Select additional row above/below. +Ctrl + Space: Add focused row to selection/remove from selection."; +$labels['helplistkeyboardnavmessages'] = "Arrows right/left: expand/collapse message thread (in threads mode only). +Enter: Open the selected/focused message. +Delete: Move selected messages to Trash."; +$labels['helplistkeyboardnavcontacts'] = "Enter: Open the selected/focused contact."; + ?> diff --git a/program/steps/addressbook/edit.inc b/program/steps/addressbook/edit.inc index 3bbbfccdf..0fc753056 100644 --- a/program/steps/addressbook/edit.inc +++ b/program/steps/addressbook/edit.inc @@ -98,12 +98,15 @@ function rcmail_get_edit_record() function rcmail_contact_edithead($attrib) { + global $RCMAIL; + // check if we have a valid result $record = rcmail_get_edit_record(); $i_size = !empty($attrib['size']) ? $attrib['size'] : 20; $form = array( 'head' => array( + 'name' => $RCMAIL->gettext('contactnameandorg'), 'content' => array( 'prefix' => array('size' => $i_size), 'firstname' => array('size' => $i_size, 'visible' => true), diff --git a/program/steps/addressbook/func.inc b/program/steps/addressbook/func.inc index be0dd2a33..8955488bd 100644 --- a/program/steps/addressbook/func.inc +++ b/program/steps/addressbook/func.inc @@ -395,7 +395,7 @@ function rcmail_js_contacts_list($result, $prefix='') ), '»'); } else - $val = ' '; + $val = ''; break; default: @@ -422,7 +422,7 @@ function rcmail_contacts_list_title($attrib) unset($attrib['name']); $OUTPUT->add_gui_object('addresslist_title', $attrib['id']); - $OUTPUT->add_label('contacts'); + $OUTPUT->add_label('contacts','uponelevel'); return html::tag($attrib['tag'], $attrib, $RCMAIL->gettext($attrib['label']), html::$common_attrib); } @@ -518,7 +518,7 @@ function rcmail_contact_form($form, $record, $attrib = null) foreach ($coltypes as $col => $prop) { if ($prop['subtypes']) { $subtype_names = array_map('rcmail_get_type_label', $prop['subtypes']); - $select_subtype = new html_select(array('name' => '_subtype_'.$col.'[]', 'class' => 'contactselectsubtype')); + $select_subtype = new html_select(array('name' => '_subtype_'.$col.'[]', 'class' => 'contactselectsubtype', 'title' => $prop['label'] . ' ' . $RCMAIL->gettext('type'))); $select_subtype->add($subtype_names, $prop['subtypes']); $coltypes[$col]['subtypes_select'] = $select_subtype->show(); } @@ -607,7 +607,7 @@ function rcmail_contact_form($form, $record, $attrib = null) // prepare subtype selector in edit mode if ($edit_mode && is_array($colprop['subtypes'])) { $subtype_names = array_map('rcmail_get_type_label', $colprop['subtypes']); - $select_subtype = new html_select(array('name' => '_subtype_'.$col.'[]', 'class' => 'contactselectsubtype')); + $select_subtype = new html_select(array('name' => '_subtype_'.$col.'[]', 'class' => 'contactselectsubtype', 'title' => $colprop['label'] . ' ' . $RCMAIL->gettext('type'))); $select_subtype->add($subtype_names, $colprop['subtypes']); } else @@ -649,6 +649,8 @@ function rcmail_contact_form($form, $record, $attrib = null) if ($subtypes[$i]) $subtype = $subtypes[$i]; + $colprop['id'] = 'ff_' . $col . intval($coltypes[$field]['count']); + // render composite field if ($colprop['type'] == 'composite') { $composite = array(); $j = 0; @@ -714,7 +716,7 @@ function rcmail_contact_form($form, $record, $attrib = null) // display row with label if ($label) { $rows .= html::div('row', - html::div('contactfieldlabel label', $select_subtype ? $select_subtype->show($subtype) : rcube::Q($label)) . + html::div('contactfieldlabel label', $select_subtype ? $select_subtype->show($subtype) : html::label($colprop['id'], rcube::Q($label))) . html::div('contactfieldcontent '.$colprop['type'], $val)); } else // row without label @@ -803,7 +805,7 @@ function rcmail_contact_photo($attrib) else $ff_value = '-del-'; // will disable delete-photo action - $img = html::img(array('src' => $photo_img, 'border' => 1, 'alt' => '')); + $img = html::img(array('src' => $photo_img, 'border' => 1, 'alt' => $RCMAIL->gettext('contactphoto'))); $content = html::div($attrib, $img); if ($CONTACT_COLTYPES['photo'] && ($RCMAIL->action == 'edit' || $RCMAIL->action == 'add')) { diff --git a/program/steps/addressbook/show.inc b/program/steps/addressbook/show.inc index f4224a3e2..4471ea658 100644 --- a/program/steps/addressbook/show.inc +++ b/program/steps/addressbook/show.inc @@ -60,6 +60,7 @@ function rcmail_contact_head($attrib) $form = array( 'head' => array( // section 'head' is magic! + 'name' => $RCMAIL->gettext('contactnameandorg'), 'content' => array( 'prefix' => array('type' => 'text'), 'firstname' => array('type' => 'text'), diff --git a/program/steps/mail/compose.inc b/program/steps/mail/compose.inc index 6baf6e79a..0257f3187 100644 --- a/program/steps/mail/compose.inc +++ b/program/steps/mail/compose.inc @@ -969,7 +969,7 @@ function rcmail_compose_body($attrib) $OUTPUT->set_env('spellcheck_langs', join(',', $editor_lang_set)); } - $out .= "\n".'<iframe name="savetarget" src="program/resources/blank.gif" style="width:0;height:0;border:none;visibility:hidden;"></iframe>'; + $out .= "\n".'<iframe name="savetarget" src="program/resources/blank.gif" style="width:0;height:0;border:none;visibility:hidden;" aria-hidden="true"></iframe>'; return $out; } @@ -1859,9 +1859,10 @@ function rcmail_compose_responses_list($attrib) foreach ($RCMAIL->get_compose_responses(true) as $response) { $key = $response['key']; $item = html::a(array( - 'href '=> '#'.urlencode($response['name']), + 'href' => '#'.urlencode($response['name']), 'class' => rtrim('insertresponse ' . $attrib['itemclass']), 'unselectable' => 'on', + 'tabindex' => '0', 'rel' => $key, ), rcube::Q($response['name'])); diff --git a/program/steps/mail/func.inc b/program/steps/mail/func.inc index 50b1e8292..0dba3c125 100644 --- a/program/steps/mail/func.inc +++ b/program/steps/mail/func.inc @@ -111,7 +111,9 @@ if (empty($RCMAIL->action) || $RCMAIL->action == 'list') { if (!$OUTPUT->ajax_call) { $OUTPUT->add_label('checkingmail', 'deletemessage', 'movemessagetotrash', 'movingmessage', 'copyingmessage', 'deletingmessage', 'markingmessage', - 'copy', 'move', 'quota', 'replyall', 'replylist', 'stillsearching'); + 'copy', 'move', 'quota', 'replyall', 'replylist', 'stillsearching', + 'flagged', 'unflagged', 'unread', 'deleted', 'replied', 'forwarded', + 'priority', 'withattachment'); } $pagetitle = $RCMAIL->localize_foldername($mbox_name, true); @@ -531,14 +533,19 @@ function rcmail_message_list_head($attrib, $a_show_cols) $a_sort_cols = array('subject', 'date', 'from', 'to', 'fromto', 'size', 'cc'); if (!empty($attrib['optionsmenuicon'])) { - $onclick = 'return ' . rcmail_output::JS_OBJECT_NAME . ".command('menu-open', 'messagelistmenu')"; - if ($attrib['optionsmenuicon'] === true || $attrib['optionsmenuicon'] == 'true') - $list_menu = html::div(array('onclick' => $onclick, 'class' => 'listmenu', - 'id' => 'listmenulink', 'title' => $RCMAIL->gettext('listoptions'))); - else - $list_menu = html::a(array('href' => '#', 'onclick' => $onclick), - html::img(array('src' => $skin_path . $attrib['optionsmenuicon'], - 'id' => 'listmenulink', 'title' => $RCMAIL->gettext('listoptions')))); + $onclick = 'return ' . rcmail_output::JS_OBJECT_NAME . ".command('menu-open', 'messagelistmenu', this, event)"; + $inner = $RCMAIL->gettext('listoptions'); + if (is_string($attrib['optionsmenuicon']) && $attrib['optionsmenuicon'] != 'true') { + $inner = html::img(array('src' => $skin_path . $attrib['optionsmenuicon'], 'alt' => $RCMAIL->gettext('listoptions'))); + } + $list_menu = html::a(array( + 'href' => '#list-options', + 'onclick' => $onclick, + 'class' => 'listmenu', + 'id' => 'listmenulink', + 'title' => $RCMAIL->gettext('listoptions'), + 'tabindex' => '0', + ), $inner); } else { $list_menu = ''; @@ -558,12 +565,14 @@ function rcmail_message_list_head($attrib, $a_show_cols) // get column name switch ($col) { case 'flag': - $col_name = html::span('flagged', ' '); + $col_name = html::span('flagged', $RCMAIL->gettext('flagged')); break; case 'attachment': case 'priority': + $col_name = html::span($col, $RCMAIL->gettext($col)); + break; case 'status': - $col_name = html::span($col, ' '); + $col_name = html::span($col, $RCMAIL->gettext('readstatus')); break; case 'threads': $col_name = $list_menu; diff --git a/program/steps/mail/list_contacts.inc b/program/steps/mail/list_contacts.inc index 0ee81135b..4f17beffd 100644 --- a/program/steps/mail/list_contacts.inc +++ b/program/steps/mail/list_contacts.inc @@ -110,7 +110,7 @@ else if (!empty($result) && $result->count > 0) { $keyname = $row['_type'] == 'group' ? 'contactgroup' : 'contact'; $OUTPUT->command('add_contact_row', $row_id, array( - $keyname => html::span(array('title' => $email), rcube::Q($name ? $name : $email) . + $keyname => html::a(array('title' => $email), rcube::Q($name ? $name : $email) . ($name && count($emails) > 1 ? ' ' . html::span('email', rcube::Q($email)) : '') )), $classname); } diff --git a/program/steps/mail/search_contacts.inc b/program/steps/mail/search_contacts.inc index d56581695..ccef32dd2 100644 --- a/program/steps/mail/search_contacts.inc +++ b/program/steps/mail/search_contacts.inc @@ -87,7 +87,7 @@ if (!empty($result) && $result->count > 0) { $row_id = $row['ID'].'-'.$i; $jsresult[$row_id] = format_email_recipient($email, $name); $OUTPUT->command('add_contact_row', $row_id, array( - 'contact' => html::span(array('title' => $email), rcube::Q($name ? $name : $email) . + 'contact' => html::a(array('title' => $email), rcube::Q($name ? $name : $email) . ($name && count($emails) > 1 ? ' ' . html::span('email', rcube::Q($email)) : '') )), 'person'); } diff --git a/program/steps/mail/show.inc b/program/steps/mail/show.inc index f1c10da3d..0ebdd6277 100644 --- a/program/steps/mail/show.inc +++ b/program/steps/mail/show.inc @@ -199,6 +199,7 @@ function rcmail_message_attachments($attrib) if (sizeof($MESSAGE->attachments)) { foreach ($MESSAGE->attachments as $attach_prop) { $filename = rcmail_attachment_name($attach_prop, true); + $size = ''; if ($PRINT_MODE) { $size = $RCMAIL->message_part_size($attach_prop); @@ -213,6 +214,10 @@ function rcmail_message_attachments($attrib) $title = ''; } + if ($attach_prop->size) { + $size = ' ' . html::span('attachment-size', '(' . $RCMAIL->show_bytes($attach_prop->size) . ')'); + } + $mimetype = rcmail_fix_mimetype($attach_prop->mimetype); $class = rcube_utils::file2class($mimetype, $filename); $id = 'attach' . $attach_prop->mime_id; @@ -222,7 +227,7 @@ function rcmail_message_attachments($attrib) rcmail_output::JS_OBJECT_NAME, $attach_prop->mime_id), 'onmouseover' => $title ? '' : 'rcube_webmail.long_subject_title_ex(this, 0)', 'title' => rcube::Q($title), - ), rcube::Q($filename)); + ), rcube::Q($filename) . $size); $ol .= html::tag('li', array('class' => $class, 'id' => $id), $link); diff --git a/program/steps/settings/edit_folder.inc b/program/steps/settings/edit_folder.inc index 6b7bd08d2..c61ac6da9 100644 --- a/program/steps/settings/edit_folder.inc +++ b/program/steps/settings/edit_folder.inc @@ -132,6 +132,7 @@ function rcmail_folder_form($attrib) } $select = $RCMAIL->folder_selector(array( + 'id' => '_parent', 'name' => '_parent', 'noselection' => '---', 'realnames' => false, @@ -155,7 +156,7 @@ function rcmail_folder_form($attrib) // Settings: threading if ($threading_supported && ($mbox_imap == 'INBOX' || (!$options['noselect'] && !$options['is_root']))) { - $select = new html_select(array('name' => '_viewmode', 'id' => '_listmode')); + $select = new html_select(array('name' => '_viewmode', 'id' => '_viewmode')); $select->add($RCMAIL->gettext('list'), 0); $select->add($RCMAIL->gettext('threads'), 1); diff --git a/program/steps/settings/func.inc b/program/steps/settings/func.inc index 4b4575f10..8f9cf090f 100644 --- a/program/steps/settings/func.inc +++ b/program/steps/settings/func.inc @@ -343,7 +343,7 @@ function rcmail_user_prefs($current = null) if (is_array($meta) && $meta['name']) { $skinname = $meta['name']; $author_link = $meta['url'] ? html::a(array('href' => $meta['url'], 'target' => '_blank'), rcube::Q($meta['author'])) : rcube::Q($meta['author']); - $license_link = $meta['license-url'] ? html::a(array('href' => $meta['license-url'], 'target' => '_blank'), rcube::Q($meta['license'])) : rcube::Q($meta['license']); + $license_link = $meta['license-url'] ? html::a(array('href' => $meta['license-url'], 'target' => '_blank', 'tabindex' => '-1'), rcube::Q($meta['license'])) : rcube::Q($meta['license']); } $skinnames[] = mb_strtolower($skinname); diff --git a/program/steps/settings/responses.inc b/program/steps/settings/responses.inc index 06093b3b8..ddd1924fe 100644 --- a/program/steps/settings/responses.inc +++ b/program/steps/settings/responses.inc @@ -95,7 +95,7 @@ function rcmail_responses_list($attrib) { global $RCMAIL, $OUTPUT; - $attrib += array('id' => 'rcmresponseslist', 'tagname' => 'table', 'cols' => 1); + $attrib += array('id' => 'rcmresponseslist', 'tagname' => 'table'); $plugin = $RCMAIL->plugins->exec_hook('responses_list', array( 'list' => $RCMAIL->get_compose_responses(true), |