From 9de1bc0eef6f5bfaa2a8d745431caa361ae91548 Mon Sep 17 00:00:00 2001 From: kujiu Date: Fri, 22 Jun 2018 21:22:21 +0200 Subject: [IMP] Improve compatibility with screen readers (accessibility) (#24574) Today, Odoo is really tricky to use without seeing the screen, it must be improved to be usable. This PR forbid to use labels without a "for" attribute, add some title, rule and aria attributes in HTML. With that, Odoo will be fully usable with a screen reader. * [IMP] Labels must have a for attribute. Improve accessibility. * [IMP] Better error message when trying to read a missing cached value * [FIX] Add some aria-label and title attributes for screen readers. * [FIX] Template name is not included in the error message in case of SyntaxError in QWeb * [FIX] Improve the Tour failed at step error message to be more explicit. * [IMP] Add aria-labels * [FIX] Add missing aria-label on failing test * [IMP] aria-hidden means hidden. Fix all bad aria-hidden and hide aria-hidden for all. * [IMP] Color names on kanban views and many2many tags * [IMP] Add some checks on views for accessibility. * [IMP] Add `alt` attribute on `img` tags. * [IMP] Add aria-label and title on non-described icons * [IMP] Add button role to widgets with btn class * [IMP] Translate aria and formatted attributes. * [IMP] Remove wrong aria-labelledby * [IMP] Add menu role on dropdowns * [IMP] Buttons must be focusable * [IMP] Add aria attributes on progress bars * [IMP] Improve accessibility of basic widgets * [IMP] Change main layout to more semantic tags * [IMP] Add menuitem role when missing * [IMP] Remove wrong role='presentation' * [IMP] Improve accessibility of tab panels * [IMP] Add aria-invalid on invalid fields * [IMP] Add aria-sort on ordered columns * [IMP] Add role on alerts * [IMP] Use dialog role, header, main and footer tags for modals * [IMP] Add labels on o_status * [IMP] Improve accessibility of kanban view with feeds and articles * [IMP] Add alerts in case of new messages * [IMP] Add widget, navigation or img role to aria-labelled items --- addons/web/static/src/js/core/dialog.js | 31 +- addons/web/static/src/js/fields/abstract_field.js | 2 + addons/web/static/src/js/fields/basic_fields.js | 22 +- .../web/static/src/js/fields/relational_fields.js | 2 +- .../static/src/js/views/basic/basic_renderer.js | 1 + .../js/views/kanban/kanban_column_progressbar.js | 3 + .../js/views/kanban/kanban_column_quick_create.js | 2 +- .../static/src/js/views/kanban/kanban_record.js | 31 +- .../js/views/kanban/kanban_record_quick_create.js | 2 +- .../src/js/views/list/list_editable_renderer.js | 6 +- .../web/static/src/js/views/list/list_renderer.js | 4 + .../static/src/js/views/pivot/pivot_renderer.js | 5 + .../web/static/src/js/widgets/change_password.js | 2 +- addons/web/static/src/js/widgets/debug_manager.js | 2 +- .../src/js/widgets/domain_selector_dialog.js | 2 +- addons/web/static/src/scss/ui.scss | 4 + addons/web/static/src/scss/utils.scss | 8 + addons/web/static/src/xml/base.xml | 414 +++++++++++---------- addons/web/static/src/xml/dialog.xml | 12 +- addons/web/static/src/xml/kanban.xml | 43 ++- addons/web/static/src/xml/web_calendar.xml | 18 +- 21 files changed, 361 insertions(+), 255 deletions(-) (limited to 'addons/web/static/src') diff --git a/addons/web/static/src/js/core/dialog.js b/addons/web/static/src/js/core/dialog.js index 7d4796886b7..6ebd94ef13e 100644 --- a/addons/web/static/src/js/core/dialog.js +++ b/addons/web/static/src/js/core/dialog.js @@ -17,12 +17,13 @@ var _t = core._t; * always exists during the lifecycle of the dialog. **/ var Dialog = Widget.extend({ + tagName: 'main', xmlDependencies: ['/web/static/src/xml/dialog.xml'], custom_events: _.extend({}, Widget.prototype.custom_events, { focus_control_button: '_onFocusControlButton', }), events: _.extend({} , Widget.prototype.events, { - 'keydown .modal-footer button':'_onFooterButtonKeyDown', + 'keydown footer.modal-footer button':'_onFooterButtonKeyDown', }), /** * @param {Widget} parent @@ -92,7 +93,7 @@ var Dialog = Widget.extend({ self.$modal.find('.modal-dialog').addClass('modal-sm'); break; } - self.$footer = self.$modal.find(".modal-footer"); + self.$footer = self.$modal.find("footer"); self.set_buttons(self.buttons); self.$modal.on('hidden.bs.modal', _.bind(self.destroy, self)); }); @@ -168,7 +169,17 @@ var Dialog = Widget.extend({ var self = this; this.appendTo($('
')).then(function () { - self.$modal.find(".modal-body").replaceWith(self.$el); + var $main = self.$modal.find("main:first"); + if ($main.length === 0) { + $main = self.$modal.find(".modal-body:first"); + } + if (self.$el.tagName !== 'MAIN') { + self.$el.removeClass(".modal-body"); + self.$el = $('
').append(self.$el); + } + $main.replaceWith(self.$el); + self.$modal.attr('open', true); + self.$modal.removeAttr("aria-hidden"); self.$modal.modal('show'); self._opened.resolve(); }); @@ -201,11 +212,13 @@ var Dialog = Widget.extend({ $('.tooltip').remove(); //remove open tooltip if any to prevent them staying when modal has disappeared if (this.$modal) { this.$modal.modal('hide'); + this.$modal.removeAttr('open'); + this.$modal.attr('aria-hidden', true); this.$modal.remove(); } if (!isFocusSet) { - var modals = $('body > .modal').filter(':visible'); + var modals = $('body > [role="dialog"]').filter(':visible'); if (modals.length) { modals.last().focus(); // Keep class modal-open (deleted by bootstrap hide fnct) on body to allow scrolling inside the modal @@ -287,9 +300,9 @@ Dialog.alert = function (owner, message, options) { return new Dialog(owner, _.extend({ size: 'medium', buttons: buttons, - $content: $('
', { + $content: $('
').append($('

', { text: message, - }), + })), title: _t("Alert"), }, options)).open({shouldFocusButtons:true}); }; @@ -312,9 +325,9 @@ Dialog.confirm = function (owner, message, options) { return new Dialog(owner, _.extend({ size: 'medium', buttons: buttons, - $content: $('

', { + $content: $('
').append($('

', { text: message, - }), + })), title: _t("Confirmation"), }, options)).open({shouldFocusButtons:true}); }; @@ -345,7 +358,7 @@ Dialog.safeConfirm = function (owner, message, options) { text: message, }); } - $content = $('

').append($content, $securityCheck); + $content = $('
').append($content, $securityCheck); var buttons = [ { diff --git a/addons/web/static/src/js/fields/abstract_field.js b/addons/web/static/src/js/fields/abstract_field.js index 9bb61475b83..eab0093ed50 100644 --- a/addons/web/static/src/js/fields/abstract_field.js +++ b/addons/web/static/src/js/fields/abstract_field.js @@ -310,6 +310,7 @@ var AbstractField = Widget.extend({ */ removeInvalidClass: function () { this.$el.removeClass('o_field_invalid'); + this.$el.removeAttr('aria-invalid'); }, /** @@ -317,6 +318,7 @@ var AbstractField = Widget.extend({ */ setInvalidClass: function () { this.$el.addClass('o_field_invalid'); + this.$el.attr('aria-invalid', 'true'); }, /** diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 43b4e349a66..0cf9271de9d 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -397,9 +397,10 @@ var LinkButton = AbstractField.extend({ if (this.value) { var className = this.attrs.icon || 'fa-globe'; - this.$el.html(""); + this.$el.html(""); this.$el.addClass("fa "+ className); this.$el.attr('title', this.value); + this.$el.attr('aria-label', this.value); } }, @@ -1460,6 +1461,7 @@ var PriorityWidget = AbstractField.extend({ _renderStar: function (tag, isFull, index, tip) { return $(tag) .attr('title', tip) + .attr('aria-label', tip) .attr('data-index', index) .addClass('o_priority_star fa') .toggleClass('fa-star', isFull) @@ -1529,7 +1531,8 @@ var AttachmentImage = AbstractField.extend({ if (this.value) { this.$el.empty().append($('/', { src: "/web/image/" + this.value.data.id + "?unique=1", - title: this.value.data.display_name + title: this.value.data.display_name, + alt: _("Image") })); } } @@ -1598,7 +1601,9 @@ var StateSelectionWidget = AbstractField.extend({ this.$('.o_status') .removeClass('o_status_red o_status_green') .addClass(currentState.state_class) - .prop('special_click', true); + .prop('special_click', true) + .parent().attr('title', currentState.state_name) + .attr('aria-label', currentState.state_name); // Render "FormSelection.Items" and move it into "FormSelection" var $items = $(qweb.render('FormSelection.items', { @@ -1662,8 +1667,9 @@ var FavoriteWidget = AbstractField.extend({ * @private */ _render: function () { - var template = this.attrs.nolabel ? '' : ' %s'; - this.$el.empty().append(_.str.sprintf(template, this.value ? 'fa-star' : 'fa-star-o', this.value ? _t('Remove from Favorites') : _t('Add to Favorites'))); + var tip = this.value ? _t('Remove from Favorites') : _t('Add to Favorites'); + var template = this.attrs.nolabel ? '' : ' %s'; + this.$el.empty().append(_.str.sprintf(template, this.value ? 'fa-star' : 'fa-star-o', tip, tip)); }, //-------------------------------------------------------------------------- @@ -2043,7 +2049,10 @@ var FieldProgressBar = AbstractField.extend({ widthComplete = 100; } - this.$('.o_progress').toggleClass('o_progress_overflow', value > max_value); + this.$('.o_progress').toggleClass('o_progress_overflow', value > max_value) + .attr('aria-valuemin', '0') + .attr('aria-valuemax', max_value) + .attr('aria-valuenow', value); this.$('.o_progressbar_complete').css('width', widthComplete + '%'); if (!this.write_mode) { @@ -2100,6 +2109,7 @@ var FieldToggleBoolean = AbstractField.extend({ .toggleClass('text-muted', !this.value); var title = this.value ? this.attrs.options.active : this.attrs.options.inactive; this.$el.attr('title', title); + this.$el.attr('aria-pressed', this.value); }, //-------------------------------------------------------------------------- diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index c86411b6cd9..83ce59f1eb6 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -1867,7 +1867,7 @@ var FormFieldMany2ManyTags = FieldMany2ManyTags.extend({ var $target = $(ev.currentTarget); var color = $target.data('color'); var id = $target.data('id'); - var tag = this.$("span.badge[data-id='" + id + "']"); + var tag = this.$("button.badge[data-id='" + id + "']"); var currentColor = tag.data('color'); var changes = {}; 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 9834c9c18de..13a00dda046 100644 --- a/addons/web/static/src/js/views/basic/basic_renderer.js +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -50,6 +50,7 @@ var BasicRenderer = AbstractRenderer.extend({ invalidFields.push(widget.name); } widget.$el.toggleClass('o_field_invalid', !canBeSaved); + widget.$el.attr('aria-invalid', !canBeSaved); }); return invalidFields; }, diff --git a/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js b/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js index fad822efdb9..7af6b677dcf 100644 --- a/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js +++ b/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js @@ -166,6 +166,9 @@ var KanbanColumnProgressBar = Widget.extend({ if (count > 0) { $bar.addClass('o_bar_has_records'); $bar.css('width', (count * 100 / self.groupCount) + '%'); + $bar.attr('aria-valuemin', 0); + $bar.attr('aria-valuemax', self.groupCount); + $bar.attr('aria-valuenow', count); } else { $bar.css('width', ''); } diff --git a/addons/web/static/src/js/views/kanban/kanban_column_quick_create.js b/addons/web/static/src/js/views/kanban/kanban_column_quick_create.js index 170b950a49d..f554d333bc9 100644 --- a/addons/web/static/src/js/views/kanban/kanban_column_quick_create.js +++ b/addons/web/static/src/js/views/kanban/kanban_column_quick_create.js @@ -216,7 +216,7 @@ var ColumnQuickCreate = Widget.extend({ } // ignore clicks in modals - if ($(event.target).closest('.modal').length) { + if ($(event.target).closest('[role="dialog"]').length) { return; } diff --git a/addons/web/static/src/js/views/kanban/kanban_record.js b/addons/web/static/src/js/views/kanban/kanban_record.js index 30bafaab117..66171d2693c 100644 --- a/addons/web/static/src/js/views/kanban/kanban_record.js +++ b/addons/web/static/src/js/views/kanban/kanban_record.js @@ -17,7 +17,21 @@ var widgetRegistry = require('web.widget_registry'); var _t = core._t; var QWeb = core.qweb; -var NB_KANBAN_RECORD_COLORS = 12; +var KANBAN_RECORD_COLORS = [ + _t('No color'), + _t('Red'), + _t('Orange'), + _t('Yellow'), + _t('Light blue'), + _t('Dark purple'), + _t('Salmon pink'), + _t('Medium blue'), + _t('Dark blue'), + _t('Fushia'), + _t('Green'), + _t('Purple'), +]; +var NB_KANBAN_RECORD_COLORS = KANBAN_RECORD_COLORS.length; var KanbanRecord = Widget.extend({ events: { @@ -126,6 +140,17 @@ var KanbanRecord = Widget.extend({ } return 0; }, + /** + * Computes a color name from value + * + * @private + * @param {number | string} variable + * @returns {integer} the color name + */ + _getColorname: function (variable) { + var colorID = this._getColorID(variable); + return KANBAN_RECORD_COLORS[colorID]; + }, /** * @private * @param {string} model the name of the model @@ -284,6 +309,7 @@ var KanbanRecord = Widget.extend({ _render: function () { this._replaceElement(this.qweb.render('kanban-box', this.qweb_context)); this.$el.addClass('o_kanban_record').attr("tabindex",0); + this.$el.attr('role', 'article'); this.$el.data('record', this); if (this.$el.hasClass('oe_kanban_global_click') || this.$el.hasClass('oe_kanban_global_click_edit')) { @@ -338,6 +364,7 @@ var KanbanRecord = Widget.extend({ kanban_image: this._getImageURL.bind(this), kanban_color: this._getColorClassname.bind(this), kanban_getcolor: this._getColorID.bind(this), + kanban_getcolorname: this._getColorname.bind(this), kanban_compute_domain: this._computeDomain.bind(this), read_only_mode: this.read_only_mode, record: this.record, @@ -354,8 +381,10 @@ var KanbanRecord = Widget.extend({ _setupColor: function () { var color_field = this.$el.attr('color'); if (color_field && color_field in this.fields) { + var colorHelp = _.str.sprintf(_t("Card color: %s"), this._getColorname(this.recordData[color_field])); var colorClass = this._getColorClassname(this.recordData[color_field]); this.$el.addClass(colorClass); + this.$el.prepend(''); } }, /** diff --git a/addons/web/static/src/js/views/kanban/kanban_record_quick_create.js b/addons/web/static/src/js/views/kanban/kanban_record_quick_create.js index bf7e60b68c3..749938e8b9e 100644 --- a/addons/web/static/src/js/views/kanban/kanban_record_quick_create.js +++ b/addons/web/static/src/js/views/kanban/kanban_record_quick_create.js @@ -232,7 +232,7 @@ var RecordQuickCreate = Widget.extend({ } // ignore clicks in modals - if ($(ev.target).closest('.modal').length) { + if ($(ev.target).closest('[role="dialog"]').length) { return; } diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js index 07aa8eacb4e..9ec82878e70 100644 --- a/addons/web/static/src/js/views/list/list_editable_renderer.js +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -527,7 +527,7 @@ ListRenderer.include({ _renderRows: function () { var $rows = this._super(); if (this.addCreateLine) { - var $a = $('').text(_t("Add a line")); + var $a = $('').text(_t("Add a line")); var $td = $('') .attr('colspan', this._getNumberOfCols()) .addClass('o_field_x2many_list_row_add') @@ -923,9 +923,9 @@ ListRenderer.include({ // ignore clicks in modals, except if the list is in a modal, and the // click is performed in that modal - var $clickModal = $(event.target).closest('.modal'); + var $clickModal = $(event.target).closest('[role="dialog"]'); if ($clickModal.length) { - var $listModal = this.$el.closest('.modal'); + var $listModal = this.$el.closest('[role="dialog"]'); if ($clickModal.prop('id') !== $listModal.prop('id')) { return; } 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 bf206eae32c..e84d6687cc9 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -549,6 +549,10 @@ var ListRenderer = BasicRenderer.extend({ .toggleClass('o-sort-up', isNodeSorted ? order[0].asc : false) .addClass(field.sortable && 'o_column_sortable'); + if (isNodeSorted) { + $th.attr('aria-sort', order[0].asc ? 'ascending': 'descending'); + } + if (field.type === 'float' || field.type === 'integer' || field.type === 'monetary') { $th.css({textAlign: 'right'}); } diff --git a/addons/web/static/src/js/views/pivot/pivot_renderer.js b/addons/web/static/src/js/views/pivot/pivot_renderer.js index b5648d358a9..b3953076161 100644 --- a/addons/web/static/src/js/views/pivot/pivot_renderer.js +++ b/addons/web/static/src/js/views/pivot/pivot_renderer.js @@ -96,6 +96,11 @@ var PivotRenderer = AbstractRenderer.extend({ $cell.data('id', cell.id).data('measure', cell.measure); if (cell.id === this.state.sortedColumn.id && cell.measure === this.state.sortedColumn.measure) { $cell.addClass('o_pivot_measure_row_sorted_' + this.state.sortedColumn.order); + if (this.state.sortedColumn.order == 'asc') { + $cell.attr('aria-sorted', 'ascending'); + } else { + $cell.attr('aria-sorted', 'descending'); + } } } $row.append($cell); diff --git a/addons/web/static/src/js/widgets/change_password.js b/addons/web/static/src/js/widgets/change_password.js index c05fa30408b..b2e4e013335 100644 --- a/addons/web/static/src/js/widgets/change_password.js +++ b/addons/web/static/src/js/widgets/change_password.js @@ -28,7 +28,7 @@ var ChangePassword = AbstractAction.extend({ var $button = self.$('.oe_form_button'); $button.appendTo(this.getParent().$footer); $button.eq(1).click(function () { - self.$el.parents('.modal').modal('hide'); + self.$el.parents('[role="dialog"]').modal('hide'); }); $button.eq(0).click(function () { self._rpc({ diff --git a/addons/web/static/src/js/widgets/debug_manager.js b/addons/web/static/src/js/widgets/debug_manager.js index 4b86b76c36d..4bdc27448a0 100644 --- a/addons/web/static/src/js/widgets/debug_manager.js +++ b/addons/web/static/src/js/widgets/debug_manager.js @@ -848,7 +848,7 @@ if (config.debug) { if (action) { var controller = parent.getCurrentControllerInDialog(); self.debugManager = new DebugManager(self); - var $header = self.$modal.find('.modal-header:first'); + var $header = self.$modal.find('header.modal-header:first'); return self.debugManager.prependTo($header).then(function () { self.debugManager.update('action', action, controller.widget); }); diff --git a/addons/web/static/src/js/widgets/domain_selector_dialog.js b/addons/web/static/src/js/widgets/domain_selector_dialog.js index 46b54089c5f..d0c0030b100 100644 --- a/addons/web/static/src/js/widgets/domain_selector_dialog.js +++ b/addons/web/static/src/js/widgets/domain_selector_dialog.js @@ -43,7 +43,7 @@ return Dialog.extend({ var self = this; this.opened().then(function () { // this restores default modal height (bootstrap) and allows field selector to overflow - self.$el.css('overflow', 'visible').closest('.modal-dialog').css('height', 'auto'); + self.$el.css('overflow', 'visible').closest('[role="dialog"]').css('height', 'auto'); }); return $.when( this._super.apply(this, arguments), diff --git a/addons/web/static/src/scss/ui.scss b/addons/web/static/src/scss/ui.scss index 8911b4f10ab..d88f21f83a1 100644 --- a/addons/web/static/src/scss/ui.scss +++ b/addons/web/static/src/scss/ui.scss @@ -9,3 +9,7 @@ .ui-autocomplete { z-index: $zindex-modal + 1; } + +[aria-hidden="true"], [aria-hidden="1"] { + display: none!important; +} diff --git a/addons/web/static/src/scss/utils.scss b/addons/web/static/src/scss/utils.scss index efb8dce7598..cf04a8d32f8 100644 --- a/addons/web/static/src/scss/utils.scss +++ b/addons/web/static/src/scss/utils.scss @@ -337,6 +337,14 @@ } } } +.oe_kanban_color_help { + bottom: 0px; + left: -1px; + top: 0px; + width: 6px; + position: absolute; + right: auto; +} @mixin o-kanban-slim-col { position: relative; @include o-flex(0, 0, auto); diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 328f1ab93b5..45bd3555d69 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -6,13 +6,13 @@
-
+ -

+

-