summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/chrome/keyboard_navigation_mixin.js
blob: c67cb98b15d7e7bcf937b7ac45ba4cd81922e6de (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
odoo.define('web.KeyboardNavigationMixin', function (require) {
    "use strict";
    var BrowserDetection = require('web.BrowserDetection');
    const core = require('web.core');

    /**
     * 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
        'B', // reserved for Odoo Previous Breadcrumb (Back)
        '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',
            'keyup': '_onKeyUp',
        },

        /**
         * @constructor
         * @param {object} [options]
         * @param {boolean} [options.autoAccessKeys=true]
         *      Whether accesskeys should be created automatically for buttons
         *      without them in the page.
         */
        init: function (options) {
            this.options = Object.assign({
                autoAccessKeys: true,
            }, options);
            this._areAccessKeyVisible = false;
            this.BrowserDetection = new BrowserDetection();
        },
        /**
         * @override
         */
        start: function () {
            const temp = this._hideAccessKeyOverlay.bind(this);
            this._hideAccessKeyOverlay = () => temp();
            window.addEventListener('blur', this._hideAccessKeyOverlay);
            core.bus.on('click', null, this._hideAccessKeyOverlay);
        },
        /**
         * @destructor
         */
        destroy: function () {
            window.removeEventListener('blur', this._hideAccessKeyOverlay);
            core.bus.off('click', null, this._hideAccessKeyOverlay);
        },

        //--------------------------------------------------------------------------
        // 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()));

                var $overlayParent;
                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.
                    $overlayParent = $(elem).parent();
                } else {
                    $overlayParent = $(elem);
                }

                if ($overlayParent.css('position') !== 'absolute') {
                    $overlayParent.css('position', 'relative');
                }
                overlay.appendTo($overlayParent);
            });
        },
        /**
         * @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 ($('body.o_ui_blocked').length &&
            (keyDownEvent.altKey || keyDownEvent.key === 'Alt') &&
            !keyDownEvent.ctrlKey) {
                if (keyDownEvent.preventDefault) keyDownEvent.preventDefault(); else keyDownEvent.returnValue = false;
                if (keyDownEvent.stopPropagation) keyDownEvent.stopPropagation();
                if (keyDownEvent.cancelBubble) keyDownEvent.cancelBubble = true;
                return false;
            }
            if (!this._areAccessKeyVisible &&
                (keyDownEvent.altKey || keyDownEvent.key === 'Alt') &&
                !keyDownEvent.ctrlKey) {

                this._areAccessKeyVisible = true;

                this._setAccessKeyOnTopNavigation();

                var usedAccessKey = this._getAllUsedAccessKeys();

                if (this.options.autoAccessKeys) {
                    var buttonsWithoutAccessKey = this.$el.find('button.btn:visible')
                        .not('[accesskey]')
                        .not('[disabled]')
                        .not('[tabindex="-1"]');
                    _.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;
                            }
                        }
                    });
                }

                var elementsWithoutAriaKeyshortcut = this.$el.find('[accesskey]').not('[aria-keyshortcuts]');
                _.each(elementsWithoutAriaKeyshortcut, function (elem) {
                    elem.setAttribute('aria-keyshortcuts', 'Alt+Shift+' + elem.accessKey);
                });
                this._addAccessKeyOverlays();
            }
            // on mac, there are a number of keys that are only accessible though the usage of
            // the ALT key (like the @ sign in most keyboards)
            // for them we do not facilitate the access keys, so they will need to be activated classically
            // though Control + Alt + key (case sensitive), see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/accesskey
            if (this.BrowserDetection.isOsMac())
                return;

            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].focus();
                            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 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;

});