From a4d00a56b640d9da5129e2611667b3f491157ca5 Mon Sep 17 00:00:00 2001 From: Vincent Schippefilt Date: Thu, 5 Apr 2018 14:00:36 +0200 Subject: [PATCH] [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 --- .../src/js/chrome/abstract_web_client.js | 10 +- .../js/chrome/action_manager_act_window.js | 2 +- .../js/chrome/keyboard_navigation_mixin.js | 216 ++++++++++++++++++ .../static/src/js/core/browser_detection.js | 20 ++ addons/web/static/src/less/webclient.less | 15 ++ addons/web/static/src/xml/base.xml | 10 +- addons/web/views/webclient_templates.xml | 2 + 7 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 addons/web/static/src/js/chrome/keyboard_navigation_mixin.js create mode 100644 addons/web/static/src/js/core/browser_detection.js diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js index 71ff0cf31c2..3492af748f5 100644 --- a/addons/web/static/src/js/chrome/abstract_web_client.js +++ b/addons/web/static/src/js/chrome/abstract_web_client.js @@ -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 diff --git a/addons/web/static/src/js/chrome/action_manager_act_window.js b/addons/web/static/src/js/chrome/action_manager_act_window.js index bff130f5b63..8ac11f60537 100644 --- a/addons/web/static/src/js/chrome/action_manager_act_window.js +++ b/addons/web/static/src/js/chrome/action_manager_act_window.js @@ -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, diff --git a/addons/web/static/src/js/chrome/keyboard_navigation_mixin.js b/addons/web/static/src/js/chrome/keyboard_navigation_mixin.js new file mode 100644 index 00000000000..dc1b5247cb1 --- /dev/null +++ b/addons/web/static/src/js/chrome/keyboard_navigation_mixin.js @@ -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("
%s
", $(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; + +}); + diff --git a/addons/web/static/src/js/core/browser_detection.js b/addons/web/static/src/js/core/browser_detection.js new file mode 100644 index 00000000000..37c6a35eea2 --- /dev/null +++ b/addons/web/static/src/js/core/browser_detection.js @@ -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; +}); + diff --git a/addons/web/static/src/less/webclient.less b/addons/web/static/src/less/webclient.less index af76b82faba..c5f594757d0 100644 --- a/addons/web/static/src/less/webclient.less +++ b/addons/web/static/src/less/webclient.less @@ -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; +} \ No newline at end of file diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 94536d30dd2..4a1bccc2850 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -427,6 +427,14 @@ + +
+
+ Hit ENTER to + +
+
+
@@ -1009,7 +1017,7 @@
- +
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index 8400288fee2..92290a846c8 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -158,6 +158,8 @@ + +