[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)
This commit is contained in:
Vincent Schippefilt
2018-04-10 09:39:08 +02:00
parent 4ba6d2a405
commit 02a063fd73
20 changed files with 991 additions and 42 deletions
@@ -417,6 +417,7 @@ var ActionManager = Widget.extend({
callbacks: [{widget: dialog}, {widget: controller.widget}],
});
widget.renderButtons(dialog.$footer);
dialog.rebindButtonBehavior();
return action;
});
+93 -9
View File
@@ -18,7 +18,12 @@ var _t = core._t;
**/
var Dialog = Widget.extend({
xmlDependencies: ['/web/static/src/xml/dialog.xml'],
custom_events: _.extend({}, Widget.prototype.custom_events, {
focus_control_button: '_onFocusControlButton',
}),
events: _.extend({} , Widget.prototype.events, {
'keydown .modal-footer button':'_onFooterButtonKeyDown',
}),
/**
* @param {Widget} parent
* @param {Object} [options]
@@ -102,6 +107,9 @@ var Dialog = Widget.extend({
}
this.$el.addClass('modal-body ' + this.dialogClass);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @param {Object[]} buttons - @see init
*/
@@ -148,7 +156,14 @@ var Dialog = Widget.extend({
return (handler)? this._opened.then(handler) : this._opened;
},
open: function () {
/**
* Show a dialog
*
* @param {Object} options
* @param {boolean} options.shouldFocusButtons if true, put the focus on
* the first button primary when the dialog opens
*/
open: function (options) {
$('.tooltip').remove(); // remove open tooltip if any to prevent them staying when modal is opened
var self = this;
@@ -157,6 +172,9 @@ var Dialog = Widget.extend({
self.$modal.modal('show');
self._opened.resolve();
});
if (options && options.shouldFocusButtons) {
self._onFocusControlButton();
}
return self;
},
@@ -176,6 +194,8 @@ var Dialog = Widget.extend({
if (this.isDestroyed()) {
return;
}
var isFocusSet = this._focusOnClose();
this._super();
$('.tooltip').remove(); //remove open tooltip if any to prevent them staying when modal has disappeared
@@ -184,11 +204,75 @@ var Dialog = Widget.extend({
this.$modal.remove();
}
var modals = $('body > .modal').filter(':visible');
if (modals.length) {
modals.last().focus();
// Keep class modal-open (deleted by bootstrap hide fnct) on body to allow scrolling inside the modal
$('body').addClass('modal-open');
if (!isFocusSet) {
var modals = $('body > .modal').filter(':visible');
if (modals.length) {
modals.last().focus();
// Keep class modal-open (deleted by bootstrap hide fnct) on body to allow scrolling inside the modal
$('body').addClass('modal-open');
}
}
},
/**
* adds the keydown behavior to the dialogs after external files modifies
* its DOM.
*/
rebindButtonBehavior: function () {
this.$footer.on('keydown', this._onFooterButtonKeyDown);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Manages the focus when the dialog closes. The default behavior is to set the focus on the top-most opened popup.
* The goal of this function is to be overridden by all children of the dialog class.
*
* @returns: boolean should return true if the focus has already been set else false.
*/
_focusOnClose: function() {
return false;
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Moves the focus to the first button primary in the footer of the dialog
*
* @private
* @param {odooEvent} e
*/
_onFocusControlButton: function (e) {
if (this.$footer) {
if (e) {
e.stopPropagation();
}
this.$footer.find('.btn-primary:visible:first()').focus();
}
},
/**
* Manages the TAB key on the buttons. If you the focus is on a primary
* button and the users tries to tab to go to the next button, display
* a tooltip
*
* @param {jQueryEvent} e
* @private
*/
_onFooterButtonKeyDown: function (e) {
switch(e.which) {
case $.ui.keyCode.TAB:
if (!e.shiftKey && (e.target.classList.contains("btn-primary") || e.target.classList.contains("oe_highlight"))) {
e.preventDefault();
var $primaryButton = $(e.target);
$primaryButton.tooltip({
delay: {show: 200, hide:0},
title: function(){
return QWeb.render('FormButton.tooltip',{title:$primaryButton.text().toUpperCase()});
},
trigger: 'manual',
});
$primaryButton.tooltip('show');
}
break;
}
}
});
@@ -207,7 +291,7 @@ Dialog.alert = function (owner, message, options) {
text: message,
}),
title: _t("Alert"),
}, options)).open();
}, options)).open({shouldFocusButtons:true});
};
// static method to open simple confirm dialog
@@ -232,7 +316,7 @@ Dialog.confirm = function (owner, message, options) {
text: message,
}),
title: _t("Confirmation"),
}, options)).open();
}, options)).open({shouldFocusButtons:true});
};
/**
+1
View File
@@ -331,6 +331,7 @@ var EventDispatcherMixin = _.extend({}, ParentedMixin, {
},
trigger_up: function (name, info) {
var event = new OdooEvent(this, name, info);
//console.info('event: ', name, info);
this._trigger_up(event);
},
_trigger_up: function (event) {
+10
View File
@@ -284,6 +284,16 @@ var Widget = core.Class.extend(mixins.PropertiesMixin, ServicesMixin, {
return this;
},
/**
* Update the modifiers with the newest value.
* Now this.attrs.modifiersValue can be used consistantly even with
* conditional modifiers
*
* @param {Object} modifiers the updated modifiers
*/
updateModifiersValue: function(modifiers) {
},
//--------------------------------------------------------------------------
// Private
@@ -299,6 +299,34 @@ var AbstractField = Widget.extend({
return this._render() || $.when();
},
/**
* Remove the invalid class on a field
*/
removeInvalidClass: function () {
this.$el.removeClass('o_field_invalid');
},
/**
* add the invalid class on a field
*/
setInvalidClass: function () {
this.$el.addClass('o_field_invalid');
},
/**
* Update the modifiers with the newest value.
* Now this.attrs.modifiersValue can be used consistantly even with
* conditional modifiers inside field widgets, and without needing new
* events or synchronization between the widgets, renderer and controller
*
* @param {Object || null} modifiers the updated modifiers
* @override
*/
updateModifiersValue: function(modifiers) {
this.attrs.modifiersValue = modifiers || {};
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
@@ -312,13 +312,21 @@ var InputField = DebouncedField.extend({
this._super.apply(this, arguments);
// the following code only makes sense in edit mode, with an input
if (this.mode === 'edit') {
if (this.mode === 'edit' && ev.data.direction !== 'cancel') {
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.data.direction === "right" && (selecting || input.selectionStart !== input.value.length))) {
ev.stopPropagation();
}
if (ev.data.direction ==='next' && this.attrs.modifiersValue.required) {
if (!this.$input.val()){
this.setInvalidClass();
ev.stopPropagation();
} else {
this.removeInvalidClass();
}
}
}
},
});
@@ -682,6 +682,8 @@ var FieldX2Many = AbstractField.extend({
resequence: '_onResequence',
save_line: '_onSaveLine',
toggle_column_order: '_onToggleColumnOrder',
activate_next_widget: '_onActiveNextWidget',
navigation_move: '_onNavigationMove',
}),
// We need to trigger the reset on every changes to be aware of the parent changes
@@ -797,6 +799,30 @@ var FieldX2Many = AbstractField.extend({
return this._super.apply(this, arguments);
},
/**
* @override
* @returns {jQuery}
*/
getFocusableElement: function () {
return (this.mode === 'edit' && this.$input) || this.$el;
},
/**
* @override
* @param {Object} [options]
*/
activate: function (options) {
if (!this.activeActions.create || this.isReadonly || !this.$el.is(":visible")) {
return false;
}
if (this.view.type === 'kanban') {
this.$buttons.find(".o-kanban-button-new").focus();
}
if (this.view.arch.tag === 'tree') {
this.renderer.$('.o_field_x2many_list_row_add a').focus();
}
return true;
},
//--------------------------------------------------------------------------
// Private
@@ -1159,6 +1185,16 @@ var FieldX2Many = AbstractField.extend({
_onToggleColumnOrder: function (ev) {
ev.data.field = this.name;
},
/*
* Move to next widget.
*
* @private
*/
_onActiveNextWidget: function (e) {
e.stopPropagation();
this.renderer.unselectRow();
this.trigger_up('navigation_move',{direction:'next'});
},
});
var FieldOne2Many = FieldX2Many.extend({
@@ -1257,7 +1293,6 @@ var FieldOne2Many = FieldX2Many.extend({
var self = this;
// we don't want interference with the components upstream.
ev.stopPropagation();
if (this.editable) {
if (!this.activeActions.create) {
if (ev.data.onFail) {
@@ -1329,7 +1364,6 @@ var FieldMany2Many = FieldX2Many.extend({
_onAddRecord: function (ev) {
var self = this;
ev.stopPropagation();
var domain = this.record.getDomain({fieldName: this.name});
new dialogs.SelectCreateDialog(this, {
@@ -108,7 +108,7 @@ var CrashManager = core.Class.extend({
title: _.str.capitalize(error.type || error.message) || _t("Odoo Warning"),
subtitle: error.data.title,
$content: $(QWeb.render('CrashManager.warning', {error: error}))
}).open();
}).open({shouldFocusButtons:true});
},
show_error: function(error) {
if (!this.active) {
@@ -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);
@@ -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.
@@ -488,6 +535,28 @@ var FormController = BasicController.extend({
event.stopPropagation();
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
@@ -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});
}
},
/**
@@ -144,6 +158,15 @@ var FormRenderer = BasicRenderer.extend({
this.$('.o_statusbar_buttons button, .oe_button_box button')
.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
*
@@ -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);
@@ -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();
+14 -2
View File
@@ -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
//--------------------------------------------------------------------------
+17 -1
View File
@@ -209,7 +209,7 @@
<t t-name="WebClient.DebugManager">
<li class="o_debug_manager">
<a href="#" title="Open Developer Tools" class="dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
<a href="#" title="Open Developer Tools" class="dropdown-toggle" data-toggle="dropdown" aria-expanded="false" tabindex="-1">
<span class="fa fa-bug"/>
</a>
<ul class="dropdown-menu o_debug_dropdown" role="menu"/>
@@ -411,6 +411,22 @@
</button>
</div>
</t>
<t t-name="SaveCancelButton.tooltip">
<div class="oe_tooltip_string">
<div class="tooltip-inner">
Hit ENTER to SAVE<br/>
Hit ESCAPE to DISCARD
</div>
</div>
</t>
<t t-name="CreateButton.tooltip">
<div class="oe_tooltip_string">
<div class="tooltip-inner">
Hit ENTER to CREATE<br/>
Hit DOWN to navigate to the list bellow
</div>
</div>
</t>
<form t-name="FormView.set_default">
<t t-set="args" t-value="widget.args"/>
<table style="width: 100%">
+1 -1
View File
@@ -7,7 +7,7 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
<button type="button" class="close" data-dismiss="modal" aria-hidden="true" tabindex="-1">×</button>
<h4 class="modal-title"><t t-raw="title"/><span class="o_subtitle text-muted small"><t t-esc="subtitle"/></span></h4>
</div>
<div class="modal-body"/>
@@ -11251,6 +11251,219 @@ QUnit.module('relational_fields', {
form.destroy();
});
QUnit.module('TabNavigation');
QUnit.test('when Navigating to a many to one with tabs, it receives the focus on the add new line link', function (assert) {
assert.expect(3);
var form = createView({
View: FormView,
model: 'partner',
viewOptions: {
mode: 'edit',
},
data: this.data,
arch:'<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field name="qux"/>' +
'</group>' +
'<notebook>' +
'<page string="Partner page">' +
'<field name="turtles">' +
'<tree editable="bottom">' +
'<field name="turtle_foo"/>' +
'</tree>' +
'</field>' +
'</page>' +
'</notebook>' +
'</sheet>' +
'</form>',
res_id: 1,
});
assert.strictEqual(form.$el.find('input[name="qux"]')[0],
document.activeElement,
"initially, the focus should be on the 'qux' field because it is the first input");
form.$el.find('input[name="qux"]').trigger($.Event('keydown', {
which: $.ui.keyCode.TAB,
keyCode: $.ui.keyCode.TAB,
}));
assert.strictEqual(assert.strictEqual(form.$el.find('.o_field_x2many_list_row_add>a')[0],
document.activeElement,
"after tab, the focus should be on the many2one on the add new line"));
form.destroy();
});
QUnit.test('when Navigating to a many to one with tabs, it places the focus on the first visible field', function (assert) {
assert.expect(3);
var form = createView({
View: FormView,
model: 'partner',
viewOptions: {
mode: 'edit',
},
data: this.data,
arch:'<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field name="qux"/>' +
'</group>' +
'<notebook>' +
'<page string="Partner page">' +
'<field name="turtles">' +
'<tree editable="bottom">' +
'<field name="turtle_bar" invisible="1"/>'+
'<field name="turtle_foo"/>' +
'</tree>' +
'</field>' +
'</page>' +
'</notebook>' +
'</sheet>' +
'</form>',
res_id: 1,
});
assert.strictEqual(form.$el.find('input[name="qux"]')[0],
document.activeElement,
"initially, the focus should be on the 'qux' field because it is the first input");
form.$el.find('input[name="qux"]').trigger($.Event('keydown', {
which: $.ui.keyCode.TAB,
keyCode: $.ui.keyCode.TAB,
}));
document.activeElement.click();
assert.strictEqual(assert.strictEqual(form.$el.find('input[name="turtle_foo"]')[0],
document.activeElement,
"after tab, the focus should be on the many2one"));
form.destroy();
});
QUnit.test('when Navigating to a many to one with tabs, not filling in the first field and hitting tab, we should not add a first line but navigate to the next control', function (assert) {
assert.expect(3);
this.data.partner.records[0].turtles = [];
var form = createView({
View: FormView,
model: 'partner',
viewOptions: {
mode: 'edit',
},
data: this.data,
arch:'<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field name="qux"/>' +
'</group>' +
'<notebook>' +
'<page string="Partner page">' +
'<field name="turtles">' +
'<tree editable="bottom">' +
'<field name="turtle_foo"/>' +
'<field name="turtle_description"/>' +
'</tree>' +
'</field>' +
'</page>' +
'</notebook>' +
'<group>' +
'<field name="foo"/>' +
'</group>' +
'</sheet>' +
'</form>',
res_id: 1,
});
assert.strictEqual(form.$el.find('input[name="qux"]')[0],
document.activeElement,
"initially, the focus should be on the 'qux' field because it is the first input");
form.$el.find('input[name="qux"]').trigger($.Event('keydown', {
which: $.ui.keyCode.TAB,
keyCode: $.ui.keyCode.TAB,
}));
$(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
assert.strictEqual(assert.strictEqual(form.$el.find('input[name="foo"]')[0],
document.activeElement,
"after tab, the focus should be on the many2one"));
form.destroy();
});
QUnit.test('when Navigating to a many to one with tabs, editing in a popup, the popup should receive the focus then give it back', function (assert) {
assert.expect(4);
this.data.partner.records[0].turtles = [];
var form = createView({
View: FormView,
model: 'partner',
viewOptions: {
mode: 'edit',
},
data: this.data,
arch:'<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field name="qux"/>' +
'</group>' +
'<notebook>' +
'<page string="Partner page">' +
'<field name="turtles">' +
'<tree>' +
'<field name="turtle_foo"/>' +
'<field name="turtle_description"/>' +
'</tree>' +
'</field>' +
'</page>' +
'</notebook>' +
'<group>' +
'<field name="foo"/>' +
'</group>' +
'</sheet>' +
'</form>',
res_id: 1,
archs: {
'turtle,false,form': '<form><group><field name="turtle_foo"/><field name="turtle_int"/></group></form>',
},
});
assert.strictEqual(form.$el.find('input[name="qux"]')[0],
document.activeElement,
"initially, the focus should be on the 'qux' field because it is the first input");
form.$el.find('input[name="qux"]').trigger($.Event('keydown', {
which: $.ui.keyCode.TAB,
keyCode: $.ui.keyCode.TAB,
}));
assert.strictEqual(form.$el.find('.o_field_x2many_list_row_add a')[0],
document.activeElement,
"after tab, the focus should be on the add new line link");
form.$el.find('.o_field_x2many_list_row_add a').trigger($.Event('keydown', {
which: $.ui.keyCode.ENTER,
keyCode: $.ui.keyCode.ENTER,
}));
assert.strictEqual($.find('input[name="turtle_foo"]')[0],
document.activeElement,
"after enter, the focus should be in the popup, in the first input field");
$('input[name="turtle_foo"]').trigger($.Event('keydown', {
which: $.ui.keyCode.ESCAPE,
keyCode: $.ui.keyCode.ESCAPE,
}));
assert.strictEqual(form.$el.find('.o_field_x2many_list_row_add a')[0],
document.activeElement,
"after escape, the focus should be back on the add new line link");
form.destroy();
});
});
});
});
+366
View File
@@ -70,6 +70,12 @@ QUnit.module('Views', {
id: 4,
display_name: "aaa",
state: "ef",
}, {
id: 5,
display_name: "aaa",
foo:'',
bar:false,
state: "ef",
}],
onchanges: {},
},
@@ -6717,6 +6723,366 @@ QUnit.module('Views', {
});
QUnit.module('FormViewTABMainButtons');
QUnit.test('using tab in an empty required string field should not move to the next field',function(assert) {
assert.expect(3);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field name="display_name" required="1" />' +
'<field name="foo" />' +
'</group>' +
'</sheet>' +
'</form>',
});
form.$('input[name=display_name]').click();
assert.strictEqual(form.$('input[name="display_name"]')[0], document.activeElement,
"display_name should be focused");
form.$('input[name="display_name"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
assert.strictEqual(form.$('input[name="display_name"]')[0], document.activeElement,
"display_name should still be focused because it is empty and required");
assert.strictEqual(form.$('input[name="display_name"]').hasClass("o_field_invalid"), true,
"display_name should have the o_field_invalid class");
form.destroy();
});
QUnit.test('using tab in an empty required date field should not move to the next field',function(assert) {
assert.expect(2);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field name="date" required="1" />' +
'<field name="foo" />' +
'</group>' +
'</sheet>' +
'</form>',
});
form.$('input[name=date]').click();
assert.strictEqual(form.$('input[name="date"]')[0], document.activeElement,
"display_name should be focused");
form.$('input[name="date"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
assert.strictEqual(form.$('input[name="date"]')[0], document.activeElement,
"date should still be focused because it is empty and required");
form.destroy();
});
QUnit.test('Edit button get the focus when pressing TAB from form', function (assert) {
assert.expect(1);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form>' +
'<div class="oe_title">' +
'<field name="display_name"/>' +
'</div>' +
'</form>',
res_id: 1,
});
// in edit
form.$buttons.find('.o_form_button_edit').click();
form.$('input[name="display_name"]').focus().trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
assert.strictEqual(form.$buttons.find('.btn-primary:visible')[0], document.activeElement,
"the first primary button (save) should be focused");
form.destroy();
});
QUnit.module('FormViewTABFormButtons');
QUnit.test('In Edition mode, after navigating to the last field, the default button when pressing TAB is SAVE', function (assert) {
assert.expect(1);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="state" invisible="1"/>' +
'<header>' +
'<button name="post" class="oe_highlight firstButton" string="Confirm" type="object"/>' +
'<button name="post" class="oe_highlight secondButton" string="Confirm2" type="object"/>' +
'</header>' +
'<sheet>' +
'<group>' +
'<div class="oe_title">' +
'<field name="display_name"/>' +
'</div>' +
'</group>' +
'</sheet>' +
'</form>',
res_id: 2,
viewOptions: {
mode: 'edit',
},
});
form.$('input[name="display_name"]').focus().trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
assert.strictEqual(form.$buttons.find('.o_form_button_save:visible')[0], document.activeElement,
"the save should be focused");
form.destroy();
});
QUnit.test('In READ mode, the default button with focus is the first primary button of the form', function (assert) {
assert.expect(1);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="state" invisible="1"/>' +
'<header>' +
'<button name="post" class="oe_highlight firstButton" string="Confirm" type="object"/>' +
'<button name="post" class="oe_highlight secondButton" string="Confirm2" type="object"/>' +
'</header>' +
'<sheet>' +
'<group>' +
'<div class="oe_title">' +
'<field name="display_name"/>' +
'</div>' +
'</group>' +
'</sheet>' +
'</form>',
res_id: 2,
});
assert.strictEqual(form.$('button.firstButton')[0], document.activeElement,
"by default the focus in edit mode should go to the first primary button of the form (not edit)");
form.destroy();
});
QUnit.test('In READ mode, the default button when pressing TAB is EDIT when there is no primary button on the form', function (assert) {
assert.expect(1);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="state" invisible="1"/>' +
'<header>' +
'<button name="post" class="not-primary" string="Confirm" type="object"/>' +
'<button name="post" class="not-primary" string="Confirm2" type="object"/>' +
'</header>' +
'<sheet>' +
'<group>' +
'<div class="oe_title">' +
'<field name="display_name"/>' +
'</div>' +
'</group>' +
'</sheet>' +
'</form>',
res_id: 2,
//debug:1,
});
assert.strictEqual(form.$buttons.find('.o_form_button_edit')[0],document.activeElement,
"in read mode, when there are no primary buttons on the form, the default button with the focus should be edit");
form.destroy();
});
QUnit.test('In Edition mode, when an attribute is dynamically required (and not required), TAB should navigate to the next field', function (assert) {
assert.expect(1);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field name="foo" attrs="{\'required\': [[\'bar\', \'=\', True]]}"/>' +
'<field name="bar"/>' +
'</group>' +
'</sheet>' +
'</form>',
res_id: 5,
viewOptions: {
mode: 'edit',
},
});
form.$('input[name="foo"]').focus();
$(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
assert.strictEqual(form.$('div[name="bar"]>input')[0], document.activeElement, "foo is not required, so hitting TAB on foo should have moved the focus to BAR");
form.destroy();
});
QUnit.test('In Edition mode, when an attribute is dynamically required, TAB should stop on the field if it is required', function (assert) {
assert.expect(1);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field name="foo" attrs="{\'required\': [[\'bar\', \'=\', True]]}"/>' +
'<field name="bar"/>' +
'</group>' +
'</sheet>' +
'</form>',
res_id: 5,
viewOptions: {
mode: 'edit',
},
});
form.$('div[name="bar"]>input').click();
form.$('input[name="foo"]').focus();
$(document.activeElement).trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
assert.strictEqual(form.$('input[name="foo"]')[0], document.activeElement, "foo is required, so hitting TAB on foo should keep the focus on foo");
form.destroy();
});
QUnit.test('display tooltips for save and discard buttons', function (assert) {
assert.expect(1);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="foo" />'+
'</form>',
});
form.$buttons.find('.o_form_buttons_edit').tooltip('show',false);
assert.strictEqual($('.tooltip .oe_tooltip_string').length, 1,
"should have rendered a tooltip");
form.destroy();
});
QUnit.test('if the focus is on the save button, hitting ENTER should save', function (assert) {
assert.expect(1);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="foo" />'+
'</form>',
viewOptions: {
mode: 'edit',
},
mockRPC: function (route, args) {
if (args.method === 'create') {
assert.ok(true, "should call the /create route");
}
return this._super(route, args);
},
});
form.$buttons.find('.o_form_button_save')
.focus()
.trigger($.Event('keydown', {which: $.ui.keyCode.ENTER}));
form.destroy();
});
QUnit.test('if the focus is on the discard button, hitting ENTER should save', function (assert) {
assert.expect(1);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="foo" />'+
'</form>',
viewOptions: {
mode: 'edit',
},
mockRPC: function (route, args) {
if (args.method === 'create') {
assert.ok(true, "should call the /create route");
}
return this._super(route, args);
},
});
form.$buttons.find('.o_form_button_cancel')
.focus()
.trigger($.Event('keydown', {which: $.ui.keyCode.ENTER}));
form.destroy();
});
QUnit.test('if the focus is on the save button, hitting ESCAPE should discard', function (assert) {
assert.expect(1);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="foo" />'+
'</form>',
viewOptions: {
mode: 'edit',
},
mockRPC: function (route, args) {
if (args.method === 'default_get') {
assert.ok(true, "should call the /create route");
}
return this._super(route, args);
},
});
form.$buttons.find('.o_form_button_save')
.focus()
.trigger($.Event('keydown', {which: $.ui.keyCode.ESCAPE}));
form.destroy();
});
QUnit.test('if the focus is on the discard button, hitting ESCAPE should discard', function (assert) {
assert.expect(1);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="foo" />'+
'</form>',
viewOptions: {
mode: 'edit',
},
mockRPC: function (route, args) {
if (args.method === 'default_get') {
assert.ok(true, "should call the /create route");
}
return this._super(route, args);
},
});
form.$buttons.find('.o_form_button_cancel')
.focus()
.trigger($.Event('keydown', {which: $.ui.keyCode.ESCAPE}));
form.destroy();
});
QUnit.test('if the focus is on the save button, hitting TAB should not move to the next button', function (assert) {
assert.expect(1);
/*
this test has only one purpose: to say that it is normal that the focus stays within a button primary even after the TAB key has been pressed.
It is not possible here to execute the default action of the TAB on a button : https://stackoverflow.com/questions/32428993/why-doesnt-simulating-a-tab-keypress-move-focus-to-the-next-input-field
so writing a test that will always succeed is not useful.
*/
assert.ok("Behavior can't be tested");
});
});
});
+4 -1
View File
@@ -2400,11 +2400,14 @@ QUnit.module('Views', {
"third row should be in edition");
// Press 'Tab' -> should go to next line
// add a value in the cell because the Tab on an empty first cell would activate the next widget in the view
list.$('.o_selected_row input').val(11).trigger('input');
list.$('.o_selected_row input').trigger({type: 'keydown', which: 9});
assert.ok(list.$('.o_data_row:nth(3)').hasClass('o_selected_row'),
"fourth row should be in edition");
// Press 'Tab' -> should go back to first line as the create action isn't available
list.$('.o_selected_row input').val(11).trigger('input');
list.$('.o_selected_row input').trigger({type: 'keydown', which: 9});
assert.ok(list.$('.o_data_row:first').hasClass('o_selected_row'),
"first row should be in edition");
@@ -2471,7 +2474,7 @@ QUnit.module('Views', {
}
return this._super.apply(this, arguments);
},
fieldDebounce: 1,
fieldDebounce: 1
});
// click on first td and press TAB
@@ -62,6 +62,12 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMi
isSet: function () {
return this.value && this.value !== "<p><br/></p>" && this.value.match(/\S/);
},
/**
* @override
*/
getFocusableElement: function () {
return this.$content || this._super.apply(this, arguments);
},
/**
* Do not re-render this field if it was the origin of the onchange call.
*
@@ -143,19 +143,22 @@ tour.register('main_flow_tour', {
trigger: ".modal-footer .btn-primary",
content: _t('Save'),
position: 'bottom',
}, {
},
{
// Add second component
trigger: ".o_field_x2many_list_row_add > a",
extra_trigger: "body:not(.modal-open)",
extra_trigger: "body:not(:has(table.o_list_view div.o_field_widget[name='product_id'] input))",
content: _t("Click here to add some lines."),
position: "bottom",
}, {
},
{
trigger: ".o_selected_row .o_required_modifier[name=product_id] input",
extra_trigger: '.o_field_widget[name=bom_line_ids] .o_data_row:nth(1).o_selected_row',
content: _t("Select a product, or create a new one on the fly."),
position: "right",
run: "text the_flow.component2",
}, {
},
{
trigger: ".ui-menu-item > a:contains('the_flow.component2')",
auto: true,
}, {
@@ -364,11 +367,11 @@ tour.register('main_flow_tour', {
content: _t("Enter an email address"),
position: "right",
run: "text test@the_flow.com",
}, {
},{
trigger: ".modal-footer .btn-primary",
content: _t("Save your changes"),
position: "bottom",
}, {
}, {
trigger: ".modal-footer .btn-primary span:contains('Send')",
content: _t("Try to send it to email"),
position: "bottom",