[IMP] web: auto-assign and show access keys on ALT

This commits aims at adding an accesskey (a shortcut) to all buttons
in odoo. This is done by either by assigning an accesskey in the view
XML or by automatically assigning a key to the buttons using the first
available (not yet assigned as a shortcut key) letter of the text of the
button, failing that we use the latin alphabetical order to find the
first unused key.

Included in this commit:
    1. Using the ALT key, automatically assign
    2. When the ALT key is pressed, show the assigned shortcuts
    3. Assign a number to the menu items (enterprise)
    4. Activate number accesskeys without using SHIFT
    5. Extra logic to support the activation of the alphabetical
        accesskeys without needing to add the SHIFT key in the key
        combination (except for Firefox on linux)
    6. Assign a fixed accesskey to the search field

Not done:
    1. Executing the primary action on dialogs using SHIFT+ENTER
    2. Show the input using TAB on the appswitcher screen

Things to consider:
    1. Fixing more access keys on forms that share the same features
        (b.e. Validate in customer invoice and vendor bills)
    2. Not assigning keys to the chatter section
This commit is contained in:
Vincent Schippefilt
2018-04-10 09:39:08 +02:00
parent 02a063fd73
commit a4d00a56b6
7 changed files with 271 additions and 4 deletions
@@ -19,6 +19,7 @@ var crash_manager = require('web.crash_manager');
var data_manager = require('web.data_manager');
var Dialog = require('web.Dialog');
var dom = require('web.dom');
var KeyboardNavigationMixin = require('web.KeyboardNavigationMixin');
var Loading = require('web.Loading');
var NotificationManager = require('web.NotificationManager');
var RainbowMan = require('web.RainbowMan');
@@ -29,7 +30,8 @@ var Widget = require('web.Widget');
var _t = core._t;
var qweb = core.qweb;
var AbstractWebClient = Widget.extend(ServiceProviderMixin, {
var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMixin, {
events: _.extend(KeyboardNavigationMixin.events, {}),
custom_events: {
clear_uncommitted_changes: function (e) {
this.clear_uncommitted_changes().then(e.data.callback);
@@ -95,6 +97,7 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, {
this.client_options = {};
this._super(parent);
ServiceProviderMixin.init.call(this);
KeyboardNavigationMixin.init.call(this);
this.origin = undefined;
this._current_state = null;
this.menu_dm = new concurrency.DropMisordered();
@@ -134,6 +137,7 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, {
core.bus.trigger('web_client_ready');
});
},
bind_events: function () {
var self = this;
$('.oe_systray').show();
@@ -161,12 +165,14 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, {
}
}, 0);
});
window.addEventListener('blur', function (e) { self._hideAccessKeyOverlay(); });
core.bus.on('click', this, function (ev) {
$('.tooltip').remove();
if (!$(ev.target).is('input[type=file]')) {
$(this.el.getElementsByClassName('oe_dropdown_menu oe_opened')).removeClass('oe_opened');
$(this.el.getElementsByClassName('oe_dropdown_toggle oe_opened')).removeClass('oe_opened');
}
this._hideAccessKeyOverlay();
});
core.bus.on('connection_lost', this, this.on_connection_lost);
core.bus.on('connection_restored', this, this.on_connection_restored);
@@ -242,7 +248,7 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, {
* Sets the first part of the title of the window, dedicated to the current action.
*/
set_title: function (title) {
this.set_title_part("action", title);
this.set_title_part("action", title);
},
/**
* Sets an arbitrary part of the title of the window. Title parts are
@@ -435,7 +435,7 @@ ActionManager.include({
var View = view_registry.get(key || viewType);
if (View) {
views.push({
accessKey: View.prototype.accessKey,
accessKey: View.prototype.accessKey || View.prototype.accesskey,
fieldsView: fieldsView,
icon: View.prototype.icon,
isMobileFriendly: View.prototype.mobile_friendly,
@@ -0,0 +1,216 @@
odoo.define('web.KeyboardNavigationMixin', function (require) {
"use strict";
var BrowserDetection = require('web.BrowserDetection');
/**
* list of the key that should not be used as accesskeys. Either because we want to reserve them for a specific behavior in Odoo or
* because they will not work in certain browser/OS
*/
var knownUnusableAccessKeys = [' ',
'A', // reserved for Odoo Edit
'C', // reserved for Odoo Create
'H', // reserved for Odoo Home
'J', // reserved for Odoo Discard
'K', // reserved for Odoo Kanban view
'L', // reserved for Odoo List view
'N', // reserved for Odoo pager Next
'P', // reserved for Odoo pager Previous
'S', // reserved for Odoo Save
'Q', // reserved for Odoo Search
'E', // chrome does not support 'E' access key --> go to address bar to search google
'F', // chrome does not support 'F' access key --> go to menu
'D', // chrome does not support 'D' access key --> go to address bar
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9' // reserved for Odoo menus
];
var KeyboardNavigationMixin = {
events: {
'keydown': '_onKeyDown',
'keypress': '_onKeyPress',
'keyup': '_onKeyUp',
},
init: function () {
this._areAccessKeyVisible = false;
this.BrowserDetection = new BrowserDetection();
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_addAccessKeyOverlays: function () {
var accesskeyElements = $(document).find('[accesskey]').filter(':visible');
_.each(accesskeyElements, function (elem) {
var overlay = $(_.str.sprintf("<div class='o_web_accesskey_overlay'>%s</div>", $(elem).attr('accesskey').toUpperCase()));
if (elem.tagName.toUpperCase() === "INPUT") {
// special case for the search input that has an access key defined. We cannot set the overlay on the input itself, only on its parent
overlay.appendTo($(elem).parent().css('position', 'relative'));
}
else {
overlay.appendTo($(elem).css('position', 'relative'));
}
});
},
/**
* @private
* @return {jQuery[]}
*/
_getAllUsedAccessKeys: function () {
var usedAccessKeys = knownUnusableAccessKeys.slice();
this.$el.find('[accesskey]').each(function (_, elem) {
usedAccessKeys.push(elem.accessKey.toUpperCase());
});
return usedAccessKeys;
},
/**
* hides the overlay that shows the access keys.
*
* @private
* @param $parent {jQueryElemen} the parent of the DOM element to which shorcuts overlay have been added
* @return {undefined|jQuery}
*/
_hideAccessKeyOverlay: function () {
this._areAccessKeyVisible = false;
var overlays = this.$el.find('.o_web_accesskey_overlay');
if (overlays.length) {
return overlays.remove();
}
},
/**
* @private
*/
_setAccessKeyOnTopNavigation: function () {
this.$el.find('.o_menu_sections>li>a').each(function (number, item) {
item.accessKey = number + 1;
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Assign access keys to all buttons inside $el and sets an overlay to show the access key
* The access keys will be assigned using first the name of the button, letter by letter until we find one available,
* after that we will assign any available letters.
* Not all letters should be used as access keys, some of the should be reserved for standard odoo behavior or browser behavior
*
* @private
* @param keyDownEvent {jQueryKeyboardEvent} the keyboard event triggered
* return {undefined|false}
*/
_onKeyDown: function (keyDownEvent) {
if (!this._areAccessKeyVisible &&
(keyDownEvent.altKey || keyDownEvent.key === 'Alt') &&
!keyDownEvent.ctrlKey) {
this._areAccessKeyVisible = true;
this._setAccessKeyOnTopNavigation();
var usedAccessKey = this._getAllUsedAccessKeys();
var buttonsWithoutAccessKey = this.$el.find('button.btn:visible').not('[accesskey]').not('[disabled]');
_.each(buttonsWithoutAccessKey, function (elem) {
var buttonString = [elem.innerText, elem.title, "ABCDEFGHIJKLMNOPQRSTUVWXYZ"].join('');
for (var letterIndex = 0; letterIndex < buttonString.length; letterIndex++) {
var candidateAccessKey = buttonString[letterIndex].toUpperCase();
if (candidateAccessKey >= 'A' && candidateAccessKey <= 'Z' &&
!_.includes(usedAccessKey, candidateAccessKey)) {
elem.accessKey = candidateAccessKey;
usedAccessKey.push(candidateAccessKey);
break;
}
}
});
this._addAccessKeyOverlays();
}
if (keyDownEvent.altKey && !keyDownEvent.ctrlKey && keyDownEvent.key.length === 1) { // we don't want to catch the Alt key down, only the characters A to Z and number keys
var elementWithAccessKey = [];
if (keyDownEvent.keyCode >= 65 && keyDownEvent.keyCode <= 90 || keyDownEvent.keyCode >= 97 && keyDownEvent.keyCode <= 122) {
// 65 = A, 90 = Z, 97 = a, 122 = z
elementWithAccessKey = document.querySelectorAll('[accesskey="' + String.fromCharCode(keyDownEvent.keyCode).toLowerCase() +
'"], [accesskey="' + String.fromCharCode(keyDownEvent.keyCode).toUpperCase() + '"]');
if (elementWithAccessKey.length) {
if (this.BrowserDetection.isOsMac() ||
!this.BrowserDetection.isBrowserChrome()) { // on windows and linux, chrome does not prevent the default of the accesskeys
elementWithAccessKey[0].click();
if (keyDownEvent.preventDefault) keyDownEvent.preventDefault(); else keyDownEvent.returnValue = false;
if (keyDownEvent.stopPropagation) keyDownEvent.stopPropagation();
if (keyDownEvent.cancelBubble) keyDownEvent.cancelBubble = true;
return false;
}
}
}
else {
// identify if the user has tapped on the number keys above the text keys.
// this is not trivial because alt is a modifier and will not input the actual number in most keyboard layouts
var numberKey;
if (keyDownEvent.originalEvent.code && keyDownEvent.originalEvent.code.indexOf('Digit') === 0) {
//chrome & FF have the key Digit set correctly for the numbers
numberKey = keyDownEvent.originalEvent.code[keyDownEvent.originalEvent.code.length - 1];
} else if (keyDownEvent.originalEvent.key &&
keyDownEvent.originalEvent.key.length === 1 &&
keyDownEvent.originalEvent.key >= '0' &&
keyDownEvent.originalEvent.key <= '9') {
//edge does not use 'code' on the original event, but the 'key' is set correctly
numberKey = keyDownEvent.originalEvent.key;
} else if (keyDownEvent.keyCode >= 48 && keyDownEvent.keyCode <= 57) {
//fallback on keyCode if both code and key are either not set or not digits
numberKey = keyDownEvent.keyCode - 48;
}
if (numberKey >= '0' && numberKey <= '9') {
elementWithAccessKey = document.querySelectorAll('[accesskey="' + numberKey + '"]');
if (elementWithAccessKey.length) {
elementWithAccessKey[0].click();
if (keyDownEvent.preventDefault) keyDownEvent.preventDefault(); else keyDownEvent.returnValue = false;
if (keyDownEvent.stopPropagation) keyDownEvent.stopPropagation();
if (keyDownEvent.cancelBubble) keyDownEvent.cancelBubble = true;
return false;
}
}
}
}
},
/**
* hides the shortcut overlays when key press event is triggered on the ALT key
*
* @private
* @param keyPressEvent {jQueryKeyboardEvent} the keyboard event triggered
* @return {undefined|false}
*/
_onKeyPress: function (keyPressEvent) {
if ((keyPressEvent.altKey || keyPressEvent.key === 'Alt') && !keyPressEvent.ctrlKey) {
if (keyPressEvent.preventDefault) keyPressEvent.preventDefault(); else keyPressEvent.returnValue = false;
if (keyPressEvent.stopPropagation) keyPressEvent.stopPropagation();
if (keyPressEvent.cancelBubble) keyPressEvent.cancelBubble = true;
return false;
}
},
/**
* hides the shortcut overlays when keyup event is triggered on the ALT key
*
* @private
* @param keyUpEvent {jQueryKeyboardEvent} the keyboard event triggered
* @return {undefined|false}
*/
_onKeyUp: function (keyUpEvent) {
if ((keyUpEvent.altKey || keyUpEvent.key === 'Alt') && !keyUpEvent.ctrlKey) {
this._hideAccessKeyOverlay();
if (keyUpEvent.preventDefault) keyUpEvent.preventDefault(); else keyUpEvent.returnValue = false;
if (keyUpEvent.stopPropagation) keyUpEvent.stopPropagation();
if (keyUpEvent.cancelBubble) keyUpEvent.cancelBubble = true;
return false;
}
},
};
return KeyboardNavigationMixin;
});
@@ -0,0 +1,20 @@
odoo.define('web.BrowserDetection', function (require) {
"use strict";
var Class = require('web.Class');
var BrowserDetection = Class.extend({
init: function () {
},
isOsMac: function () {
return navigator.platform.toLowerCase().indexOf('mac') !== -1;
},
isBrowserChrome: function () {
return $.browser.chrome && // depends on jquery 1.x, removed in jquery 2 and above
navigator.userAgent.toLocaleLowerCase().indexOf('edge') === -1; // as far as jquery is concerned, Edge is chrome
}
});
return BrowserDetection;
});
+15
View File
@@ -128,3 +128,18 @@ div.o_boolean_toggle {
.bg-info-full {
background-color: @brand-info;
}
.o_web_accesskey_overlay {
font-family: @font-family-sans-serif;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
z-index: 1000000;
background-color: rgba(0,0,0,.6);
color: #FFFFFF;
justify-content: center;
display: flex;
align-items: center;
}
+9 -1
View File
@@ -427,6 +427,14 @@
</div>
</div>
</t>
<t t-name="FormButton.tooltip">
<div class="oe_tooltip_string">
<div class="tooltip-inner">
Hit ENTER to
<t t-esc="title"></t>
</div>
</div>
</t>
<form t-name="FormView.set_default">
<t t-set="args" t-value="widget.args"/>
<table style="width: 100%">
@@ -1009,7 +1017,7 @@
<div t-name="SearchView" class="o_searchview">
<span class="o_searchview_more fa" title="Advanced Search..."/>
</div>
<input t-name="SearchView.InputView" type="text" class="o_searchview_input" placeholder="Search..."/>
<input t-name="SearchView.InputView" type="text" class="o_searchview_input" accesskey="Q" placeholder="Search..."/>
<div t-name="SearchView.FacetView" tabindex="0" class="o_searchview_facet">
<span t-if="widget.model.has('icon')" t-att-class="'fa ' + widget.model.get('icon') + ' o_searchview_facet_label'"/>
<span t-if="!widget.model.has('icon')" class="o_searchview_facet_label">
+2
View File
@@ -158,6 +158,8 @@
<script type="text/javascript" src="/web/static/src/js/chrome/action_manager_act_window.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/action_manager_report.js"/>
<script type="text/javascript" src="/web/static/src/js/chrome/control_panel.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/browser_detection.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/keyboard_navigation_mixin.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/abstract_web_client.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/web_client.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/root_widget.js"></script>