From 378d10b748f71311e661cbbc722ea8f393294711 Mon Sep 17 00:00:00 2001
From: Aleksander Machniak <alec@alec.pl>
Date: Fri, 20 May 2016 05:17:21 -0400
Subject: [PATCH] Fix bug where message list columns could be in wrong order after column drag-n-drop and list sorting

---
 program/js/list.js |  284 +++++++++++++++++++++++++++++++++++---------------------
 1 files changed, 179 insertions(+), 105 deletions(-)

diff --git a/program/js/list.js b/program/js/list.js
index 6a0c5b3..9b0bd9a 100644
--- a/program/js/list.js
+++ b/program/js/list.js
@@ -51,7 +51,7 @@
   this.rowcount = 0;
   this.colcount = 0;
 
-  this.subject_col = -1;
+  this.subject_col = 0;
   this.modkey = 0;
   this.multiselect = false;
   this.multiexpand = false;
@@ -60,12 +60,14 @@
   this.column_movable = false;
   this.keyboard = false;
   this.toggleselect = false;
+  this.aria_listbox = false;
+  this.parent_focus = true;
 
   this.drag_active = false;
   this.col_drag_active = false;
   this.column_fixed = null;
-  this.last_selected = 0;
-  this.shift_start = 0;
+  this.last_selected = null;
+  this.shift_start = null;
   this.focused = false;
   this.drag_mouse_start = null;
   this.dblclick_time = 500; // default value on MS Windows is 500
@@ -75,6 +77,9 @@
   if (p && typeof p === 'object')
     for (var n in p)
       this[n] = p[n];
+
+  // register this instance
+  rcube_list_widget._instances.push(this);
 };
 
 
@@ -94,14 +99,23 @@
     this.tbody = this.list;
   }
 
+  if ($(this.list).attr('role') == 'listbox') {
+    this.aria_listbox = true;
+    if (this.multiselect)
+      $(this.list).attr('aria-multiselectable', 'true');
+  }
+
+  var me = this;
+
   if (this.tbody) {
     this.rows = {};
     this.rowcount = 0;
 
-    var r, len, rows = this.tbody.childNodes, me = this;
+    var r, len, rows = this.tbody.childNodes;
 
     for (r=0, len=rows.length; r<len; r++) {
-      this.rowcount += this.init_row(rows[r]) ? 1 : 0;
+      if (rows[r].nodeType == 1)
+        this.rowcount += this.init_row(rows[r]) ? 1 : 0;
     }
 
     this.init_header();
@@ -111,16 +125,14 @@
     if (this.keyboard) {
       rcube_event.add_listener({event:'keydown', object:this, method:'key_press'});
 
-      // install a link element to receive focus.
-      // this helps to maintain the natural tab order when moving focus with keyboard
-      this.focus_elem = $('<a>')
-        .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); });
+      // allow the table element to receive focus.
+      $(this.list).attr('tabindex', '0')
+        .on('focus', function(e){ me.focus(e); });
     }
+  }
+
+  if (this.parent_focus) {
+    this.list.parentNode.onclick = function(e) { me.focus(); };
   }
 
   return this;
@@ -139,11 +151,17 @@
     var self = this, uid = row.uid;
     this.rows[uid] = {uid:uid, id:row.id, obj:row};
 
-    // set eventhandlers to table row
-    row.onmousedown = function(e){ return self.drag_row(e, this.uid); };
-    row.onmouseup = function(e){ return self.click_row(e, this.uid); };
+    $(row).data('uid', uid)
+      // set eventhandlers to table row (only left-button-clicks in mouseup)
+      .mousedown(function(e) { return self.drag_row(e, this.uid); })
+      .mouseup(function(e) {
+        if (e.which == 1 && !self.drag_active)
+          return self.click_row(e, this.uid);
+        else
+          return true;
+      });
 
-    if (bw.touch) {
+    if (bw.touch && row.addEventListener) {
       row.addEventListener('touchstart', function(e) {
         if (e.touches.length == 1) {
           self.touchmoved = false;
@@ -163,6 +181,15 @@
             e.preventDefault();
         }
       }, 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)
@@ -186,9 +213,9 @@
 
     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', '').find('a.sortcol').attr('tabindex', '-1');  // 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 && 0) {
+    else if (!bw.touch && this.list.className.indexOf('fixedheader') >= 0) {
       this.init_fixed_header();
     }
 
@@ -199,7 +226,7 @@
         if (this.column_fixed == r)
           continue;
         col = this.thead.rows[0].cells[r];
-        col.onmousedown = function(e){ return p.drag_column(e, this); };
+        col.onmousedown = function(e) { return p.drag_column(e, this); };
         this.colcount++;
       }
     }
@@ -213,18 +240,20 @@
   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>');
     $(this.list).before(this.fixed_header);
 
     var me = this;
-    $(window).resize(function(){ me.resize() });
-    $(window).scroll(function(){
+    $(window).resize(function() { me.resize(); });
+    $(window).scroll(function() {
       var w = $(window);
-      me.fixed_header.css('marginLeft', (-w.scrollLeft()) + 'px');
-      if (!bw.webkit)
-        me.fixed_header.css('marginTop', (-w.scrollTop()) + 'px');
+      me.fixed_header.css({
+        marginLeft: -w.scrollLeft() + 'px',
+        marginTop: -w.scrollTop() + 'px'
+      });
     });
   }
   else {
@@ -249,14 +278,14 @@
     var column_widths = [];
 
     // get column widths from original thead
-    $(this.tbody).parent().find('thead tr td').each(function(index) {
+    $(this.tbody).parent().find('thead th,thead td').each(function(index) {
       column_widths[index] = $(this).width();
     });
 
     // apply fixed widths to fixed table header
     $(this.thead).parent().width($(this.tbody).parent().width());
-    $(this.thead).find('tr td').each(function(index) {
-      $(this).css('width', column_widths[index]);
+    $(this.thead).find('th,td').each(function(index) {
+      $(this).width(column_widths[index]);
     });
 
     $(window).scroll();
@@ -279,11 +308,10 @@
 
   this.rows = {};
   this.rowcount = 0;
+  this.last_selected = null;
 
   if (sel)
     this.clear_selection();
-  else
-    this.last_selected = 0;
 
   // reset scroll position (in Opera)
   if (this.frame)
@@ -329,10 +357,11 @@
   if (row.nodeName === undefined) {
     // for performance reasons use DOM instead of jQuery here
     var domrow = document.createElement(this.row_tagname());
+
     if (row.id) domrow.id = row.id;
+    if (row.uid) domrow.uid = row.uid;
     if (row.className) domrow.className = row.className;
     if (row.style) $.extend(domrow.style, row.style);
-    if (row.uid) $(domrow).data('uid', row.uid);
 
     for (var e, domcell, col, i=0; row.cols && i < row.cols.length; i++) {
       col = row.cols[i];
@@ -368,18 +397,22 @@
   var row = this.rows[id];
   if (!row) return false;
 
-  var domrow = row.obj;
-  for (var domcell, col, i=0; cols && i < cols.length; i++) {
+  var i, domrow = row.obj;
+  for (i = 0; cols && i < cols.length; i++) {
     this.get_cell(domrow, i).html(cols[i]);
   }
 
   if (newid) {
     delete this.rows[id];
+    domrow.uid = newid;
     domrow.id = 'rcmrow' + newid;
     this.init_row(domrow);
 
     if (select)
       this.selection[0] = newid;
+
+    if (this.last_selected == id)
+      this.last_selected = newid;
   }
 },
 
@@ -392,23 +425,21 @@
   if (this.focused)
     return;
 
-  var n, id;
   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)
-  if (this.focus_elem) {
+  if (focus_elem && focus_elem.length) {
     // We now fix this by explicitly assigning focus to a dedicated link element
-    this.focus_elem.focus();
+    this.focus_noscroll(focus_elem);
   }
   else {
     // It looks that window.focus() does the job for all browsers, but not Firefox (#1489058)
@@ -416,7 +447,7 @@
     window.focus();
   }
 
-  $(this.list).addClass('focus');
+  $(this.list).addClass('focus').removeAttr('tabindex');
 
   // set internal focus pointer to first row
   if (!this.last_selected)
@@ -429,16 +460,28 @@
  */
 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');
   }
-  
-  $(this.list).removeClass('focus');
+},
+
+/**
+ * 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;
 },
 
 
@@ -529,6 +572,10 @@
  */
 click_row: function(e, id)
 {
+  // sanity check
+  if (!id || !this.rows[id])
+    return false;
+
   // don't do anything (another action processed before)
   if (!this.is_event_target(e))
     return true;
@@ -786,14 +833,16 @@
 
 get_row_uid: function(row)
 {
-  if (row && row.uid)
-    return row.uid;
+  if (!row)
+    return;
 
-  var uid;
-  if (row && (uid = $(row).data('uid')))
-    row.uid = uid;
-  else if (row && String(row.id).match(this.id_regexp))
-    row.uid = RegExp.$1;
+  if (!row.uid) {
+    var uid = $(row).data('uid');
+    if (uid)
+      row.uid = uid;
+    else if (String(row.id).match(this.id_regexp))
+      row.uid = RegExp.$1;
+  }
 
   return row.uid;
 },
@@ -832,9 +881,9 @@
 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;
   }
@@ -877,9 +926,10 @@
  */
 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)
@@ -915,20 +965,26 @@
     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 && 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 && !mod_key) {
     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;
@@ -1048,11 +1104,11 @@
 /**
  * Check if given id is part of the current selection
  */
-in_selection: function(id)
+in_selection: function(id, index)
 {
   for (var n in this.selection)
     if (this.selection[n] == id)
-      return true;
+      return index ? parseInt(n) : true;
 
   return false;
 },
@@ -1076,7 +1132,7 @@
       this.highlight_row(n, true, true);
     }
     else {
-      $(this.rows[n].obj).removeClass('selected').removeClass('unfocused');
+      $(this.rows[n].obj).removeClass('selected').removeAttr('aria-selected');
     }
   }
 
@@ -1133,7 +1189,7 @@
   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 = [];
@@ -1141,7 +1197,7 @@
 
   if (num_select && !this.selection.length && !no_event) {
     this.triggerEvent('select');
-    this.last_selected = 0;
+    this.last_selected = null;
   }
 },
 
@@ -1196,23 +1252,24 @@
     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
+    var pre, post, p = this.in_selection(id, true);
+
+    if (p === false) { // 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);
     }
     else { // unselect row
-      var p = $.inArray(id, this.selection),
-        a_pre = this.selection.slice(0, p),
-        a_post = this.selection.slice(p+1, this.selection.length);
+      pre = this.selection.slice(0, p);
+      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.selection = pre.concat(post);
+      $(this.rows[id].obj).removeClass('selected').removeAttr('aria-selected');
       if (!norecur && !this.rows[id].expanded)
         this.highlight_children(id, false);
     }
@@ -1291,6 +1348,14 @@
       }
 
       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;
@@ -1372,7 +1437,8 @@
  */
 scrollto: function(id)
 {
-  var row = this.rows[id].obj;
+  var row = this.rows[id] ? this.rows[id].obj : null;
+
   if (row && this.frame) {
     var scroll_to = Number(row.offsetTop),
       head_offset = 0;
@@ -1461,7 +1527,11 @@
 
       $('> ' + self.col_tagname(), self.rows[uid].obj).each(function(n, cell) {
         if (self.subject_col < 0 || (self.subject_col >= 0 && self.subject_col == n)) {
-          var subject = $(cell).text();
+          // remove elements marked with "skip-on-drag" class
+          cell = $(cell).clone();
+          $(cell).find('.skip-on-drag').remove();
+
+          var subject = cell.text();
 
           if (subject) {
             // remove leading spaces
@@ -1618,32 +1688,33 @@
     this.col_draglayer = null;
   }
 
-  if (this.col_drag_active)
-    this.focus();
-  this.col_drag_active = false;
-
   rcube_event.remove_listener({event:'mousemove', object:this, method:'column_drag_mouse_move'});
   rcube_event.remove_listener({event:'mouseup', object:this, method:'column_drag_mouse_up'});
+
   // remove temp divs
   this.del_dragfix();
 
-  if (this.selected_column !== null && this.cols && this.cols.length) {
-    var i, cpos = 0, pos = rcube_event.get_mouse_pos(e);
+  if (this.col_drag_active) {
+    this.col_drag_active = false;
+    this.focus();
+    this.triggerEvent('column_dragend', e);
 
-    // find destination position
-    for (i=0; i<this.cols.length; i++) {
-      if (pos.x >= this.cols[i]/2 + this.list_pos + cpos)
-        cpos += this.cols[i];
-      else
-        break;
-    }
+    if (this.selected_column !== null && this.cols && this.cols.length) {
+      var i, cpos = 0, pos = rcube_event.get_mouse_pos(e);
 
-    if (i != this.selected_column && i != this.selected_column+1) {
-      this.column_replace(this.selected_column, i);
+      // find destination position
+      for (i=0; i<this.cols.length; i++) {
+        if (pos.x >= this.cols[i]/2 + this.list_pos + cpos)
+          cpos += this.cols[i];
+        else
+          break;
+      }
+
+      if (i != this.selected_column && i != this.selected_column+1) {
+        this.column_replace(this.selected_column, i);
+      }
     }
   }
-
-  this.triggerEvent('column_dragend', e);
 
   return rcube_event.cancel(e);
 },
@@ -1756,3 +1827,6 @@
 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 = [];

--
Gitblit v1.9.1