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("
", $(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 @@ +