[FIX] web: properly handle field navigation for all fields

Before this commit, only fields which were specializations of the
`InputField` were handling up/right/down/left navigation in editable
list view. As m2o field is not an `InputField` (yet?), it was not
possible to navigate from one of those for example.

Now, navigation events are handled by the `AbstractField` class. If a
given field has to handle navigation a different way, it is its
responsability to stop the associated navigation event. For example,
`InputField` instances are stopping the left/right event if the cursor
is not at the start/end of the <input/> element.

Also, before this commit, there were many events ("move_down",
"move_left", "move_next", ...). Now there is only one event for
navigation: "navigation_move". This event is given a "direction" data
("left", "next", ...). It also comes with a "target" data which is a
reference to the last `AbstractField` instance the navigation event
has crossed.
This commit is contained in:
qsm-odoo
2017-05-08 10:38:05 +02:00
parent c04b840a45
commit c1377f5972
7 changed files with 255 additions and 195 deletions
@@ -37,6 +37,9 @@ var AbstractField = Widget.extend({
events: {
'keydown': '_onKeydown',
},
custom_events: {
navigation_move: '_onNavigationMove',
},
/**
* If this flag is set to true, the field widget will be reset on every
* change which is made in the view (if the view supports it). This is
@@ -374,21 +377,60 @@ var AbstractField = Widget.extend({
//--------------------------------------------------------------------------
/**
* might be controversial: intercept the tab key, to allow the editable list
* view to control where the focus is.
* Intercepts navigation keyboard events to prevent their default behavior
* and notifies the view so that it can handle it its own way.
*
* Note: the navigation keyboard events are stopped so that potential parent
* abstract field does not trigger the navigation_move event a second time.
* However, this might be controversial, we might wanna let the event
* continue its propagation and flag it to say that navigation has already
* been handled (TODO ?).
*
* @private
* @param {KeyEvent} event
* @param {KeyEvent} ev
*/
_onKeydown: function (event) {
if (event.which === $.ui.keyCode.TAB) {
// the event needs to be stopped, to prevent other field widgets
// to retrigger a move event
event.stopPropagation();
this.trigger_up(event.shiftKey ? 'move_previous' : 'move_next');
event.preventDefault();
_onKeydown: function (ev) {
switch (ev.which) {
case $.ui.keyCode.TAB:
ev.preventDefault();
ev.stopPropagation();
this.trigger_up('navigation_move', {
direction: ev.shiftKey ? 'previous' : 'next',
});
break;
case $.ui.keyCode.ENTER:
ev.stopPropagation();
this.trigger_up('navigation_move', {direction: 'next_line'});
break;
case $.ui.keyCode.UP:
ev.stopPropagation();
this.trigger_up('navigation_move', {direction: 'up'});
break;
case $.ui.keyCode.RIGHT:
ev.stopPropagation();
this.trigger_up('navigation_move', {direction: 'right'});
break;
case $.ui.keyCode.DOWN:
ev.stopPropagation();
this.trigger_up('navigation_move', {direction: 'down'});
break;
case $.ui.keyCode.LEFT:
ev.stopPropagation();
this.trigger_up('navigation_move', {direction: 'left'});
break;
}
},
/**
* Updates the target data value with the current AbstractField instance.
* This allows to consider the parent field in case of nested fields. The
* field which triggered the event is still accessible through ev.target.
*
* @private
* @param {OdooEvent} ev
*/
_onNavigationMove: function (ev) {
ev.data.target = this;
},
});
return AbstractField;
+17 -49
View File
@@ -277,40 +277,20 @@ var InputField = DebouncedField.extend({
this._doDebouncedAction();
},
/**
* Implement keyboard movements. Mostly useful for its environment, such
* as a list view.
* Stops the left/right navigation move event if the cursor is not at the
* start/end of the input element.
*
* @override
* @private
* @param {any} event
* @param {OdooEvent} ev
*/
_onKeydown: function (event) {
var input = this.$input[0];
var is_not_selecting;
switch (event.which) {
case $.ui.keyCode.DOWN:
this.trigger_up('move_down');
break;
case $.ui.keyCode.UP:
this.trigger_up('move_up');
break;
case $.ui.keyCode.LEFT:
is_not_selecting = input.selectionEnd === input.selectionStart;
if (is_not_selecting && input.selectionStart === 0) {
this.trigger_up('move_left');
}
break;
case $.ui.keyCode.RIGHT:
is_not_selecting = input.selectionEnd === input.selectionStart;
if (is_not_selecting && input.selectionEnd === input.value.length) {
this.trigger_up('move_right');
}
break;
case $.ui.keyCode.ENTER:
this.trigger_up('move_next_line');
break;
}
_onNavigationMove: function (ev) {
this._super.apply(this, arguments);
var input = this.$input[0];
var selecting = (input.selectionEnd !== input.selectionStart);
if ((ev.data.direction === "left" && (selecting || input.selectionStart !== 0))
|| (ev.data.direction === "right" && (selecting || input.selectionStart !== input.value.length))) {
ev.stopPropagation();
}
},
});
@@ -660,28 +640,16 @@ var FieldBoolean = AbstractField.extend({
*
* @override
* @private
* @param {KeyEvent} event
* @param {KeyEvent} ev
*/
_onKeydown: function (event) {
this._super.apply(this, arguments);
switch (event.which) {
case $.ui.keyCode.DOWN:
this.trigger_up('move_down');
break;
case $.ui.keyCode.UP:
this.trigger_up('move_up');
break;
case $.ui.keyCode.LEFT:
this.trigger_up('move_left');
break;
case $.ui.keyCode.RIGHT:
this.trigger_up('move_right');
break;
_onKeydown: function (ev) {
switch (ev.which) {
case $.ui.keyCode.ENTER:
this.$input.prop('checked', !this.value);
this._setValue(!this.value);
break;
return;
}
this._super.apply(this, arguments);
},
});
@@ -1904,10 +1872,10 @@ var FieldDomain = AbstractField.extend({
"click .o_domain_show_selection_button": "_onShowSelectionButtonClick",
"click .o_field_domain_dialog_button": "_onDialogEditButtonClick",
}),
custom_events: {
custom_events: _.extend({}, AbstractField.prototype.custom_events, {
"domain_changed": "_onDomainSelectorValueChange",
"domain_selected": "_onDomainSelectorDialogValueChange",
},
}),
/**
* @constructor
* @override init from AbstractField
@@ -14,6 +14,7 @@ odoo.define('web.relational_fields', function (require) {
*/
var AbstractField = require('web.AbstractField');
var basicFields = require('web.basic_fields');
var concurrency = require('web.concurrency');
var ControlPanel = require('web.ControlPanel');
var dialogs = require('web.view_dialogs');
@@ -76,10 +77,10 @@ var M2ODialog = Dialog.extend({
var FieldMany2One = AbstractField.extend({
supportedFieldTypes: ['many2one'],
template: 'FieldMany2One',
custom_events: {
custom_events: _.extend({}, AbstractField.prototype.custom_events, {
'quick_create': '_onQuickCreate',
'search_create_popup': '_onSearchCreatePopup',
},
}),
events: _.extend({}, AbstractField.prototype.events, {
'click input': '_onInputClick',
'focusout input': '_onInputFocusout',
@@ -460,6 +461,19 @@ var FieldMany2One = AbstractField.extend({
this._updateExternalButton();
}
},
/**
* Stops the left/right navigation move event if the cursor is not at the
* start/end of the input element. Stops any navigation move event if the
* user is selecting text.
*
* TODO this is a duplicate of InputField._onNavigationMove, maybe this
* should be done in a mixin or, better, the m2o field should be an
* InputField (but this requires some refactoring).
*
* @private
* @param {OdooEvent} ev
*/
_onNavigationMove: basicFields.InputField.prototype._onNavigationMove,
/**
* @private
* @param {OdooEvent} event
@@ -1215,13 +1229,11 @@ var FieldMany2ManyTags = AbstractField.extend({
tag_template: "FieldMany2ManyTag",
className: "o_field_many2manytags",
supportedFieldTypes: ['many2many'],
custom_events: {
custom_events: _.extend({}, AbstractField.prototype.custom_events, {
field_changed: '_onFieldChanged',
move_next: '_onMoveNext',
},
}),
events: _.extend({}, AbstractField.prototype.events, {
'click .o_delete': '_onDeleteTag',
'keydown .o_field_many2one input': '_onKeyDown',
}),
fieldsToFetch: {
color: {type: 'integer'},
@@ -1365,42 +1377,35 @@ var FieldMany2ManyTags = AbstractField.extend({
this._removeTag($(event.target).parent().data('id'));
},
/**
* Controls the changes made in the internal m2o field.
*
* @private
* @param {OdooEvent} event
* @param {OdooEvent} ev
*/
_onFieldChanged: function (event) {
if (event.target === this) {
_onFieldChanged: function (ev) {
if (ev.target !== this.many2one) {
return;
}
event.stopPropagation();
var new_value = event.data.changes[this.name];
if (new_value) {
this._addTag(new_value);
ev.stopPropagation();
var newValue = ev.data.changes[this.name];
if (newValue) {
this._addTag(newValue);
this.many2one.reinitialize(false);
}
},
/**
* @private
* @param {KeyboardEvent} event
* @param {KeyboardEvent} ev
*/
_onKeyDown: function (event) {
if ($(event.target).val() === "" && event.which === $.ui.keyCode.BACKSPACE) {
_onKeydown: function (ev) {
if (ev.which === $.ui.keyCode.BACKSPACE && this.$('input').val() === "") {
var $badges = this.$('.badge');
if ($badges.length) {
this._removeTag($badges.last().data('id'));
return;
}
}
},
/**
* @private
* @param {OdooEvent} event
*/
_onMoveNext: function (event) {
if (event.target === this) {
return;
}
// Intercept event triggered up by the many2one, to prevent triggering it twice
event.stopPropagation();
this._super.apply(this, arguments);
},
/**
* @private
@@ -16,12 +16,7 @@ var qweb = core.qweb;
var BasicRenderer = AbstractRenderer.extend({
custom_events: {
/**
* All basic renderers should be able to handle widget navigation
* through the TAB key.
*/
move_next: '_onMoveNext',
move_previous: '_onMovePrevious',
navigation_move: '_onNavigationMove',
},
/**
* Basic renderers implements the concept of "mode", they can either be in
@@ -600,24 +595,15 @@ var BasicRenderer = AbstractRenderer.extend({
//--------------------------------------------------------------------------
/**
* When someone presses TAB in a widget, it is nice to be able to go to the
* next widget, especially since the TAB key event is preventDefaulted
* When someone presses the TAB/UP/DOWN/... key in a widget, it is nice to
* be able to navigate in the view (default browser behaviors are disabled
* by Odoo).
*
* @abstract
* @private
* @param {OdooEvent} ev
*/
_onMoveNext: function (ev) {},
/**
* When someone presses SHIFT+TAB in a widget, it is nice to be able to go
* back to the previous widget, especially since the TAB key event is
* preventDefaulted
*
* @abstract
* @private
* @param {OdooEvent} ev
*/
_onMovePrevious: function (ev) {},
_onNavigationMove: function (ev) {},
});
return BasicRenderer;
@@ -740,20 +740,16 @@ var FormRenderer = BasicRenderer.extend({
* @private
* @param {OdooEvent} ev
*/
_onMoveNext: function (ev) {
_onNavigationMove: function (ev) {
ev.stopPropagation();
var index = this.allFieldWidgets[this.state.id].indexOf(ev.target);
this._activateNextFieldWidget(this.state, index);
},
/**
* @override
* @private
* @param {OdooEvent} event
*/
_onMovePrevious: function (ev) {
ev.stopPropagation();
var index = this.allFieldWidgets[this.state.id].indexOf(ev.target);
this._activatePreviousFieldWidget(this.state, index);
var index;
if (ev.data.direction === "next") {
index = this.allFieldWidgets[this.state.id].indexOf(ev.data.target);
this._activateNextFieldWidget(this.state, index);
} else if (ev.data.direction === "previous") {
index = this.allFieldWidgets[this.state.id].indexOf(ev.data.target);
this._activatePreviousFieldWidget(this.state, index);
}
},
});
@@ -18,11 +18,7 @@ var _t = core._t;
ListRenderer.include({
custom_events: _.extend({}, ListRenderer.prototype.custom_events, {
move_down: '_onMoveDown',
move_up: '_onMoveUp',
move_left: '_onMoveLeft',
move_right: '_onMoveRight',
move_next_line: '_onMoveNextLine',
navigation_move: '_onNavigationMove',
}),
events: _.extend({}, ListRenderer.prototype.events, {
'click tbody td.o_data_cell': '_onCellClick',
@@ -214,7 +210,20 @@ ListRenderer.include({
return n;
},
/**
* Move the cursor one the beginning of the next line, if possible.
* Move the cursor on the end of the previous line, if possible.
* If there is no previous line, then we create a new record.
*
* @private
*/
_moveToPreviousLine: function () {
if (this.currentRow > 0) {
this._selectCell(this.currentRow - 1, this.columns.length - 1);
} else {
this._unselectRow().then(this.trigger_up.bind(this, 'add_record'));
}
},
/**
* Move the cursor on the beginning of the next line, if possible.
* If there is no next line, then we create a new record.
*
* @private
@@ -223,9 +232,7 @@ ListRenderer.include({
if (this.currentRow < this.state.data.length - 1) {
this._selectCell(this.currentRow + 1, 0);
} else {
this._unselectRow().then(
this.trigger_up.bind(this, 'add_record')
);
this._unselectRow().then(this.trigger_up.bind(this, 'add_record'));
}
},
/**
@@ -495,78 +502,68 @@ ListRenderer.include({
this._unselectRow();
},
/**
* Move the cursor on the cell just down the current cell.
*/
_onMoveDown: function () {
if (this.currentRow < this.state.data.length - 1) {
this._selectCell(this.currentRow + 1, this.currentCol);
}
},
/**
* Move the cursor on the left, if possible
*/
_onMoveLeft: function () {
if (this.currentCol > 0) {
this._selectCell(this.currentRow, this.currentCol - 1);
}
},
/**
* Move the cursor on the next available cell, so either the next available
* cell on the right, or the first on the next line. This method is called
* when the user press the TAB key.
* Handles the keyboard navigation according to events triggered by field
* widgets.
* - up/down: move to the cell above/below if any, or the first activable
* one on the row above/below if any on the right of this cell
* above/below (if none on the right, wrap to the beginning of the
* line).
* - left/right: move to the first activable cell on the left/right if any
* (wrap to the end/beginning of the line if necessary).
* - previous: move to the first activable cell on the left if any, if not
* move to the rightmost activable cell on the row above.
* - next: move to the first activable cell on the right if any, if not move
* to the leftmost activable cell on the row below.
* - next_line: move to leftmost activable cell on the row below.
*
* Note: moving to a line below if on the last line or moving to a line
* above if on the first line automatically creates a new line.
*
* @param {OdooEvent} ev
*/
_onMoveNext: function (ev) {
// we need to stop the event, to prevent interference with some other
// component up there, such as a form renderer.
ev.stopPropagation();
if (this.currentCol + 1 < this.columns.length) {
this._selectCell(this.currentRow, this.currentCol + 1, false)
.fail(this._moveToNextLine.bind(this));
} else {
this._moveToNextLine();
}
},
/**
* @private
* @param {OdooEvent} event
*/
_onMoveNextLine: function (event) {
event.stopPropagation();
this._moveToNextLine();
},
/**
* Move the cursor on the previous available cell, so either the previous
* available cell on the left, or the last on the previous line. This method
* is called when the user press the TAB key while holding the shift key.
*
* @param {OdooEvent} ev
*/
_onMovePrevious: function (ev) {
// we need to stop the event, to prevent interference with some other
// component up there, such as a form renderer.
ev.stopPropagation();
if (this.currentCol > 0) {
this._selectCell(this.currentRow, this.currentCol - 1);
} else if (this.currentRow > 0) {
this._selectCell(this.currentRow - 1, this.columns.length - 1);
}
},
/**
* Move the cursor one cell right, if possible
*/
_onMoveRight: function () {
if (this.currentCol + 1 < this.columns.length) {
this._selectCell(this.currentRow, this.currentCol + 1);
}
},
/**
* Move the cursor one line up
*/
_onMoveUp: function () {
if (this.currentRow > 0) {
this._selectCell(this.currentRow - 1, this.currentCol);
_onNavigationMove: function (ev) {
ev.stopPropagation(); // stop the event, the action is done by this renderer
switch (ev.data.direction) {
case 'up':
if (this.currentRow > 0) {
this._selectCell(this.currentRow - 1, this.currentCol);
}
break;
case 'right':
if (this.currentCol + 1 < this.columns.length) {
this._selectCell(this.currentRow, this.currentCol + 1);
}
break;
case 'down':
if (this.currentRow < this.state.data.length - 1) {
this._selectCell(this.currentRow + 1, this.currentCol);
}
break;
case 'left':
if (this.currentCol > 0) {
this._selectCell(this.currentRow, this.currentCol - 1);
}
break;
case 'previous':
if (this.currentCol > 0) {
this._selectCell(this.currentRow, this.currentCol - 1, false)
.fail(this._moveToPreviousLine.bind(this));
} else {
this._moveToPreviousLine();
}
break;
case 'next':
if (this.currentCol + 1 < this.columns.length) {
this._selectCell(this.currentRow, this.currentCol + 1, false)
.fail(this._moveToNextLine.bind(this));
} else {
this._moveToNextLine();
}
break;
case 'next_line':
this._moveToNextLine();
break;
}
},
/**
@@ -1970,6 +1970,72 @@ QUnit.module('Views', {
"next field (checkbox) should now be focused");
list.destroy();
});
QUnit.test('navigation: moving left/right with keydown', function (assert) {
assert.expect(8);
this.data.foo.fields.foo.type = 'text';
var list = createView({
View: ListView,
model: 'foo',
data: this.data,
arch:
'<tree editable="bottom">' +
'<field name="m2m" widget="many2many_tags"/>' +
'<field name="foo"/>' +
'<field name="bar"/>' +
'<field name="m2o"/>' +
'<field name="qux"/>' +
'</tree>',
});
list.$('td:contains(13)').click();
var $m2m = list.$('[name="m2m"] input');
var $foo = list.$('textarea[name="foo"]');
var $bar = list.$('[name="bar"] input');
var $m2o = list.$('[name="m2o"] input');
var $qux = list.$('input[name="qux"]');
assert.strictEqual(document.activeElement, $qux[0],
"'qux' input should be focused");
$qux[0].selectionEnd = 0; // Simulate browser keyboard left behavior (unselect)
$qux.trigger({type: 'keydown', which: $.ui.keyCode.LEFT});
assert.strictEqual(document.activeElement, $m2o[0],
"'m2o' input should be focused");
// forget unselecting and try leaving
$m2o.trigger({type: 'keydown', which: $.ui.keyCode.LEFT});
assert.strictEqual(document.activeElement, $m2o[0],
"'m2o' input should still be focused");
$m2o[0].selectionEnd = 0; // Simulate browser keyboard left behavior (unselect)
$m2o.trigger({type: 'keydown', which: $.ui.keyCode.LEFT});
assert.strictEqual(document.activeElement, $bar[0],
"'bar' input should be focused");
// no unselect here as it is a checkbox
$bar.trigger({type: 'keydown', which: $.ui.keyCode.LEFT});
assert.strictEqual(document.activeElement, $foo[0],
"'foo' input should be focused");
// forget unselecting and try leaving
$foo.trigger({type: 'keydown', which: $.ui.keyCode.LEFT});
assert.strictEqual(document.activeElement, $foo[0],
"'foo' input should still be focused");
$foo[0].selectionEnd = 0; // Simulate browser keyboard left behavior (unselect)
$foo.trigger({type: 'keydown', which: $.ui.keyCode.LEFT});
assert.strictEqual(document.activeElement, $m2m[0],
"'m2m' input should be focused");
$m2m[0].selectionStart = $m2m[0].value.length; // Simulate browser keyboard right behavior (unselect)
$m2m.trigger({type: 'keydown', which: $.ui.keyCode.RIGHT});
assert.strictEqual(document.activeElement, $foo[0],
"'foo' input should be focused");
list.destroy();
});
});
});