Browse Source

Improve customisability of the accessibility checker configuration. Fix #10137 (#10333)

* Extract userbar BaseItem.get_context_data()
* Extract Axe config into smaller attributes and methods for easier overrides
* Add TypeScript interface for WagtailAxeConfiguration
* Improve typings for userbar.ts
* Separate Axe `runOnly` and `rules` options
* Pass request object to all axe configuration methods
* Remove Axe runOnly option if it's falsy
* Add docs for customising the accessibility checker
* Use lists for Axe include and exclude selectors
* Parse JSON script when testing accessibility checker config
* Add tests for customising accessibility checker configuration

Co-authored-by: Thibaud Colas <thibaudcolas@gmail.com>
sag᠎e 1 year ago
parent
commit
ed36b5b9b6

+ 1 - 0
CHANGELOG.txt

@@ -50,6 +50,7 @@ Changelog
  * Allow `panels` / `edit_handler` to be specified via `SnippetViewSet` (Sage Abdullah)
  * Introduce dark mode support for the Wagtail admin interface, with a toggle in account preferences (Thibaud Colas)
  * Allow snippets to be registered into arbitrary admin menu items (Sage Abdullah)
+ * Add configuration APIs in user bar accessibility checker for simpler customisation of the checks performed
  * Fix: Ensure `label_format` on StructBlock gracefully handles missing variables (Aadi jindal)
  * Fix: Adopt a no-JavaScript and more accessible solution for the 'Reset to default' switch to Gravatar when editing user profile (Loveth Omokaro)
  * Fix: Ensure `Site.get_site_root_paths` works on cache backends that do not preserve Python objects (Jaap Roes)

+ 54 - 41
client/src/includes/userbar.ts

@@ -1,4 +1,4 @@
-import axe, { AxeResults, NodeResult } from 'axe-core';
+import axe, { ElementContext, NodeResult, Result, RunOptions } from 'axe-core';
 
 import { dialog } from './dialog';
 
@@ -11,6 +11,16 @@ This component implements a roving tab index for keyboard navigation
 Learn more about roving tabIndex: https://w3c.github.io/aria-practices/#kbd_roving_tabindex
 */
 
+/**
+ * Wagtail's Axe configuration object. This should reflect what's returned by
+ * `wagtail.admin.userbar.AccessibilityItem.get_axe_configuration()`.
+ */
+interface WagtailAxeConfiguration {
+  context: ElementContext;
+  options: RunOptions;
+  messages: Record<string, string>;
+}
+
 const sortAxeNodes = (nodeResultA?: NodeResult, nodeResultB?: NodeResult) => {
   if (!nodeResultA || !nodeResultB) return 0;
   const nodeA = document.querySelector<HTMLElement>(nodeResultA.target[0]);
@@ -23,7 +33,7 @@ const sortAxeNodes = (nodeResultA?: NodeResult, nodeResultB?: NodeResult) => {
     : -1;
 };
 
-export const sortAxeViolations = (violations: AxeResults['violations']) =>
+export const sortAxeViolations = (violations: Result[]) =>
   violations.sort((violationA, violationB) => {
     const earliestNodeA = violationA.nodes.sort(sortAxeNodes)[0];
     const earliestNodeB = violationB.nodes.sort(sortAxeNodes)[0];
@@ -51,7 +61,7 @@ export class Userbar extends HTMLElement {
     const trigger = userbar?.querySelector<HTMLElement>(
       '[data-wagtail-userbar-trigger]',
     );
-    const list = userbar?.querySelector('[role=menu]');
+    const list = userbar?.querySelector<HTMLUListElement>('[role=menu]');
 
     if (!userbar || !trigger || !list) {
       return;
@@ -74,7 +84,7 @@ export class Userbar extends HTMLElement {
     button:not([disabled]),
     input:not([disabled])`;
 
-    const showUserbar = (shouldFocus) => {
+    const showUserbar = (shouldFocus: boolean) => {
       userbar.classList.add(isActiveClass);
       trigger.setAttribute('aria-expanded', 'true');
       // eslint-disable-next-line @typescript-eslint/no-use-before-define
@@ -115,8 +125,8 @@ export class Userbar extends HTMLElement {
       userbar.removeEventListener('keydown', handleUserbarItemsKeyDown, false);
     };
 
-    const toggleUserbar = (e2) => {
-      e2.stopPropagation();
+    const toggleUserbar = (event: MouseEvent) => {
+      event.stopPropagation();
       if (userbar.classList.contains(isActiveClass)) {
         hideUserbar();
       } else {
@@ -137,7 +147,7 @@ export class Userbar extends HTMLElement {
     };
 
     // Focus element using a roving tab index
-    const focusElement = (el) => {
+    const focusElement = (el: HTMLElement) => {
       resetItemsTabIndex();
       // eslint-disable-next-line no-param-reassign
       el.tabIndex = 0;
@@ -154,13 +164,15 @@ export class Userbar extends HTMLElement {
 
     const setFocusToFirstItem = () => {
       if (listItems.length > 0) {
-        focusElement(listItems[0].firstElementChild);
+        focusElement(listItems[0].firstElementChild as HTMLElement);
       }
     };
 
     const setFocusToLastItem = () => {
       if (listItems.length > 0) {
-        focusElement(listItems[listItems.length - 1].firstElementChild);
+        focusElement(
+          listItems[listItems.length - 1].firstElementChild as HTMLElement,
+        );
       }
     };
 
@@ -169,7 +181,7 @@ export class Userbar extends HTMLElement {
         // Check which item is currently focused
         if (element.firstElementChild === shadowRoot.activeElement) {
           if (idx + 1 < listItems.length) {
-            focusElement(listItems[idx + 1].firstElementChild);
+            focusElement(listItems[idx + 1].firstElementChild as HTMLElement);
           } else {
             // Loop around
             setFocusToFirstItem();
@@ -183,7 +195,7 @@ export class Userbar extends HTMLElement {
         // Check which item is currently focused
         if (element.firstElementChild === shadowRoot.activeElement) {
           if (idx > 0) {
-            focusElement(listItems[idx - 1].firstElementChild);
+            focusElement(listItems[idx - 1].firstElementChild as HTMLElement);
           } else {
             setFocusToLastItem();
           }
@@ -199,7 +211,7 @@ export class Userbar extends HTMLElement {
     - Shifting focus using the arrow / home / end keys.
     - Closing the menu when 'Escape' is pressed.
     */
-    const handleUserbarItemsKeyDown = (event) => {
+    const handleUserbarItemsKeyDown = (event: KeyboardEvent) => {
       // Only handle keyboard input if the userbar is open
       if (trigger.getAttribute('aria-expanded') === 'true') {
         if (event.key === 'Escape') {
@@ -235,11 +247,11 @@ export class Userbar extends HTMLElement {
       return true;
     };
 
-    const handleFocusChange = (event) => {
+    const handleFocusChange = (event: FocusEvent) => {
       // Is the focus is still in the menu? If so, don't to anything
       if (
-        event.relatedTarget == null ||
-        (event.relatedTarget && event.relatedTarget.closest('.w-userbar-nav'))
+        !event.relatedTarget ||
+        (event.relatedTarget as HTMLElement).closest('.w-userbar-nav')
       ) {
         return;
       }
@@ -252,7 +264,7 @@ export class Userbar extends HTMLElement {
     This handler is responsible for opening the userbar with the arrow keys
     if it's focused and not open yet. It should always be listening.
     */
-    const handleTriggerKeyDown = (event) => {
+    const handleTriggerKeyDown = (event: KeyboardEvent) => {
       // Check if the userbar is focused (but not open yet) and should be opened by keyboard input
       if (
         trigger === document.activeElement &&
@@ -281,8 +293,8 @@ export class Userbar extends HTMLElement {
       }
     };
 
-    const sandboxClick = (e2) => {
-      e2.stopPropagation();
+    const sandboxClick = (event: MouseEvent) => {
+      event.stopPropagation();
     };
 
     const clickOutside = () => {
@@ -312,7 +324,7 @@ export class Userbar extends HTMLElement {
   See documentation: https://github.com/dequelabs/axe-core/tree/develop/doc
   */
 
-  getAxeConfiguration() {
+  getAxeConfiguration(): WagtailAxeConfiguration | null {
     const script = this.shadowRoot?.querySelector<HTMLScriptElement>(
       '#accessibility-axe-configuration',
     );
@@ -343,10 +355,7 @@ export class Userbar extends HTMLElement {
     if (!this.shadowRoot || !accessibilityTrigger || !config) return;
 
     // Initialise Axe based on the configurable context (whole page body by default) and options ('empty-heading', 'p-as-heading' and 'heading-order' rules by default)
-    const results = (await axe.run(
-      config.context,
-      config.options,
-    )) as unknown as AxeResults;
+    const results = await axe.run(config.context, config.options);
 
     const a11yErrorsNumber = results.violations.reduce(
       (sum, violation) => sum + violation.nodes.length,
@@ -360,9 +369,10 @@ export class Userbar extends HTMLElement {
       this.trigger.appendChild(a11yErrorBadge);
     }
 
-    const dialogTemplates = this.shadowRoot.querySelectorAll(
-      '[data-wagtail-dialog]',
-    );
+    const dialogTemplates =
+      this.shadowRoot.querySelectorAll<HTMLTemplateElement>(
+        '[data-wagtail-dialog]',
+      );
     const dialogs = dialog(
       dialogTemplates,
       this.shadowRoot as unknown as HTMLElement,
@@ -373,10 +383,11 @@ export class Userbar extends HTMLElement {
     // Disable TS linter check for legacy code in 3rd party `A11yDialog` element
     // eslint-disable-next-line @typescript-eslint/ban-ts-comment
     // @ts-ignore
-    const modalBody = modal.$el.querySelector('[data-dialog-body]');
-    const accessibilityResultsBox = this.shadowRoot.querySelector(
-      '#accessibility-results',
-    );
+    const modalBody = modal.$el.querySelector(
+      '[data-dialog-body]',
+    ) as HTMLDivElement;
+    const accessibilityResultsBox =
+      this.shadowRoot.querySelector<HTMLDivElement>('#accessibility-results');
     const a11yRowTemplate = this.shadowRoot.querySelector<HTMLTemplateElement>(
       '#w-a11y-result-row-template',
     );
@@ -398,7 +409,7 @@ export class Userbar extends HTMLElement {
       return;
     }
 
-    const innerErrorBadges = this.shadowRoot.querySelectorAll(
+    const innerErrorBadges = this.shadowRoot.querySelectorAll<HTMLSpanElement>(
       '[data-a11y-result-count]',
     );
     innerErrorBadges.forEach((badge) => {
@@ -420,33 +431,35 @@ export class Userbar extends HTMLElement {
         const sortedViolations = sortAxeViolations(results.violations);
         sortedViolations.forEach((violation, violationIndex) => {
           modalBody.appendChild(a11yRowTemplate.content.cloneNode(true));
-          const currentA11yRow = modalBody.querySelectorAll(
+          const currentA11yRow = modalBody.querySelectorAll<HTMLDivElement>(
             '[data-a11y-result-row]',
           )[violationIndex];
 
           const a11yErrorName = currentA11yRow.querySelector(
             '[data-a11y-result-name]',
-          );
+          ) as HTMLSpanElement;
           a11yErrorName.id = `w-a11y-result__name-${violationIndex}`;
-          // Display custom error messages for rules supported by Wagtail out of the box, fallback to default error message from Axe
+          // Display custom error messages supplied by Wagtail if available,
+          // fallback to default error message from Axe
           a11yErrorName.textContent =
             config.messages[violation.id] || violation.help;
           const a11yErrorCount = currentA11yRow.querySelector(
             '[data-a11y-result-count]',
-          );
-          a11yErrorCount.textContent = violation.nodes.length;
+          ) as HTMLSpanElement;
+          a11yErrorCount.textContent = `${violation.nodes.length}`;
 
           const a11yErrorContainer = currentA11yRow.querySelector(
             '[data-a11y-result-container]',
-          );
+          ) as HTMLDivElement;
 
           violation.nodes.forEach((node, nodeIndex) => {
             a11yErrorContainer.appendChild(
               a11ySelectorTemplate.content.cloneNode(true),
             );
-            const currentA11ySelector = a11yErrorContainer.querySelectorAll(
-              '[data-a11y-result-selector]',
-            )[nodeIndex];
+            const currentA11ySelector =
+              a11yErrorContainer.querySelectorAll<HTMLButtonElement>(
+                '[data-a11y-result-selector]',
+              )[nodeIndex];
 
             currentA11ySelector.setAttribute(
               'aria-describedby',
@@ -454,7 +467,7 @@ export class Userbar extends HTMLElement {
             );
             const currentA11ySelectorText = currentA11ySelector.querySelector(
               '[data-a11y-result-selector-text]',
-            );
+            ) as HTMLSpanElement;
             const selectorName = node.target[0];
             // Remove unnecessary details before displaying selectors to the user
             currentA11ySelectorText.textContent = selectorName.replace(

+ 66 - 0
docs/advanced_topics/accessibility_considerations.md

@@ -143,6 +143,72 @@ By default, the checker includes the following rules to find common accessibilit
 -   `link-name`: `<a>` link elements must always have a text label.
 -   `p-as-heading`: This rule checks for paragraphs that are styled as headings. Paragraphs should not be styled as headings, as they don’t help users who rely on headings to navigate content.
 
+To customise how the checker is run (e.g. the rules to test), you can define a custom subclass of {class}`~wagtail.admin.userbar.AccessibilityItem` and override the attributes to your liking. Then, swap the instance of the default `AccessibilityItem` with an instance of your custom class via the [`construct_wagtail_userbar`](construct_wagtail_userbar) hook.
+
+The following is the reference documentation for the `AccessibilityItem` class:
+
+```{eval-rst}
+.. autoclass:: wagtail.admin.userbar.AccessibilityItem
+
+    .. autoattribute:: axe_include
+    .. autoattribute:: axe_exclude
+    .. autoattribute:: axe_run_only
+       :no-value:
+    .. autoattribute:: axe_rules
+    .. autoattribute:: axe_messages
+       :no-value:
+
+    The above attributes can also be overridden via the following methods to allow per-request customisation.
+    When overriding these methods, be mindful of the mutability of the class attributes above.
+    To avoid unexpected behaviour, you should always return a new object instead of modifying the attributes
+    directly in the methods.
+
+    .. method:: get_axe_include(request)
+    .. method:: get_axe_exclude(request)
+    .. method:: get_axe_run_only(request)
+    .. method:: get_axe_rules(request)
+    .. method:: get_axe_messages(request)
+
+    For more advanced customisation, you can also override the following methods:
+
+    .. automethod:: get_axe_context
+    .. automethod:: get_axe_options
+```
+
+Here is an example of a custom `AccessibilityItem` subclass that enables more rules:
+
+```python
+from wagtail.admin.userbar import AccessibilityItem
+
+
+class CustomAccessibilityItem(AccessibilityItem):
+    # Run all rules with these tags
+    axe_run_only = [
+        "wcag2a",
+        "wcag2aa",
+        "wcag2aaa",
+        "wcag21a",
+        "wcag21aa",
+        "wcag22aa",
+        "best-practice",
+    ]
+    # Except for the color-contrast-enhanced rule
+    axe_rules = {
+        "color-contrast-enhanced": {"enabled": False},
+    }
+
+    def get_axe_rules(self, request):
+        # Do not disable any rules if the user is a superuser
+        if request.user.is_superuser:
+            return {}
+        return self.axe_rules
+
+
+@hooks.register('construct_wagtail_userbar')
+def replace_userbar_accessibility_item(request, items):
+    items[:] = [CustomAccessibilityItem() if isinstance(item, AccessibilityItem) else item for item in items]
+```
+
 ### wagtail-accessibility
 
 [wagtail-accessibility](https://github.com/neon-jungle/wagtail-accessibility) is a third-party package which adds [tota11y](https://khan.github.io/tota11y/) to Wagtail previews.

+ 1 - 1
docs/reference/hooks.md

@@ -834,7 +834,7 @@ def remove_page_listing_button_item(buttons, page, page_perms, context=None):
 
 ### `construct_wagtail_userbar`
 
-Add or remove items from the wagtail userbar. Add, edit, and moderation tools are provided by default. The callable passed into the hook must take the `request` object and a list of menu objects, `items`. The menu item objects must have a `render` method which can take a `request` object and return the HTML string representing the menu item. See the userbar templates and menu item classes for more information.
+Add or remove items from the Wagtail [user bar](wagtailuserbar_tag). Add, edit, and moderation tools are provided by default. The callable passed into the hook must take the `request` object and a list of menu objects, `items`. The menu item objects must have a `render` method which can take a `request` object and return the HTML string representing the menu item. See the userbar templates and menu item classes for more information. See also the {class}`~wagtail.admin.userbar.AccessibilityItem` class for the accessibility checker item in particular.
 
 ```python
 from wagtail import hooks

+ 5 - 4
docs/releases/5.0.md

@@ -45,11 +45,12 @@ This has been made possible thanks to a multi-year refactoring effort to migrate
 
 ### Accessibility checker improvements
 
-The accessibility checker has been updated with:
+The [built-in accessibility checker](authoring_accessible_content) has been updated with:
 
- * 5 more Axe rules enabled by default
- * Sorting of checker results according to their position on the page
- * Highlight styles to more easily identify elements with errors
+ * 5 more Axe rules enabled by default.
+ * Sorting of checker results according to their position on the page.
+ * Highlight styles to more easily identify elements with errors.
+ * Configuration APIs in {class}`~wagtail.admin.userbar.AccessibilityItem` for simpler customisation of the checks performed.
 
 Those improvements were implemented by Albina Starykova as part of an [Outreachy internship](https://wagtail.org/blog/introducing-wagtails-new-accessibility-checker/), with support from mentors Thibaud Colas, Sage Abdullah, and Joshua Munn.
 

+ 7 - 5
docs/topics/writing_templates.md

@@ -231,20 +231,20 @@ Returns the Site object corresponding to the current request.
 
 (wagtailuserbar_tag)=
 
-## Wagtail User Bar
+## Wagtail user bar
 
 This tag provides a contextual flyout menu for logged-in users. The menu gives editors the ability to edit the current page or add a child page, besides the options to show the page in the Wagtail page explorer or jump to the Wagtail admin dashboard. Moderators are also given the ability to accept or reject a page being previewed as part of content moderation.
 
 This tag may be used on standard Django views, without page object. The user bar will contain one item pointing to the admin.
 
-We recommend putting the tag near the top of the `<body>` element to allow keyboard users to reach it. You should consider putting the tag after any [skip links](https://webaim.org/techniques/skipnav/)` but before the navigation and main content of your page.
+We recommend putting the tag near the top of the `<body>` element to allow keyboard users to reach it. You should consider putting the tag after any [skip links](https://webaim.org/techniques/skipnav/) but before the navigation and main content of your page.
 
 ```html+django
 {% load wagtailuserbar %}
 ...
 <body>
     <a id="#content">Skip to content</a>
-    {% wagtailuserbar %} {# This is a good place for the userbar #}
+    {% wagtailuserbar %} {# This is a good place for the user bar #}
     <nav>
     ...
     </nav>
@@ -254,7 +254,7 @@ We recommend putting the tag near the top of the `<body>` element to allow keybo
 </body>
 ```
 
-By default, the User Bar appears in the bottom right of the browser window, inset from the edge. If this conflicts with your design it can be moved by passing a parameter to the template tag. These examples show you how to position the userbar in each corner of the screen:
+By default, the user bar appears in the bottom right of the browser window, inset from the edge. If this conflicts with your design it can be moved by passing a parameter to the template tag. These examples show you how to position the user bar in each corner of the screen:
 
 ```html+django
 ...
@@ -265,7 +265,7 @@ By default, the User Bar appears in the bottom right of the browser window, inse
 ...
 ```
 
-The userbar can be positioned where it works best with your design. Alternatively, you can position it with a CSS rule in your own CSS files, for example:
+The user bar can be positioned where it works best with your design. Alternatively, you can position it with a CSS rule in your own CSS files, for example:
 
 ```css
 wagtail-userbar::part(userbar) {
@@ -273,6 +273,8 @@ wagtail-userbar::part(userbar) {
 }
 ```
 
+To customise the items shown in the user bar, you can use the [`construct_wagtail_userbar`](construct_wagtail_userbar) hook.
+
 ## Varying output between preview and live
 
 Sometimes you may wish to vary the template output depending on whether the page is being previewed or viewed live. For example, if you have visitor-tracking code such as Google Analytics in place on your site, it's a good idea to leave this out when previewing, so that editor activity doesn't appear in your analytics reports. Wagtail provides a `request.is_preview` variable to distinguish between preview and live:

+ 179 - 17
wagtail/admin/tests/test_userbar.py

@@ -1,9 +1,14 @@
-from django.contrib.auth.models import AnonymousUser
+import json
+
+from bs4 import BeautifulSoup
+from django.contrib.auth.models import AnonymousUser, Permission
 from django.template import Context, Template
 from django.test import TestCase
-from django.test.client import RequestFactory
 from django.urls import reverse
 
+from wagtail import hooks
+from wagtail.admin.userbar import AccessibilityItem
+from wagtail.coreutils import get_dummy_request
 from wagtail.models import PAGE_TEMPLATE_VAR, Page
 from wagtail.test.testapp.models import BusinessChild, BusinessIndex
 from wagtail.test.utils import WagtailTestUtils
@@ -25,7 +30,7 @@ class TestUserbarTag(WagtailTestUtils, TestCase):
         revision_id=None,
         is_editing=False,
     ):
-        request = RequestFactory().get("/")
+        request = get_dummy_request()
         request.user = user or AnonymousUser()
         request.is_preview = is_preview
         request.is_editing = is_editing
@@ -178,24 +183,181 @@ class TestUserbarTag(WagtailTestUtils, TestCase):
         # Make sure nothing was rendered
         self.assertEqual(content, "")
 
-    def test_userbar_accessibility_configuration(self):
+
+class TestAccessibilityCheckerConfig(WagtailTestUtils, TestCase):
+    def setUp(self):
+        self.user = self.login()
+        self.request = get_dummy_request()
+        self.request.user = self.user
+
+    def get_script(self):
         template = Template("{% load wagtailuserbar %}{% wagtailuserbar %}")
-        content = template.render(
-            Context(
-                {
-                    PAGE_TEMPLATE_VAR: self.homepage,
-                    "request": self.dummy_request(self.user),
-                }
-            )
-        )
+        content = template.render(Context({"request": self.request}))
+        soup = BeautifulSoup(content, "html.parser")
 
         # Should include the configuration as a JSON script with the specific id
-        self.assertIn(
-            '<script id="accessibility-axe-configuration" type="application/json">',
-            content,
+        return soup.find("script", id="accessibility-axe-configuration")
+
+    def get_config(self):
+        return json.loads(self.get_script().string)
+
+    def get_hook(self, item_class):
+        def customise_accessibility_checker(request, items):
+            items[:] = [
+                item_class() if isinstance(item, AccessibilityItem) else item
+                for item in items
+            ]
+
+        return customise_accessibility_checker
+
+    def test_config_json(self):
+        script = self.get_script()
+        # The configuration should be a valid non-empty JSON script
+        self.assertIsNotNone(script)
+        self.assertEqual(script.attrs["type"], "application/json")
+        config_string = script.string.strip()
+        self.assertGreater(len(config_string), 0)
+        config = json.loads(config_string)
+        self.assertIsInstance(config, dict)
+        self.assertGreater(len(config.keys()), 0)
+
+    def test_messages(self):
+        # Should include Wagtail's error messages
+        config = self.get_config()
+        self.assertIsInstance(config.get("messages"), dict)
+        self.assertEqual(
+            config["messages"]["empty-heading"],
+            "Empty heading found. Use meaningful text for screen reader users.",
         )
-        # Should include the custom error message
-        self.assertIn("Empty heading found", content)
+
+    def test_custom_message(self):
+        class CustomMessageAccessibilityItem(AccessibilityItem):
+            # Override via class attribute
+            axe_messages = {
+                "empty-heading": "Headings should not be empty!",
+            }
+
+            # Override via method
+            def get_axe_messages(self, request):
+                return {
+                    **super().get_axe_messages(request),
+                    "color-contrast-enhanced": "Increase colour contrast!",
+                }
+
+        with hooks.register_temporarily(
+            "construct_wagtail_userbar",
+            self.get_hook(CustomMessageAccessibilityItem),
+        ):
+            config = self.get_config()
+            self.assertEqual(
+                config["messages"],
+                {
+                    "empty-heading": "Headings should not be empty!",
+                    "color-contrast-enhanced": "Increase colour contrast!",
+                },
+            )
+
+    def test_unset_run_only(self):
+        class UnsetRunOnlyAccessibilityItem(AccessibilityItem):
+            # Example config that unsets the runOnly property so that all
+            # non-experimental rules are run, but the experimental
+            # focus-order-semantics rule is explicitly enabled
+            axe_run_only = None
+            axe_rules = {"focus-order-semantics": {"enabled": True}}
+
+        with hooks.register_temporarily(
+            "construct_wagtail_userbar",
+            self.get_hook(UnsetRunOnlyAccessibilityItem),
+        ):
+            config = self.get_config()
+            self.assertEqual(
+                config["options"],
+                # Should not include the runOnly property, but should include
+                # the focus-order-semantics rule
+                {"rules": {"focus-order-semantics": {"enabled": True}}},
+            )
+
+    def test_custom_context(self):
+        class CustomContextAccessibilityItem(AccessibilityItem):
+            axe_include = ["article", "section"]
+            axe_exclude = [".sr-only"]
+
+            def get_axe_exclude(self, request):
+                return [*super().get_axe_exclude(request), "[data-please-ignore]"]
+
+        with hooks.register_temporarily(
+            "construct_wagtail_userbar",
+            self.get_hook(CustomContextAccessibilityItem),
+        ):
+            config = self.get_config()
+            self.assertEqual(
+                config["context"],
+                {
+                    # Override via class attribute
+                    "include": ["article", "section"],
+                    "exclude": [
+                        # Override via class attribute
+                        ".sr-only",
+                        # Should include the default exclude selectors
+                        {"fromShadowDOM": ["wagtail-userbar"]},
+                        # Override via method
+                        "[data-please-ignore]",
+                    ],
+                },
+            )
+
+    def test_custom_run_only_and_rules_per_request(self):
+        class CustomRunOnlyAccessibilityItem(AccessibilityItem):
+            # Enable all rules within these tags
+            axe_run_only = [
+                "wcag2a",
+                "wcag2aa",
+                "wcag2aaa",
+                "wcag21a",
+                "wcag21aa",
+                "wcag22aa",
+                "best-practice",
+            ]
+            # Turn off the color-contrast-enhanced rule
+            axe_rules = {
+                "color-contrast-enhanced": {"enabled": False},
+            }
+
+            def get_axe_rules(self, request):
+                # Do not turn off any rules for superusers
+                if request.user.is_superuser:
+                    return {}
+                return super().get_axe_rules(request)
+
+        with hooks.register_temporarily(
+            "construct_wagtail_userbar",
+            self.get_hook(CustomRunOnlyAccessibilityItem),
+        ):
+            config = self.get_config()
+            self.assertEqual(
+                config["options"],
+                {
+                    "runOnly": CustomRunOnlyAccessibilityItem.axe_run_only,
+                    "rules": {},
+                },
+            )
+
+            self.user.is_superuser = False
+            self.user.user_permissions.add(
+                Permission.objects.get(
+                    content_type__app_label="wagtailadmin", codename="access_admin"
+                )
+            )
+            self.user.save()
+
+            config = self.get_config()
+            self.assertEqual(
+                config["options"],
+                {
+                    "runOnly": CustomRunOnlyAccessibilityItem.axe_run_only,
+                    "rules": CustomRunOnlyAccessibilityItem.axe_rules,
+                },
+            )
 
 
 class TestUserbarFrontend(WagtailTestUtils, TestCase):

+ 126 - 60
wagtail/admin/userbar.py

@@ -5,9 +5,12 @@ from django.utils.translation import gettext_lazy as _
 class BaseItem:
     template = "wagtailadmin/userbar/item_base.html"
 
+    def get_context_data(self, request):
+        return {"self": self, "request": request}
+
     def render(self, request):
         return render_to_string(
-            self.template, {"self": self, "request": request}, request=request
+            self.template, self.get_context_data(request), request=request
         )
 
 
@@ -24,75 +27,138 @@ class AdminItem(BaseItem):
 
 
 class AccessibilityItem(BaseItem):
+    """A userbar item that runs the accessibility checker."""
+
+    #: The template to use for rendering the item.
     template = "wagtailadmin/userbar/item_accessibility.html"
 
-    def get_axe_configuration(self):
+    #: A list of CSS selector(s) to test specific parts of the page.
+    #: For more details, see `Axe documentation <https://github.com/dequelabs/axe-core/blob/master/doc/context.md#the-include-property>`__.
+    axe_include = ["body"]
+
+    #: A list of CSS selector(s) to exclude specific parts of the page from testing.
+    #: For more details, see `Axe documentation <https://github.com/dequelabs/axe-core/blob/master/doc/context.md#exclude-elements-from-test>`__.
+    axe_exclude = []
+
+    # Make sure that the userbar is not tested.
+    _axe_default_exclude = [{"fromShadowDOM": ["wagtail-userbar"]}]
+
+    #: A list of `axe-core tags <https://github.com/dequelabs/axe-core/blob/master/doc/API.md#axe-core-tags>`_
+    #: or a list of `axe-core rule IDs <https://github.com/dequelabs/axe-core/blob/master/doc/rule-descriptions.md>`_
+    #: (not a mix of both).
+    #: Setting this to a falsy value (e.g. ``None``) will omit the ``runOnly`` option and make Axe run with all non-experimental rules enabled.
+    axe_run_only = [
+        "button-name",
+        "empty-heading",
+        "empty-table-header",
+        "frame-title",
+        "heading-order",
+        "input-button-name",
+        "link-name",
+        "p-as-heading",
+    ]
+
+    #: A dictionary that maps axe-core rule IDs to a dictionary of rule options,
+    #: commonly in the format of ``{"enabled": True/False}``. This can be used in
+    #: conjunction with :attr:`axe_run_only` to enable or disable specific rules.
+    #: For more details, see `Axe documentation <https://github.com/dequelabs/axe-core/blob/master/doc/API.md#options-parameter-examples>`__.
+    axe_rules = {}
+
+    #: A dictionary that maps axe-core rule IDs to custom translatable strings
+    #: to use as the error messages. If an enabled rule does not exist in this
+    #: dictionary, Axe's error message for the rule will be used as fallback.
+    axe_messages = {
+        "button-name": _(
+            "Button text is empty. Use meaningful text for screen reader users."
+        ),
+        "empty-heading": _(
+            "Empty heading found. Use meaningful text for screen reader users."
+        ),
+        "empty-table-header": _(
+            "Table header text is empty. Use meaningful text for screen reader users."
+        ),
+        "frame-title": _(
+            "Empty frame title found. Use a meaningful title for screen reader users."
+        ),
+        "heading-order": _("Incorrect heading hierarchy. Avoid skipping levels."),
+        "input-button-name": _(
+            "Input button text is empty. Use meaningful text for screen reader users."
+        ),
+        "link-name": _(
+            "Link text is empty. Use meaningful text for screen reader users."
+        ),
+        "p-as-heading": _("Misusing paragraphs as headings. Use proper heading tags."),
+    }
+
+    def get_axe_include(self, request):
+        """Returns a list of CSS selector(s) to test specific parts of the page."""
+        return self.axe_include
+
+    def get_axe_exclude(self, request):
+        """Returns a list of CSS selector(s) to exclude specific parts of the page from testing."""
+        return self.axe_exclude + self._axe_default_exclude
+
+    def get_axe_run_only(self, request):
+        """Returns a list of axe-core tags or a list of axe-core rule IDs (not a mix of both)."""
+        return self.axe_run_only
+
+    def get_axe_rules(self, request):
+        """Returns a dictionary that maps axe-core rule IDs to a dictionary of rule options."""
+        return self.axe_rules
+
+    def get_axe_messages(self, request):
+        """Returns a dictionary that maps axe-core rule IDs to custom translatable strings."""
+        return self.axe_messages
+
+    def get_axe_context(self, request):
+        """
+        Returns the `context object <https://github.com/dequelabs/axe-core/blob/develop/doc/context.md>`_
+        to be passed as the
+        `context parameter <https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#context-parameter>`_
+        for ``axe.run``.
+        """
         return {
-            # See https://github.com/dequelabs/axe-core/blob/develop/doc/context.md.
-            "context": {
-                "include": "body",
-                "exclude": {"fromShadowDOM": ["wagtail-userbar"]},
-            },
-            # See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#options-parameter.
-            "options": {
-                "runOnly": {
-                    "type": "rule",
-                    "values": [
-                        "button-name",
-                        "empty-heading",
-                        "empty-table-header",
-                        "frame-title",
-                        "heading-order",
-                        "input-button-name",
-                        "link-name",
-                        "p-as-heading",
-                    ],
-                }
-            },
-            # Wagtail-specific translatable custom error messages.
-            "messages": {
-                "button-name": _(
-                    "Button text is empty. Use meaningful text for screen reader users."
-                ),
-                "empty-heading": _(
-                    "Empty heading found. Use meaningful text for screen reader users."
-                ),
-                "empty-table-header": _(
-                    "Table header text is empty. Use meaningful text for screen reader users."
-                ),
-                "frame-title": _(
-                    "Empty frame title found. Use a meaningful title for screen reader users."
-                ),
-                "heading-order": _(
-                    "Incorrect heading hierarchy. Avoid skipping levels."
-                ),
-                "input-button-name": _(
-                    "Input button text is empty. Use meaningful text for screen reader users."
-                ),
-                "link-name": _(
-                    "Link text is empty. Use meaningful text for screen reader users."
-                ),
-                "p-as-heading": _(
-                    "Misusing paragraphs as headings. Use proper heading tags."
-                ),
-            },
+            "include": self.get_axe_include(request),
+            "exclude": self.get_axe_exclude(request),
         }
 
-    def render(self, request):
+    def get_axe_options(self, request):
+        """
+        Returns the options object to be passed as the
+        `options parameter <https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#options-parameter>`_
+        for ``axe.run``.
+        """
+        options = {
+            "runOnly": self.get_axe_run_only(request),
+            "rules": self.get_axe_rules(request),
+        }
+        # If the runOnly option is omitted, Axe will run all rules except those
+        # with the "experimental" flag or that are disabled in the rules option.
+        # The runOnly has to be omitted (instead of set to an empty list or null)
+        # for this to work, so we remove it if it's falsy.
+        if not options["runOnly"]:
+            options.pop("runOnly")
+        return options
+
+    def get_axe_configuration(self, request):
+        return {
+            "context": self.get_axe_context(request),
+            "options": self.get_axe_options(request),
+            "messages": self.get_axe_messages(request),
+        }
 
+    def get_context_data(self, request):
+        return {
+            **super().get_context_data(request),
+            "axe_configuration": self.get_axe_configuration(request),
+        }
+
+    def render(self, request):
         # Don't render if user doesn't have permission to access the admin area
         if not request.user.has_perm("wagtailadmin.access_admin"):
             return ""
 
-        return render_to_string(
-            self.template,
-            {
-                "self": self,
-                "request": request,
-                "axe_configuration": self.get_axe_configuration(),
-            },
-            request=request,
-        )
+        return super().render(request)
 
 
 class AddPageItem(BaseItem):