[IMP] web: keyboard navigation in kanban view

1. Added the ability to navigate within the kanban view using UP,
        DOWN, LEFT and RIGHT keys.
    2. By defaults, the focus should be set on the search input field.
    3. From the search input field, using the DOWN key, the focus goes
        to the first card of the kanban
    4. From the search input field, using TAB key, the focus goes to the
        create button
    5. From the create/import buttons, using TAB or DOWN key goes to the
        first card of the kanban, then UP and DOWN keys moves the focus
        from card to card in the same column or if it is not a grouped
        view from left to right.
    6. In grouped view, LEFT and RIGHT keys moves the focus to the next
        column
    7. When on a card, hitting ENTER opens the view of that card.

Not included in this commit
    1. Memorize the position on the kanban when opening an card, than restoring
        when going back to that kanban
This commit is contained in:
Vincent Schippefilt
2018-04-10 09:39:08 +02:00
parent e0924fd01d
commit bc1bba446a
8 changed files with 170 additions and 8 deletions
@@ -107,6 +107,9 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
*/
on_attach_callback: function () {
this.renderer.on_attach_callback();
if (this.searchView) {
this.searchView.on_attach_callback();
}
},
/**
* Called each time the controller is detached from the DOM.
@@ -58,6 +58,7 @@ var KanbanController = BasicController.extend({
if (this.hasButtons && this.is_action_enabled('create')) {
this.$buttons = $(qweb.render('KanbanView.buttons', {widget: this}));
this.$buttons.on('click', 'button.o-kanban-button-new', this._onButtonNew.bind(this));
this.$buttons.on('keydown',this._onButtonsKeyDown.bind(this));
this._updateButtons();
this.$buttons.appendTo($node);
}
@@ -220,6 +221,18 @@ var KanbanController = BasicController.extend({
});
}
},
/**
* Moves the focus from the controller buttons to the first kanban record
*
* @private
* @param {jQueryEvent} e
*/
_onButtonsKeyDown: function (e) {
switch(e.keyCode) {
case $.ui.keyCode.DOWN:
this.$('.o_kanban_record:first').focus();
}
},
/**
* @private
* @param {OdooEvent} event
@@ -23,6 +23,7 @@ var KanbanRecord = Widget.extend({
events: {
'click .oe_kanban_action': '_onKanbanActionClicked',
'click .o_kanban_manage_toggle_button': '_onManageTogglerClicked',
'keydown' : '_onKanbanKeyDown',
},
/**
* @override
@@ -283,7 +284,7 @@ var KanbanRecord = Widget.extend({
*/
_render: function () {
this._replaceElement(this.qweb.render('kanban-box', this.qweb_context));
this.$el.addClass('o_kanban_record');
this.$el.addClass('o_kanban_record').attr("tabindex",0);
this.$el.data('record', this);
if (this.$el.hasClass('oe_kanban_global_click') ||
this.$el.hasClass('oe_kanban_global_click_edit')) {
@@ -504,6 +505,14 @@ var KanbanRecord = Widget.extend({
this.do_warn("Kanban: no action for type : " + type);
}
},
_onKanbanKeyDown: function (event) {
switch (event.keyCode) {
case $.ui.keyCode.ENTER:
event.preventDefault();
this._openRecord();
break;
}
},
/**
* Toggles the configuration panel of the record
*
@@ -88,6 +88,61 @@ var KanbanRenderer = BasicRenderer.extend({
start_quick_create: '_onStartQuickCreate',
quick_create_column_updated: '_onQuickCreateColumnUpdated',
}),
events:_.extend({}, BasicRenderer.prototype.events || {}, {
'keydown .o_kanban_record' : '_onRecordKeyDown'
}),
_focusOnNextCard: function(currentCardElement) {
var nextCard = currentCardElement.nextElementSibling;
if (nextCard) {
nextCard.focus();
}
},
_focusOnPrevousCard: function(currentCardElement) {
var previousCard = currentCardElement.previousElementSibling;
if (previousCard && previousCard.classList.contains("o_kanban_record")) { //previous element might be column title
previousCard.focus();
}
},
_onRecordKeyDown: function(e) {
switch(e.which) {
case $.ui.keyCode.DOWN:
this._focusOnNextCard(e.currentTarget);
e.stopPropagation();
break;
case $.ui.keyCode.UP:
this._focusOnPrevousCard(e.currentTarget);
e.stopPropagation();
break;
case $.ui.keyCode.RIGHT:
var currentColumn = e.currentTarget.parentElement;
var nextColumn = currentColumn.nextElementSibling;
if (nextColumn) {
var allCardsOfNextColumn = nextColumn.getElementsByClassName('o_kanban_record');
if (allCardsOfNextColumn.length) {
allCardsOfNextColumn[0].focus();
}
}
else {
this._focusOnNextCard(e.currentTarget);
}
e.stopPropagation();
break;
case $.ui.keyCode.LEFT:
var currentColumn = e.currentTarget.parentElement;
var previousColumn = currentColumn.previousElementSibling;
if (previousColumn) {
var allCardsOfPreviousColumn = previousColumn.getElementsByClassName('o_kanban_record');
if (allCardsOfPreviousColumn.length) {
allCardsOfPreviousColumn[0].focus();
}
}
else {
this._focusOnPrevousCard(e.currentTarget);
}
e.stopPropagation();
break;
}
},
/**
* @override
@@ -132,6 +187,9 @@ var KanbanRenderer = BasicRenderer.extend({
addQuickCreate: function () {
return this.widgets[0].addQuickCreate();
},
giveFocus:function() {
this.$('.o_kanban_record:first').focus();
},
/**
* Toggle fold/unfold the Column quick create widget
*/
@@ -43,6 +43,14 @@
margin-bottom: 10px;
}
&:focus, &:focus-within {
-webkit-box-shadow: 1px 1px 3px 0px rgba(0,0,0,1);
-moz-box-shadow: 1px 1px 3px 0px rgba(0,0,0,1);
box-shadow: 1px 1px 3px 0px rgba(0,0,0,1);
z-index: 1; // show the shadow on top of the previous & next cards in grouped mode
outline: none;
}
// ------- Generic layout adaptations -------
.container {
width: 100%;
@@ -154,6 +154,13 @@
@media (max-width: @screen-xs-max) {
padding: @o-kanban-inside-vgutter @o-kanban-inside-hgutter-mobile;
}
&:focus, &:focus-within {
-webkit-box-shadow: 3px 3px 10px 0px rgba(0,0,0,1);
-moz-box-shadow: 3px 3px 10px 0px rgba(0,0,0,1);
box-shadow: 3px 3px 10px 0px rgba(0,0,0,1);
z-index: 1; // show the shadow on top of the previous & next cards in grouped mode
outline: none;
}
}
.o_kanban_quick_create {
+7 -7
View File
@@ -203,7 +203,7 @@
<button type="button" t-att-accesskey="view.accessKey"
t-attf-class="btn btn-icon fa fa-lg #{view.icon} o_cp_switch_#{view.type}"
t-att-aria-label="view.type"
t-att-data-view-type="view.type" t-att-title="view.label"/>
t-att-data-view-type="view.type" t-att-title="view.label" tabindex="-1"/>
</t>
</t>
@@ -1033,7 +1033,7 @@
<ul class="dropdown-menu o_searchview_autocomplete" role="menu"></ul>
</t>
<div t-name="SearchView.FilterMenu" class="btn-group o_dropdown">
<button class="o_dropdown_toggler_btn btn btn-sm dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
<button class="o_dropdown_toggler_btn btn btn-sm dropdown-toggle" data-toggle="dropdown" aria-expanded="false" tabindex="-1">
<span class="fa fa-filter"/> Filters <span class="caret"/>
</button>
<ul class="dropdown-menu o_filters_menu" role="menu">
@@ -1145,7 +1145,7 @@
</select>
</t>
<div t-name="SearchView.GroupByMenu" class="btn-group hidden-xs o_dropdown">
<button class="o_dropdown_toggler_btn btn btn-sm dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
<button class="o_dropdown_toggler_btn btn btn-sm dropdown-toggle" data-toggle="dropdown" aria-expanded="false" tabindex="-1">
<span class="fa fa-bars"/> Group By <span class="caret"/>
</button>
<ul class="dropdown-menu o_group_by_menu" role="menu">
@@ -1162,11 +1162,11 @@
</t>
</select></li>
<li>
<button type="button" class="btn btn-primary btn-sm o_add_group o_select_group">Apply</button>
<button type="button" class="btn btn-primary btn-sm o_add_group o_select_group" tabindex="-1">Apply</button>
</li>
</t>
<div t-name="SearchView.FavoriteMenu" class="btn-group o_dropdown">
<button class="o_dropdown_toggler_btn btn btn-sm dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
<button class="o_dropdown_toggler_btn btn btn-sm dropdown-toggle" data-toggle="dropdown" aria-expanded="false" tabindex="-1">
<span class="fa fa-star"/> Favorites <span class="caret"/>
</button>
<ul class="dropdown-menu o_favorites_menu" role="menu">
@@ -1310,8 +1310,8 @@
<t t-set="att_prev" t-value="{'accesskey': 'p'}" />
<t t-set="att_next" t-value="{'accesskey': 'n'}" />
</t>
<button type="button" class="fa fa-chevron-left btn btn-icon o_pager_previous" t-att="att_prev" aria-label="Previous"/>
<button type="button" class="fa fa-chevron-right btn btn-icon o_pager_next" t-att="att_next" aria-label="Next"/>
<button type="button" class="fa fa-chevron-left btn btn-icon o_pager_previous" t-att="att_prev" aria-label="Previous" tabindex="-1"/>
<button type="button" class="fa fa-chevron-right btn btn-icon o_pager_next" t-att="att_next" aria-label="Next" tabindex="-1"/>
</span>
</div>
@@ -3339,6 +3339,70 @@ QUnit.module('Views', {
testUtils.unpatch(mixins.ParentedMixin);
});
QUnit.test('keyboard navigation on kanban basic rendering', function (assert) {
assert.expect(3);
var kanban = createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch: '<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
'<div>' +
'<t t-esc="record.foo.value"/>' +
'<field name="foo"/>' +
'</div>' +
'</t></templates></kanban>',
});
var $fisrtCard = kanban.$('.o_kanban_record:first');
var $secondCard = kanban.$('.o_kanban_record:eq(1)');
$fisrtCard.focus();
assert.strictEqual(document.activeElement, $fisrtCard[0], "the kanban cards are focussable");
$fisrtCard.trigger($.Event('keydown', { which: $.ui.keyCode.RIGHT, keyCode: $.ui.keyCode.RIGHT, }));
assert.strictEqual(document.activeElement, $secondCard[0], "the second card should be focussed");
$secondCard.trigger($.Event('keydown', { which: $.ui.keyCode.LEFT, keyCode: $.ui.keyCode.LEFT, }));
assert.strictEqual(document.activeElement, $fisrtCard[0], "the first card should be focussed");
kanban.destroy();
});
QUnit.test('keyboard navigation on kanban grouped rendering', function (assert) {
assert.expect(3);
var kanban = createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch: '<kanban class="o_kanban_test">' +
'<field name="bar"/>' +
'<templates><t t-name="kanban-box">' +
'<div><field name="foo"/></div>' +
'</t></templates></kanban>',
groupBy: ['bar'],
});
var $firstColumnFisrtCard = kanban.$('.o_kanban_record:first');
var $secondColumnFirstCard = kanban.$('.o_kanban_group:eq(1) .o_kanban_record:first');
var $secondColumnSecondCard = kanban.$('.o_kanban_group:eq(1) .o_kanban_record:eq(1)');
$firstColumnFisrtCard.focus();
//RIGHT should select the next column
$firstColumnFisrtCard.trigger($.Event('keydown', { which: $.ui.keyCode.RIGHT, keyCode: $.ui.keyCode.RIGHT, }));
assert.strictEqual(document.activeElement, $secondColumnFirstCard[0], "RIGHT should select the first card of the next column");
//DOWN should move up one card
$secondColumnFirstCard.trigger($.Event('keydown', { which: $.ui.keyCode.DOWN, keyCode: $.ui.keyCode.DOWN, }));
assert.strictEqual(document.activeElement, $secondColumnSecondCard[0], "DOWN should select the second card of the current column");
//LEFT should go back to the first column
$secondColumnSecondCard.trigger($.Event('keydown', { which: $.ui.keyCode.LEFT, keyCode: $.ui.keyCode.LEFT, }));
assert.strictEqual(document.activeElement, $firstColumnFisrtCard[0], "LEFT should select the first card of the first column");
kanban.destroy();
});
});
});