summaryrefslogtreecommitdiff
path: root/odoo/tools/view_validation.py
diff options
context:
space:
mode:
authorkujiu <kujiu@users.noreply.github.com>2018-06-22 21:22:21 +0200
committerGitHub <noreply@github.com>2018-06-22 21:22:21 +0200
commit9de1bc0eef6f5bfaa2a8d745431caa361ae91548 (patch)
tree342ddbf7fb2b7303606f864ff9f68cbf1bd8814e /odoo/tools/view_validation.py
parent864357203fc6222cc989b99a5e9f428cd53f413f (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/view_validation.py')
-rw-r--r--odoo/tools/view_validation.py242
1 files changed, 239 insertions, 3 deletions
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