[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:
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user