diff options
| author | kujiu <kujiu@users.noreply.github.com> | 2018-06-22 21:22:21 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2018-06-22 21:22:21 +0200 |
| commit | 9de1bc0eef6f5bfaa2a8d745431caa361ae91548 (patch) | |
| tree | 342ddbf7fb2b7303606f864ff9f68cbf1bd8814e /odoo/tools | |
| parent | 864357203fc6222cc989b99a5e9f428cd53f413f (diff) | |
[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
Diffstat (limited to 'odoo/tools')
| -rw-r--r-- | odoo/tools/translate.py | 5 | ||||
| -rw-r--r-- | odoo/tools/view_validation.py | 242 |
2 files changed, 243 insertions, 4 deletions
diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py index f69d37214bd..6b785c0d47a 100644 --- a/odoo/tools/translate.py +++ b/odoo/tools/translate.py @@ -143,9 +143,12 @@ TRANSLATED_ELEMENTS = { # which attributes must be translated TRANSLATED_ATTRS = { - 'string', 'help', 'sum', 'avg', 'confirm', 'placeholder', 'alt', 'title', + 'string', 'help', 'sum', 'avg', 'confirm', 'placeholder', 'alt', 'title', 'aria-label', + 'aria-keyshortcuts', 'aria-placeholder', 'aria-roledescription', 'aria-valuetext', } +TRANSLATED_ATTRS = TRANSLATED_ATTRS | {'t-attf-' + attr for attr in TRANSLATED_ATTRS} + avoid_pattern = re.compile(r"\s*<!DOCTYPE", re.IGNORECASE | re.MULTILINE | re.UNICODE) node_pattern = re.compile(r"<[^>]*>(.*)</[^<]*>", re.DOTALL | re.MULTILINE | re.UNICODE) diff --git a/odoo/tools/view_validation.py b/odoo/tools/view_validation.py index 1ffdd43e58b..7c69906220d 100644 --- a/odoo/tools/view_validation.py +++ b/odoo/tools/view_validation.py @@ -15,9 +15,13 @@ _relaxng_cache = {} def valid_view(arch): for pred in _validators[arch.tag]: - if not pred(arch): + check = pred(arch) + if not check: _logger.error("Invalid XML: %s", pred.__doc__) return False + if check == "Warning": + _logger.warning("Invalid XML: %s", pred.__doc__) + return "Warning" return True @@ -88,8 +92,8 @@ def valid_att_in_field(arch): @validate('form') def valid_att_in_label(arch): - """ ``label`` nodes must have a ``@for`` or a ``@string`` """ - return not arch.xpath('//label[not(@for or @string)]') + """ ``label`` nodes must have a ``@for`` """ + return not arch.xpath('//label[not(@for) and not(descendant::input)]') @validate('form') @@ -113,3 +117,235 @@ def valid_type_in_col(arch): attrib.isdigit() for attrib in arch.xpath('//@col') ) + +@validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree') +def valid_alternative_image_text(arch): + """An `img` tag must have an alt value.""" + if arch.xpath('//img[not(@alt or @t-att-alt or @t-attf-alt)]'): + return "Warning" + return True + +@validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree') +def valid_alternative_icon_text(arch): + """An icon with fa- class or in a button must have aria-label in its tag, parents, descendants or have text.""" + valid_aria_attrs = { + 'aria-label', 'aria-labelledby', 't-att-aria-label', 't-attf-aria-label', + 't-att-aria-labelledby', 't-attf-aria-labelledby'} + valid_t_attrs = {'t-value', 't-raw', 't-field', 't-esc'} + valid_attrs = valid_aria_attrs | valid_t_attrs + valid_aria_attrs_xpath = ' or '.join('@' + attr for attr in valid_aria_attrs) + valid_attrs_xpath = ' or '.join('@' + attr for attr in valid_attrs) + + # Select elements with class begining by 'fa-' + xpath = '(//*[contains(concat(" ", @class), " fa-")' + xpath += ' or contains(concat(" ", @t-att-class), " fa-")' + xpath += ' or contains(concat(" ", @t-attf-class), " fa-")]' + xpath += ' | //button[@icon])' + # Elements with accessibility or string attrs are good + xpath += '[not(' + valid_attrs_xpath + ')]' + # And we ignore all elements with describing in children + xpath += '[not(//*[' + valid_attrs_xpath + '])]' + # Aria label can be on ancestors + xpath += '[not(ancestor[' + valid_aria_attrs_xpath + '])]' + # Labels provide text by definition + xpath += '[not(descendant-or-self::label)]' + # Buttons can have a string attribute + xpath += '[not(descendant-or-self::button[@string])]' + # Fields have labels + xpath += '[not(descendant-or-self::field)]' + # And finally, if there is some text, it's good too + xpath += '[not(descendant-or-self::*[text()])]' + + if arch.xpath(xpath): + return "Warning" + return True + +@validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree') +def valid_title_icon(arch): + """An icon with fa- class or in a button must have title in its tag, parents, descendants or have text.""" + valid_title_attrs = {'title', 't-att-title', 't-attf-title'} + valid_t_attrs = {'t-value', 't-raw', 't-field', 't-esc'} + valid_attrs = valid_title_attrs | valid_t_attrs + valid_title_attrs_xpath = ' or '.join('@' + attr for attr in valid_title_attrs) + valid_attrs_xpath = ' or '.join('@' + attr for attr in valid_attrs) + + # Select elements with class begining by 'fa-' + xpath = '(//*[contains(concat(" ", @class), " fa-")' + xpath += ' or contains(concat(" ", @t-att-class), " fa-")' + xpath += ' or contains(concat(" ", @t-attf-class), " fa-")]' + xpath += ' | //button[@icon])' + # Elements with accessibility or string attrs are good + xpath += '[not(' + valid_attrs_xpath + ')]' + # And we ignore all elements with describing in children + xpath += '[not(//*[' + valid_attrs_xpath + '])]' + # Aria label can be on ancestors + xpath += '[not(ancestor[' + valid_title_attrs_xpath + '])]' + # Labels provide text by definition + xpath += '[not(descendant-or-self::label)]' + # Buttons can have a string attribute + xpath += '[not(descendant-or-self::button[@string])]' + # Fields have labels + xpath += '[not(descendant-or-self::field)]' + # And finally, if there is some text, it's good too + xpath += '[not(descendant-or-self::*[text()])]' + + if arch.xpath(xpath): + return "Warning" + return True + +@validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree') +def valid_simili_button(arch): + """A simili button must be tagged with "role='button'".""" + # Select elements with class 'btn' + xpath = '//a[contains(concat(" ", @class), " btn")' + xpath += ' or contains(concat(" ", @t-att-class), " btn")' + xpath += ' or contains(concat(" ", @t-attf-class), " btn")]' + xpath += '[not(@role="button")]' + if arch.xpath(xpath): + return "Warning" + return True + +@validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree') +def valid_simili_dropdown(arch): + """A simili dropdown must be tagged with "role='menu'".""" + xpath = '//*[contains(concat(" ", @class, " "), " dropdown-menu ")' + xpath += ' or contains(concat(" ", @t-att-class, " "), " dropdown-menu ")' + xpath += ' or contains(concat(" ", @t-attf-class, " "), " dropdown-menu ")]' + xpath += '[not(@role="menu")]' + if arch.xpath(xpath): + return "Warning" + return True + +@validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree') +def valid_simili_progressbar(arch): + """A simili progressbar must be tagged with "role='progressbar'" and have + aria-valuenow, aria-valuemin and aria-valuemax attributes.""" + # Select elements with class 'btn' + xpath = '//*[contains(concat(" ", @class, " "), " o_progressbar ")' + xpath += ' or contains(concat(" ", @t-att-class, " "), " o_progressbar ")' + xpath += ' or contains(concat(" ", @t-attf-class, " "), " o_progressbar ")]' + xpath += '[not(self::progress)]' + xpath += '[not(@role="progressbar")]' + xpath += '[not(@aria-valuenow or @t-att-aria-valuenow or @t-attf-aria-valuenow)]' + xpath += '[not(@aria-valuemin or @t-att-aria-valuemin or @t-attf-aria-valuemin)]' + xpath += '[not(@aria-valuemax or @t-att-aria-valuemax or @t-attf-aria-valuemax)]' + if arch.xpath(xpath): + return "Warning" + return True + +@validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree') +def valid_dialog(arch): + """A dialog must use role="dialog" and its header, body and footer contents must use <header/>, <main/> and <footer/>.""" + # Select elements with class 'btn' + xpath = '//*[contains(concat(" ", @class, " "), " modal ")' + xpath += ' or contains(concat(" ", @t-att-class, " "), " modal ")' + xpath += ' or contains(concat(" ", @t-attf-class, " "), " modal ")]' + xpath += '[not(@role="dialog")]' + if arch.xpath(xpath): + return "Warning" + + xpath = '//*[contains(concat(" ", @class, " "), " modal-header ")' + xpath += ' or contains(concat(" ", @t-att-class, " "), " modal-header ")' + xpath += ' or contains(concat(" ", @t-attf-class, " "), " modal-header ")]' + xpath += '[not(self::header)]' + if arch.xpath(xpath): + return "Warning" + + xpath = '//*[contains(concat(" ", @class, " "), " modal-body ")' + xpath += ' or contains(concat(" ", @t-att-class, " "), " modal-body ")' + xpath += ' or contains(concat(" ", @t-attf-class, " "), " modal-body ")]' + xpath += '[not(self::main)]' + if arch.xpath(xpath): + return "Warning" + + xpath = '//*[contains(concat(" ", @class, " "), " modal-footer ")' + xpath += ' or contains(concat(" ", @t-att-class, " "), " modal-footer ")' + xpath += ' or contains(concat(" ", @t-attf-class, " "), " modal-footer ")]' + xpath += '[not(self::footer)]' + if arch.xpath(xpath): + return "Warning" + return True + +@validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree') +def valid_simili_tabpanel(arch): + """A tab panel with tab-pane class must have role="tabpanel".""" + # Select elements with class 'btn' + xpath = '//*[contains(concat(" ", @class, " "), " tab-pane ")' + xpath += ' or contains(concat(" ", @t-att-class, " "), " tab-pane ")' + xpath += ' or contains(concat(" ", @t-attf-class, " "), " tab-pane ")]' + xpath += '[not(@role="tabpanel")]' + if arch.xpath(xpath): + return "Warning" + return True + +@validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree') +def valid_simili_tab(arch): + """A tab link must have role="tab", a link to an id (without #) by aria-controls.""" + # Select elements with class 'btn' + xpath = '//*[@data-toggle="tab"]' + xpath += '[not(@role="tab")' + xpath += 'or not(@aria-controls or @t-att-aria-controls or @t-attf-aria-controls)' + xpath += 'or contains(@aria-controls, "#") or contains(@t-att-aria-controls, "#")]' + if arch.xpath(xpath): + return "Warning" + return True + +@validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree') +def valid_simili_tablist(arch): + """A tab list with class nav-tabs must have role="tablist".""" + # Select elements with class 'btn' + xpath = '//*[contains(concat(" ", @class, " "), " nav-tabs ")' + xpath += ' or contains(concat(" ", @t-att-class, " "), " nav-tabs ")' + xpath += ' or contains(concat(" ", @t-attf-class, " "), " nav-tabs ")]' + xpath += '[not(@role="tablist")]' + if arch.xpath(xpath): + return "Warning" + return True + +@validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree') +def valid_focusable_button(arch): + """A simili button must be with a `button`, an `input` (with type `button`, `submit` or `reset`) or a `a` tag.""" + xpath = '//*[contains(concat(" ", @class), " btn")' + xpath += ' or contains(concat(" ", @t-att-class), " btn")' + xpath += ' or contains(concat(" ", @t-attf-class), " btn")]' + xpath += '[not(self::a)]' + xpath += '[not(self::button)]' + xpath += '[not(self::select)]' + xpath += '[not(self::input[@type="button"])]' + xpath += '[not(self::input[@type="submit"])]' + xpath += '[not(self::input[@type="reset"])]' + xpath += '[not(contains(@class, "btn-group"))]' + xpath += '[not(contains(@t-att-class, "btn-group"))]' + xpath += '[not(contains(@t-attf-class, "btn-group"))]' + xpath += '[not(contains(@class, "btn-toolbar"))]' + xpath += '[not(contains(@t-att-class, "btn-toolbar"))]' + xpath += '[not(contains(@t-attf-class, "btn-toolbar"))]' + xpath += '[not(contains(@class, "btn-ship"))]' + xpath += '[not(contains(@t-att-class, "btn-ship"))]' + xpath += '[not(contains(@t-attf-class, "btn-ship"))]' + if arch.xpath(xpath): + return "Warning" + return True + +@validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree') +def valid_prohibited_none_role(arch): + """A role can't be `none` or `presentation`. All your elements must be accessible with screen readers, describe it.""" + xpath = '//*[@role="none" or @role="presentation"]' + if arch.xpath(xpath): + return "Warning" + return True + +@validate('calendar', 'diagram', 'form', 'graph', 'kanban', 'pivot', 'search', 'tree') +def valid_alerts(arch): + """An alert (class alert-*) must have an alert, alertdialog or status role. Please use alert and alertdialog only for what expects to stop any activity to be read immediatly.""" + xpath = '//*[contains(concat(" ", @class), " alert-")' + xpath += ' or contains(concat(" ", @t-att-class), " alert-")' + xpath += ' or contains(concat(" ", @t-attf-class), " alert-")]' + xpath += '[not(contains(@class, "alert-link") or contains(@t-att-class, "alert-link")' + xpath += ' or contains(@t-attf-class, "alert-link"))]' + xpath += '[not(@role="alert")]' + xpath += '[not(@role="alertdialog")]' + xpath += '[not(@role="status")]' + if arch.xpath(xpath): + return "Warning" + return True |
