summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/views
diff options
context:
space:
mode:
authorVincent Schippefilt <vsc@odoo.com>2018-01-29 10:39:28 +0100
committerVincent Schippefilt <vsc@odoo.com>2018-04-10 09:39:08 +0200
commit02a063fd73d662bfe175db03aad96685543c5ca9 (patch)
tree50c820ba6abd8861f8d9c72d049b0833add1a2e6 /addons/web/static/src/js/views
parent4ba6d2a405c7362986e8a09702e6228887632ea1 (diff)
[IMP] web: keyboard navigation in form view
The goal of this commit is to allow fast entry of the same model using the keyboard for navigating through the form once, by blocking the user from advancing in the form if there is a required field or once they went through the form once to get them to a primary action (button) This commit includes the following changes 1) Enable moving forward from field to field using the TAB key 2) Entering the one to many and many to many using the TAB key a. When entering it, set the focus on the "add new line" link or button b. When adding a new line, set the focus on the field visible editable field of the new line c. Discard adding a new line with ESC key d. If the user leaves the first field empty and uses TAB, we will cancel the adding of a new line and move to the next field of the form 3) When a field is required and not filled in, do not allow the user to move out of the field using the TAB key (the user is still allowed to use the mouse though), mark the field as invalid instead 4) After going though the form once, using the TAB key on the last field will move the focus to the first primary button of the page 5) When the focus is on a primary button (EDIT/SAVE), the user cannot move the focus forward using the TAB key. Hitting TAB again will display a tooltip telling to hit ENTER to activate the button. The mouse is still available to move the focus. 6) When the user saves, the focus is placed on the first primary button of the form renderer (like VALIDATE for a new invoice) 7) On dialogs, primary buttons should stop the users from moving out of them, and showing a popup if the user tries 8) When closing a dialog, the focus will be moved back to the widget that opened it. This commit does not include the following features 1) Navigation with the keyboard on a selection one to man 2) Navigate between tabs in a form using the keyboard 3) Cancelling the adding of a new line in a many to many using the ESC key do not set the focus correctly 4) Enhancing the focusses fields (like blue underline)
Diffstat (limited to 'addons/web/static/src/js/views')
-rw-r--r--addons/web/static/src/js/views/basic/basic_renderer.js9
-rw-r--r--addons/web/static/src/js/views/form/form_controller.js75
-rw-r--r--addons/web/static/src/js/views/form/form_renderer.js67
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js47
-rw-r--r--addons/web/static/src/js/views/view_dialogs.js16
5 files changed, 195 insertions, 19 deletions
diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js
index e8367e79c26..4fb7983d00f 100644
--- a/addons/web/static/src/js/views/basic/basic_renderer.js
+++ b/addons/web/static/src/js/views/basic/basic_renderer.js
@@ -172,7 +172,7 @@ var BasicRenderer = AbstractRenderer.extend({
* @param {Object} [options]
* @param {integer} [options.inc=1] - the increment to use when searching for the
* "next" possible one
- * @param {boolean} [options.wrap=true] if true, when we arrive at the end of the
+ * @param {boolean} [options.wrap=false] if true, when we arrive at the end of the
* list of widget, we wrap around and try to activate widgets starting at
* the beginning. Otherwise, we just stop trying and return -1
* @returns {integer} the index of the widget that was activated or -1 if
@@ -180,7 +180,8 @@ var BasicRenderer = AbstractRenderer.extend({
*/
_activateFieldWidget: function (record, currentIndex, options) {
options = options || {};
- _.defaults(options, {inc: 1, wrap: true});
+ _.defaults(options, {inc: 1, wrap: false});
+ currentIndex = Math.max(0,currentIndex); // do not allow negative currentIndex
var recordWidgets = this.allFieldWidgets[record.id] || [];
for (var i = 0 ; i < recordWidgets.length ; i++) {
@@ -271,6 +272,10 @@ var BasicRenderer = AbstractRenderer.extend({
element.$el.toggleClass("o_readonly_modifier", !!modifiers.readonly);
element.$el.toggleClass("o_required_modifier", !!modifiers.required);
+ if (element.widget) {
+ element.widget.updateModifiersValue(modifiers);
+ }
+
// Call associated callback
if (element.callback) {
element.callback(element, modifiers, record);
diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js
index 00a4631d3f7..084c9ae4579 100644
--- a/addons/web/static/src/js/views/form/form_controller.js
+++ b/addons/web/static/src/js/views/form/form_controller.js
@@ -19,6 +19,8 @@ var FormController = BasicController.extend({
open_one2many_record: '_onOpenOne2ManyRecord',
open_record: '_onOpenRecord',
toggle_column_order: '_onToggleColumnOrder',
+ focus_control_button: '_onFocusControlButton',
+ form_dialog_discarded: '_onFormDialogDiscarded',
}),
/**
* @override
@@ -41,12 +43,19 @@ var FormController = BasicController.extend({
// Public
//--------------------------------------------------------------------------
+
/**
* Calls autofocus on the renderer
*/
autofocus: function () {
if (!this.disableAutofocus) {
- this.renderer.autofocus();
+ var isControlActivted = this.renderer.autofocus();
+ if (!isControlActivted) {
+ // this can happen in read mode if there are no button with class oe_highlight
+ if (this.$buttons && this.mode === 'readonly') {
+ return this.$buttons.find('.o_form_button_edit').focus();
+ }
+ }
}
},
/**
@@ -119,13 +128,21 @@ var FormController = BasicController.extend({
this.$buttons = $('<div/>');
if (mustRenderFooterButtons) {
this.$buttons.append($footer);
+
} else {
this.$buttons.append(qweb.render("FormView.buttons", {widget: this}));
this.$buttons.on('click', '.o_form_button_edit', this._onEdit.bind(this));
this.$buttons.on('click', '.o_form_button_create', this._onCreate.bind(this));
this.$buttons.on('click', '.o_form_button_save', this._onSave.bind(this));
this.$buttons.on('click', '.o_form_button_cancel', this._onDiscard.bind(this));
-
+ this._assignSaveCancelKeyboardBehavior(this.$buttons.find('.o_form_buttons_edit'));
+ this.$buttons.find('.o_form_buttons_edit').tooltip({
+ delay: {show: 200, hide:0},
+ title: function(){
+ return qweb.render('SaveCancelButton.tooltip');
+ },
+ trigger: 'manual',
+ });
this._updateButtons();
}
this.$buttons.appendTo($node);
@@ -218,7 +235,37 @@ var FormController = BasicController.extend({
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
-
+ /**
+ * Assign on the buttons save and discard additionnal behavior to facilitate
+ * the work of the users doing input only using the keyboard
+ *
+ * @param {jQueryElement} $saveCancelButtonContainer The div containing the
+ * save and cancel buttons
+ * @private
+ */
+ _assignSaveCancelKeyboardBehavior: function ($saveCancelButtonContainer) {
+ var self = this;
+ $saveCancelButtonContainer.children().on('keydown', function(e) {
+ switch(e.which) {
+ case $.ui.keyCode.ENTER:
+ e.preventDefault();
+ self.saveRecord.apply(self);
+ break;
+ case $.ui.keyCode.ESCAPE:
+ e.preventDefault();
+ self._discardChanges.apply(self);
+ break;
+ case $.ui.keyCode.TAB:
+ if (!e.shiftKey &&
+ (e.target.classList.contains("btn-primary") ||
+ e.target.classList.contains("oe_highlight"))) {
+ $saveCancelButtonContainer.tooltip('show');
+ e.preventDefault();
+ }
+ break;
+ }
+ });
+ },
/**
* When a save operation has been confirmed from the model, this method is
* called.
@@ -489,6 +536,28 @@ var FormController = BasicController.extend({
this.model.freezeOrder(event.data.id);
},
/**
+ * Set the focus on the first primary button of the controller (likely Edit)
+ *
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onFocusControlButton:function(e) {
+ if (this.$buttons) {
+ e.stopPropagation();
+ this.$buttons.find('.btn-primary:visible:first()').focus();
+ }
+ },
+ /**
+ * Reset the focus on the control that openned a Dialog after it was closed
+ *
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onFormDialogDiscarded: function(e) {
+ e.stopPropagation();
+ this.renderer.focusLastActivatedWidget();
+ },
+ /**
* Opens a one2many record (potentially new) in a dialog. This handler is
* o2m specific as in this case, the changes done on the related record
* shouldn't be saved in DB when the user clicks on 'Save' in the dialog,
diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js
index d221f93a4e8..94e870b78d6 100644
--- a/addons/web/static/src/js/views/form/form_renderer.js
+++ b/addons/web/static/src/js/views/form/form_renderer.js
@@ -15,6 +15,10 @@ var FormRenderer = BasicRenderer.extend({
'click .o_notification_box .oe_field_translate': '_onTranslate',
'click .oe_title, .o_inner_group': '_onClick',
}),
+ custom_events: _.extend({}, BasicRenderer.prototype.custom_events, {
+ 'navigation_move':'_onNavigationMove',
+ 'activate_next_widget' : '_onActivateNextWidget',
+ }),
// default col attributes for the rendering of groups
INNER_GROUP_COL: 2,
OUTER_GROUP_COL: 2,
@@ -25,6 +29,7 @@ var FormRenderer = BasicRenderer.extend({
init: function () {
this._super.apply(this, arguments);
this.idsForLabels = {};
+ this.lastActivatedFieldIndex = -1;
},
/**
* @override
@@ -43,10 +48,19 @@ var FormRenderer = BasicRenderer.extend({
/**
* Focuses the field having attribute 'default_focus' set, if any, or the
* first focusable field otherwise.
+ * In read mode, delegate which button to give the focus to, to the form_renderer
+ *
+ * @returns {int || undefined} the index of the widget activated else
+ * undefined
*/
autofocus: function () {
if (this.mode === 'readonly') {
- return;
+ var firstPrimaryFormButton = this.$el.find('button.oe_highlight:enabled:visible:first()');
+ if (firstPrimaryFormButton.length > 0) {
+ return firstPrimaryFormButton.focus();
+ } else {
+ return;
+ }
}
var focusWidget = this.defaultFocusField;
if (!focusWidget || !focusWidget.isFocusable()) {
@@ -60,7 +74,7 @@ var FormRenderer = BasicRenderer.extend({
}
}
if (focusWidget) {
- focusWidget.activate({noselect: true});
+ return focusWidget.activate({noselect: true});
}
},
/**
@@ -145,6 +159,15 @@ var FormRenderer = BasicRenderer.extend({
.removeAttr('disabled');
},
/**
+ * Put the focus on the last activated widget.
+ * This function is used when closing a dialog to give the focus back to the
+ * form that has opened it and ensures that the focus is in the correct
+ * field.
+ */
+ focusLastActivatedWidget: function () {
+ this._activateNextFieldWidget(this.state, this.lastActivatedFieldIndex - 1);
+ },
+ /**
* returns the active tab pages for each notebook
*
* @todo currently, this method is unused...
@@ -211,7 +234,27 @@ var FormRenderer = BasicRenderer.extend({
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
-
+ /**
+ * @override
+ */
+ _activateNextFieldWidget: function (record, currentIndex) {
+ //if we are the last widget, we should give the focus to the first Primary Button in the form
+ //else do the default behavior
+ if ( (currentIndex + 1) >= (this.allFieldWidgets[record.id] || []).length) {
+ this.trigger_up('focus_control_button');
+ this.lastActivatedFieldIndex = -1;
+ } else {
+ var activatedIndex = this._super.apply(this, arguments);
+ if (activatedIndex === -1 ) { // no widget have been activated, we should go to the edit/save buttons
+ this.trigger_up('focus_control_button');
+ this.lastActivatedFieldIndex = -1;
+ }
+ else {
+ this.lastActivatedFieldIndex = activatedIndex;
+ }
+ }
+ return this.lastActivatedFieldIndex;
+ },
/**
* Add a tooltip on a button
*
@@ -819,11 +862,15 @@ var FormRenderer = BasicRenderer.extend({
this.defs = defs;
var $form = this._renderNode(this.arch).addClass(this.className);
delete this.defs;
-
+
return $.when.apply($, defs).then(function () {
self._updateView($form.contents());
}, function () {
$form.remove();
+ }).then(function(){
+ if (self.lastActivatedFieldIndex >= 0) {
+ self._activateNextFieldWidget(self.state, self.lastActivatedFieldIndex);
+ }
});
},
/**
@@ -882,7 +929,11 @@ var FormRenderer = BasicRenderer.extend({
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
-
+ _onActivateNextWidget: function (e) {
+ e.stopPropagation();
+ var index = this.allFieldWidgets[this.state.id].indexOf(e.data.target);
+ this._activateNextFieldWidget(this.state, index);
+ },
/**
* Makes the Edit button bounce in readonly
*
@@ -899,11 +950,13 @@ var FormRenderer = BasicRenderer.extend({
* @param {OdooEvent} ev
*/
_onNavigationMove: function (ev) {
- ev.stopPropagation();
+ if (ev.data.direction !== "cancel") {
+ ev.stopPropagation();
+ }
var index;
if (ev.data.direction === "next") {
- index = this.allFieldWidgets[this.state.id].indexOf(ev.data.target);
+ index = this.allFieldWidgets[this.state.id].indexOf(ev.data.target || ev.target);
this._activateNextFieldWidget(this.state, index);
} else if (ev.data.direction === "previous") {
index = this.allFieldWidgets[this.state.id].indexOf(ev.data.target);
diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js
index 11735fa94f8..f3dc79cd4bf 100644
--- a/addons/web/static/src/js/views/list/list_editable_renderer.js
+++ b/addons/web/static/src/js/views/list/list_editable_renderer.js
@@ -24,6 +24,7 @@ ListRenderer.include({
}),
events: _.extend({}, ListRenderer.prototype.events, {
'click .o_field_x2many_list_row_add a': '_onAddRecord',
+ 'keydown .o_field_x2many_list_row_add a': '_onKeyDownAddRecord',
'click tbody td.o_data_cell': '_onCellClick',
'click tbody tr:not(.o_data_row)': '_onEmptyRowClick',
'click tfoot': '_onFooterClick',
@@ -738,6 +739,34 @@ ListRenderer.include({
_onFooterClick: function () {
this.unselectRow();
},
+ _onKeyDownAddRecord: function(e) {
+ switch(e.keyCode) {
+ case $.ui.keyCode.ENTER:
+ e.stopPropagation();
+ e.preventDefault();
+ this._onAddRecord(e);
+ break;
+ }
+ },
+ /**
+ * It will returns the first visible widget that is editable
+ *
+ * @private
+ * @returns {Class} Widget returns first widget
+ */
+ _getFirstWidget: function () {
+ var record = this.state.data[this.currentRow];
+ var recordWidgets = this.allFieldWidgets[record.id];
+ var firstWidget = _.find(recordWidgets, function (widget) {
+ var isFirst = widget.$el.is(':visible') &&
+ (widget.$el.has('input').length > 0 ||
+ widget.tagName== 'input') &&
+ !widget.$el.hasClass('o_readonly_modifier');
+ return isFirst;
+ });
+ return firstWidget;
+ },
+
/**
* Handles the keyboard navigation according to events triggered by field
* widgets.
@@ -791,12 +820,20 @@ ListRenderer.include({
}
break;
case 'next':
- if (this.currentFieldIndex + 1 < this.columns.length) {
- this._selectCell(this.currentRow, this.currentFieldIndex + 1, {wrap: false})
- .fail(this._moveToNextLine.bind(this));
+ // When navigating with the keyboard, we want to get out of the list editable if the
+ // first field is left empty.
+ var column = this.columns[this.currentFieldIndex];
+ var firstWidget = this._getFirstWidget();
+ if (column.attrs.name === firstWidget.name && !firstWidget.$input.val()) {
+ this.trigger_up('activate_next_widget');
} else {
- this._moveToNextLine();
- }
+ if (this.currentFieldIndex + 1 < this.columns.length) {
+ this._selectCell(this.currentRow, this.currentFieldIndex + 1, {wrap: false})
+ .fail(this._moveToNextLine.bind(this));
+ } else {
+ this._moveToNextLine();
+ }
+ }
break;
case 'next_line':
this._moveToNextLine();
diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js
index d4ae709008c..55f9a2e85d7 100644
--- a/addons/web/static/src/js/views/view_dialogs.js
+++ b/addons/web/static/src/js/views/view_dialogs.js
@@ -209,7 +209,13 @@ var FormViewDialog = ViewDialog.extend({
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
-
+ /**
+ * @override
+ */
+ _focusOnClose: function() {
+ this.trigger_up('form_dialog_discarded');
+ return true;
+ },
_save: function () {
var self = this;
return this.form_view.saveRecord(this.form_view.handle, {
@@ -418,7 +424,13 @@ var SelectCreateDialog = ViewDialog.extend({
dialog.on('closed', this, this.close);
return dialog;
},
-
+ /**
+ * @override
+ */
+ _focusOnClose: function() {
+ this.trigger_up('form_dialog_discarded');
+ return true;
+ },
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------