From e0924fd01d8ffaf95b677b10ea0a5627b484a7d6 Mon Sep 17 00:00:00 2001 From: Vincent Schippefilt Date: Mon, 19 Feb 2018 15:26:15 +0100 Subject: [PATCH] [IMP] web: keyboard navigation in form view 1. Added the ability to navigate within the List view using UP and DOWN keys. 2. By defaults, the focus should be set on the search input field. 3. From the search input field, using the DOWN key, the focus goes to the list 4. From the search input field, using TAB key, the focus goes to the create button 5. From the create/import buttons, using TAB or DOWN key goes to the first row of the list, then UP and DOWN keys moves the focus from item to item in the list. 6. When on a row, hitting ENTER opens the view of that row. Also in this commit 1. When on the first row, hitting the UP key does nothing 2. When on the last row, hitting the DOWN key does nothing Not included in this commit 1. Memorize the position on the list when opening an row, than restoring when going back to that list 2. Navigation on grouped list using the keyboard --- .../js/chrome/action_manager_act_window.js | 16 ++++++++ .../web/static/src/js/chrome/search_view.js | 15 +++++++ .../src/js/views/abstract_controller.js | 6 +++ .../src/js/views/basic/basic_controller.js | 6 +++ .../src/js/views/basic/basic_renderer.js | 6 +++ .../src/js/views/list/list_controller.js | 40 ++++++++++++++++++- .../static/src/js/views/list/list_renderer.js | 38 +++++++++++++++++- .../static/src/js/widgets/auto_complete.js | 3 ++ addons/web/static/src/less/list_view.less | 12 ++++++ 9 files changed, 140 insertions(+), 2 deletions(-) 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 8ac11f60537..9dc0ff4c1c8 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 @@ -25,6 +25,7 @@ ActionManager.include({ update_filters: '_onUpdateFilters', search: '_onSearch', switch_view: '_onSwitchView', + navigation_move: '_onNavigationMove', }), //-------------------------------------------------------------------------- @@ -820,6 +821,21 @@ ActionManager.include({ var addedFilters = action.searchView.updateFilters(data.newFilters, data.filtersToRemove); data.callback(addedFilters); }, + /** + * Called mainly from the control panel when the focus should be given to a controller + * + * @param {OdooEvent} event + * @private + */ + _onNavigationMove : function(event) { + switch(event.data.direction) { + case 'down' : + var currentController = this.getCurrentController().widget; + currentController.giveFocus(); + event.stopPropagation(); + break; + } + }, /** * Called when there is a change in the search view, so the current action's * environment needs to be updated with the new domain, context and groupby. diff --git a/addons/web/static/src/js/chrome/search_view.js b/addons/web/static/src/js/chrome/search_view.js index 8ec1eb9ec71..94087c999b6 100644 --- a/addons/web/static/src/js/chrome/search_view.js +++ b/addons/web/static/src/js/chrome/search_view.js @@ -248,6 +248,12 @@ var SearchView = Widget.extend({ } e.preventDefault(); break; + case $.ui.keyCode.DOWN: + if (!this.autocomplete.is_expanded()) { + e.preventDefault(); + this.trigger_up('navigation_move', {direction: 'down'}); + break; + } } }, }, @@ -336,6 +342,9 @@ var SearchView = Widget.extend({ } return $.when.apply($, menu_defs).then(this.set_default_filters.bind(this)); }, + on_attach_callback: function () { + this._focusInput(); + }, get_title: function () { return this.title; }, @@ -417,6 +426,12 @@ var SearchView = Widget.extend({ if (this.$buttons) { this.$buttons.toggle(!this.headless && is_visible && this.visible_filters); } + this._focusInput(); + }, + /** + * puts the focus on the search input + */ + _focusInput: function () { if (!config.device.touch && config.device.size_class >= config.device.SIZES.SM) { this.$('input').focus(); } diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js index 4fff15d5663..deb7c37bb59 100644 --- a/addons/web/static/src/js/views/abstract_controller.js +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -115,6 +115,12 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { this.renderer.on_detach_callback(); }, + /** + * Gives the focus to the renderer + */ + giveFocus:function(){ + this.renderer.giveFocus(); + }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js index 1a87c3433f6..8a9b3713f15 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -120,6 +120,12 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { getSelectedIds: function () { return []; }, + /** + * Gives the focus to the renderer + */ + giveFocus:function() { + this.renderer.giveFocus(); + }, /** * Returns true iff the given recordID (or the main recordID) is dirty. * diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js index 4fb7983d00f..55a6c7a6c94 100644 --- a/addons/web/static/src/js/views/basic/basic_renderer.js +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -136,6 +136,12 @@ var BasicRenderer = AbstractRenderer.extend({ } }, + /** + * Order to focus to be given to the content of the current view + */ + giveFocus:function() { + }, + //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js index 551fa8102be..3262196fa51 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -116,6 +116,15 @@ var ListController = BasicController.extend({ if (!this.noLeaf && this.hasButtons) { this.$buttons = $(qweb.render('ListView.buttons', {widget: this})); this.$buttons.on('click', '.o_list_button_add', this._onCreateRecord.bind(this)); + + this._assignCreateKeyboardBehavior(this.$buttons.find('.o_list_button_add')); + this.$buttons.find('.o_list_button_add').tooltip({ + delay: {show: 200, hide:0}, + title: function(){ + return qweb.render('CreateButton.tooltip'); + }, + trigger: 'manual', + }); this.$buttons.on('click', '.o_list_button_discard', this._onDiscard.bind(this)); this.$buttons.appendTo($node); } @@ -221,6 +230,33 @@ var ListController = BasicController.extend({ .toggleActive(ids, !archive, this.handle) .then(this.update.bind(this, {}, {reload: false})); }, + /** + * Assign on the buttons create additionnal behavior to facilitate the work of the users doing input only using the keyboard + * + * @param {jQueryElement} $createButton The create button itself + */ + _assignCreateKeyboardBehavior: function($createButton) { + var self = this; + $createButton.on('keydown', function(e) { + $createButton.tooltip('hide'); + switch(e.which) { + case $.ui.keyCode.ENTER: + e.preventDefault(); + self._onCreateRecord.apply(self); + break; + case $.ui.keyCode.DOWN: + e.preventDefault(); + self.renderer.giveFocus(); + break; + case $.ui.keyCode.TAB: + if (!e.shiftKey && (e.target.classList.contains("btn-primary") || e.target.classList.contains("oe_highlight"))) { + e.preventDefault(); + $createButton.tooltip('show'); + } + break; + } + }); + }, /** * This function is the hook called by the field manager mixin to confirm * that a record has been saved. @@ -353,7 +389,9 @@ var ListController = BasicController.extend({ // we prevent the event propagation because we don't want this event to // trigger a click on the main bus, which would be then caught by the // list editable renderer and would unselect the newly created row - event.stopPropagation(); + if (event) { + event.stopPropagation(); + } var state = this.model.get(this.handle, {raw: true}); if (this.editable && !state.groupedBy.length) { this._addRecord(); diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js index e9c9a4d1f76..354e7c8a90b 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -37,6 +37,9 @@ var ListRenderer = BasicRenderer.extend({ 'click thead th.o_column_sortable': '_onSortColumn', 'click .o_group_header': '_onToggleGroup', 'click thead .o_list_record_selector input': '_onToggleSelection', + 'keypress thead tr td' : '_onKeyPress', + 'keydown tr' : '_onKeyDown', + 'keydown thead tr' : '_onKeyDown', }, /** * @constructor @@ -65,7 +68,14 @@ var ListRenderer = BasicRenderer.extend({ //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- - + /** + * Order to focus to be given to the content of the current view + * @override + * @public + */ + giveFocus:function() { + this.$('tbody .o_list_record_selector input:first()').focus(); + }, /** * @override */ @@ -716,6 +726,32 @@ var ListRenderer = BasicRenderer.extend({ // Handlers //-------------------------------------------------------------------------- + /** + * Manages the keyboard events on the list. If the list is not editable, when the user navigates to + * a cell using the keyboard, if he presses enter, enter the model represented by the line + * + * @private + * @param {KeyboardEvent} e + */ + _onKeyDown : function(e) { + if (!this.editable) { + switch(e.which) { + case $.ui.keyCode.DOWN: + $(e.currentTarget).next().find('input').focus(); + break; + case $.ui.keyCode.UP: + $(e.currentTarget).prev().find('input').focus(); + break; + case $.ui.keyCode.ENTER: + e.preventDefault(); + var id = $(e.currentTarget).data('id'); + if (id) { + this.trigger_up('open_record', {id:id, target: e.target}); + } + break; + } + } + }, /** * @private * @param {MouseEvent} event diff --git a/addons/web/static/src/js/widgets/auto_complete.js b/addons/web/static/src/js/widgets/auto_complete.js index 883206ae4c2..a25dc0150f5 100644 --- a/addons/web/static/src/js/widgets/auto_complete.js +++ b/addons/web/static/src/js/widgets/auto_complete.js @@ -216,5 +216,8 @@ return Widget.extend({ is_expandable: function () { return !!this.$('.o-selection-focus .o-expand').length; }, + is_expanded: function() { + return this.$el[0].style.display === "block"; + } }); }); diff --git a/addons/web/static/src/less/list_view.less b/addons/web/static/src/less/list_view.less index c6d81293fbf..ddc530e540a 100644 --- a/addons/web/static/src/less/list_view.less +++ b/addons/web/static/src/less/list_view.less @@ -117,6 +117,18 @@ border-top: none; // Override bootstrap for grouped list views } + tbody { + tr:focus-within { + background-color: @odoo-form-lightsecondary; + } + tr{ + .focussed { + background-color: @odoo-form-lightsecondary; + } + } + } + + // Values styling .o_list_number { text-align: right;