[IMP] web: mobile kanban tabs UX

This merge change the mobile kanban tabs UX by allowing to scroll
through them and go directly from a column A to B. It also improve the
readability independently of the title's length.

This behavior is heavily inspired by Material Design.

See: https://material.io/design/components/tabs.html

It's also an opportunity to refactor the mobile kanban renderer code to make it
more robust and readable.

Task: 1893168

closes odoo/odoo#29024
This commit is contained in:
Robot Odoo
2019-02-14 16:33:38 +01:00
committed by GitHub
3 changed files with 168 additions and 35 deletions
@@ -45,6 +45,7 @@ KanbanRenderer.include({
$column.scrollLeft(this._scrollPosition.left);
$column.scrollTop(this._scrollPosition.top);
}
this._computeTabPosition();
this._super.apply(this, arguments);
},
/**
@@ -103,24 +104,156 @@ KanbanRenderer.include({
// Private
//--------------------------------------------------------------------------
/**
* Update the columns positions
*
* @private
* @param {boolean} [animate=false] set to true to animate
*/
_computeColumnPosition: function (animate) {
if (this.widgets.length) {
var self = this;
var moveToIndex = this.activeColumnIndex;
var updateFunc = animate ? 'animate' : 'css';
_.each(this.widgets, function (column, index) {
var columnID = column.id || column.db_id;
var $column = self.$('.o_kanban_group[data-id="' + columnID + '"]');
if (index === moveToIndex - 1) {
$column[updateFunc]({left: '-100%'});
} else if (index === moveToIndex + 1) {
$column[updateFunc]({left: '100%'});
} else if (index === moveToIndex) {
$column[updateFunc]({left: '0%'});
} else if (index < moveToIndex) {
$column.css({left: '-100%'});
} else if (index > moveToIndex) {
$column.css({left: '100%'});
}
});
}
},
/**
* Define the o_current class to the current selected kanban (column & tab)
*
* @private
*/
_computeCurrentColumn: function () {
if (this.widgets.length) {
var column = this.widgets[this.activeColumnIndex];
if (!column) {
return;
}
var columnID = column.id || column.db_id;
this.$('.o_kanban_mobile_tab.o_current, .o_kanban_group.o_current')
.removeClass('o_current');
this.$('.o_kanban_group[data-id="' + columnID + '"], ' +
'.o_kanban_mobile_tab[data-id="' + columnID + '"]')
.addClass('o_current');
}
},
/**
* Update the tabs positions
*
* @private
*/
_computeTabPosition: function () {
this._computeTabJustification();
this._computeTabScrollPosition();
},
/**
* Update the tabs positions
*
* @private
*/
_computeTabScrollPosition: function () {
if (this.widgets.length) {
var lastItemIndex = this.widgets.length - 1;
var moveToIndex = this.activeColumnIndex;
var scrollToLeft = 0;
for (var i = 0; i < moveToIndex; i++) {
var columnWidth = this._getTabWidth(this.widgets[i]);
// apply
if (moveToIndex !== lastItemIndex && i === moveToIndex - 1) {
var partialWidth = 0.75;
scrollToLeft += columnWidth * partialWidth;
} else {
scrollToLeft += columnWidth;
}
}
// Apply the scroll x on the tabs
this.$('.o_kanban_mobile_tabs').scrollLeft(scrollToLeft);
}
},
/**
* Compute the justify content of the kanban tab headers
*
* @private
*/
_computeTabJustification: function () {
if (this.widgets.length) {
var self = this;
// Use to compute the sum of the width of all tab
var widthChilds = this.widgets.reduce(function (total, column) {
return total + self._getTabWidth(column);
}, 0);
// Apply a space around between child if the parent length is higher then the sum of the child width
var $tabs = this.$('.o_kanban_mobile_tabs');
$tabs.toggleClass('justify-content-around', $tabs.outerWidth() >= widthChilds);
}
},
/**
* Enables swipe event on the current column
*
* @private
* @param {KanbanColumn} column
*/
_enableSwipe: function () {
var self = this;
this.$el.swipe({
excludedElements: ".o_kanban_mobile_tabs",
swipeLeft: function () {
self._moveToGroup(self.activeColumnIndex + 1, self.ANIMATE);
var moveToIndex = self.activeColumnIndex + 1;
if (moveToIndex < self.widgets.length) {
self._moveToGroup(moveToIndex, self.ANIMATE);
}
},
swipeRight: function () {
self._moveToGroup(self.activeColumnIndex - 1, self.ANIMATE);
var moveToIndex = self.activeColumnIndex - 1;
if (moveToIndex > -1) {
self._moveToGroup(moveToIndex, self.ANIMATE);
}
}
});
},
/**
* Retrieve the outerWidth of a given widget column
*
* @param {KanbanColumn} column
* @returns {integer} outerWidth of the found column
* @private
*/
_getTabWidth : function (column) {
var columnID = column.id || column.db_id;
return this.$('.o_kanban_mobile_tab[data-id="' + columnID + '"]').outerWidth();
},
/**
* Update the kanban layout
*
* @private
* @param {boolean} [animate=false] set to true to animate
*/
_layoutUpdate : function (animate) {
this._computeCurrentColumn();
this._computeTabPosition();
this._computeColumnPosition(animate);
},
/**
* Moves to the given kanban column
*
@@ -133,6 +266,7 @@ KanbanRenderer.include({
_moveToGroup: function (moveToIndex, animate) {
var self = this;
if (moveToIndex < 0 || moveToIndex >= this.widgets.length) {
this._layoutUpdate(animate);
return $.when();
}
var def = $.Deferred();
@@ -141,36 +275,13 @@ KanbanRenderer.include({
this.trigger_up('kanban_load_records', {
columnID: column.db_id,
onSuccess: function () {
// update the columns and tabs positions (optionally with an animation)
var updateFunc = animate ? 'animate' : 'css';
self.$('.o_kanban_mobile_tab, .o_kanban_group').removeClass('o_current');
_.each(self.widgets, function (column, index) {
var columnID = column.id || column.db_id;
var $column = self.$('.o_kanban_group[data-id="' + columnID + '"]');
var $tab = self.$('.o_kanban_mobile_tab[data-id="' + columnID + '"]');
if (index === moveToIndex - 1) {
$column[updateFunc]({left: '-100%'});
$tab[updateFunc]({left: '0%'});
} else if (index === moveToIndex + 1) {
$column[updateFunc]({left: '100%'});
$tab[updateFunc]({left: '100%'});
} else if (index === moveToIndex) {
$column[updateFunc]({left: '0%'});
$tab[updateFunc]({left: '50%'});
$tab.add($column).addClass('o_current');
} else if (index < moveToIndex) {
$column.css({left: '-100%'});
$tab[updateFunc]({left: '-100%'});
} else if (index > moveToIndex) {
$column.css({left: '100%'});
$tab[updateFunc]({left: '200%'});
}
});
self._layoutUpdate(animate);
def.resolve();
},
});
return def;
},
/**
* @override
* @private
@@ -193,6 +304,7 @@ KanbanRenderer.include({
})).prependTo(fragment);
return result;
},
/**
* @override
* @private
@@ -201,7 +313,8 @@ KanbanRenderer.include({
var self = this;
return this._super.apply(this, arguments).then(function () {
if (self.state.groupedBy.length) {
return self._moveToGroup(self.activeColumnIndex);
// force first column for kanban view, because the groupedBy can be changed
return self._moveToGroup(0);
}
});
},
@@ -5,15 +5,16 @@
overflow: hidden;
.o_kanban_mobile_tabs {
display:flex;
width: 100%;
height: $o-kanban-mobile-tabs-height;
position: relative;
overflow-x: auto;
background-color: #5E5E5E;
.o_kanban_mobile_tab {
@include o-position-absolute($left: 100%);
transform: translateX(-50%);
height: $o-kanban-mobile-tabs-height;
padding: 10px;
padding: 10px 20px;
font-size: 14px;
color: white;
@@ -23,6 +24,7 @@
}
.o_column_title {
white-space: nowrap;
text-transform: uppercase;
}
}
}
@@ -56,7 +56,7 @@ QUnit.module('Views', {
QUnit.module('KanbanView Mobile');
QUnit.test('mobile grouped rendering', function (assert) {
assert.expect(11);
assert.expect(13);
var kanban = createView({
View: KanbanView,
@@ -110,10 +110,16 @@ QUnit.module('Views', {
}).get();
assert.deepEqual(column_ids, tab_ids, "all columns data-id should match mobile tabs data-id");
// kanban tabs with tab with lower width then available with have justify-content-around class
assert.containsN(kanban, '.o_kanban_mobile_tabs.justify-content-around', 1,
"should have justify-content-around class");
assert.hasClass(kanban.$('.o_kanban_mobile_tabs'), 'justify-content-around',
"the mobile tabs have the class 'justify-content-around'");
kanban.destroy();
});
QUnit.test('mobile grouped with undefined column', function (assert) {
assert.expect(3);
assert.expect(5);
var kanban = createView({
View: KanbanView,
@@ -141,10 +147,16 @@ QUnit.module('Views', {
}).get();
assert.deepEqual(column_ids, tab_ids, "all columns data-id should match mobile tabs data-id");
// kanban tabs with tab with lower width then available with have justify-content-around class
assert.containsN(kanban, '.o_kanban_mobile_tabs.justify-content-around', 1,
"should have justify-content-around class");
assert.hasClass(kanban.$('.o_kanban_mobile_tabs'), 'justify-content-around',
"the mobile tabs have the class 'justify-content-around'");
kanban.destroy();
});
QUnit.test('mobile grouped on many2one rendering', function (assert) {
assert.expect(3);
assert.expect(5);
var kanban = createView({
View: KanbanView,
@@ -172,6 +184,12 @@ QUnit.module('Views', {
}).get();
assert.deepEqual(column_ids, tab_ids, "all columns data-id should match mobile tabs data-id");
// kanban tabs with tab with lower width then available with have justify-content-around class
assert.containsN(kanban, '.o_kanban_mobile_tabs.justify-content-around', 1,
"should have justify-content-around class");
assert.hasClass(kanban.$('.o_kanban_mobile_tabs'), 'justify-content-around',
"the mobile tabs have the class 'justify-content-around'");
kanban.destroy();
});