Browse Source

Axe accessibility checker integration with floating dialog component (#9899)

Co-authored-by: Thibaud Colas <thibaudcolas@gmail.com>
Albina 2 years ago
parent
commit
55d04366f7

+ 1 - 1
CHANGELOG.txt

@@ -24,7 +24,7 @@ Changelog
  * Add a system check to warn when a `django-storages` backend is configured to allow overwriting (Rishabh jain)
  * Update admin focus outline color to have higher contrast against white backgrounds (Thibaud Colas)
  * Implement latest design for the admin dashboard header (Thibaud Colas, Steven Steinwand)
- * Add base Axe accessibility checker integration within userbar, with error count (Albina Starykova)
+ * Add base Axe accessibility checker integration within userbar, with count and list of errors (Albina Starykova)
  * Allow configuring Axe accessibility checker integration via `construct_wagtail_userbar` hook (Sage Abdullah)
  * Support pinning and un-pinning the rich text editor toolbar depending on user preference (Thibaud Colas)
  * Make the rich text block trigger and slash-commands always available regardless of where the cursor is (Thibaud Colas)

+ 14 - 1
client/scss/components/_dialog.scss

@@ -26,7 +26,6 @@
     width: 100%;
     position: relative;
     margin: auto;
-    overflow: hidden;
     max-width: theme('maxWidth.2xl');
     z-index: theme('zIndex.dialog');
     background: theme('colors.white.DEFAULT');
@@ -150,3 +149,17 @@
     margin-bottom: 0;
   }
 }
+
+// Variant of the dialog which allows viewing other page content.
+.w-dialog--floating {
+  pointer-events: none;
+
+  .w-dialog__box {
+    pointer-events: auto;
+    box-shadow: theme('boxShadow.md');
+  }
+
+  .w-dialog__overlay {
+    display: none;
+  }
+}

+ 98 - 5
client/scss/components/_userbar.scss

@@ -97,7 +97,7 @@ $positions: (
     display: flex;
     justify-content: center;
     align-items: center;
-    background-color: $color-button-no;
+    background-color: theme('colors.critical.200');
     border-radius: theme('borderRadius.full');
     color: $color-white;
     font-size: theme('fontSize.14');
@@ -203,10 +203,6 @@ $positions: (
     border-end-start-radius: $userbar-radius;
   }
 
-  & + & {
-    border-top: 1px solid $color-black;
-  }
-
   a,
   .w-action,
   button {
@@ -258,6 +254,95 @@ $positions: (
     width: 100%;
     background-color: transparent;
     outline: none;
+    display: flex;
+  }
+}
+
+.w-dialog--userbar {
+  // Display off to the side of the page rather than in the middle.
+  inset-inline-start: auto;
+  font-family: $font-sans;
+  padding-inline-end: 2rem;
+
+  .w-dialog__close-button {
+    $size: theme('spacing.6');
+    width: $size;
+    height: $size;
+    top: calc(-1 * $size / 2);
+    inset-inline-end: calc(-1 * $size / 2);
+    border-radius: theme('borderRadius.full');
+    border: 2px solid theme('colors.primary.DEFAULT');
+    background: theme('colors.white.DEFAULT');
+  }
+
+  .w-dialog__content {
+    padding: 0;
+    min-height: unset;
+  }
+
+  .w-dialog__title {
+    @apply w-h3;
+    padding: theme('spacing.4') theme('spacing.5');
+  }
+
+  .w-dialog__subtitle {
+    @apply w-body-text;
+    padding-inline-start: theme('spacing.5');
+    display: flex;
+    align-items: center;
+    gap: theme('spacing.2');
+  }
+
+  .w-a11y-result__row {
+    border-top: 1px solid theme('colors.grey.100');
+  }
+
+  .w-a11y-result__header {
+    margin: 0;
+    padding: theme('spacing.4') theme('spacing.5');
+    width: 100%;
+    display: flex;
+    justify-content: space-between;
+    gap: theme('spacing.2');
+    font: inherit;
+  }
+
+  .w-a11y-result__name {
+    color: theme('colors.primary.200');
+    font-weight: theme('fontWeight.bold');
+  }
+
+  .w-a11y-result__container {
+    padding: 0 theme('spacing.5') theme('spacing.5') theme('spacing.5');
+  }
+
+  .w-a11y-result__subtotal_count {
+    color: theme('colors.grey.600');
+  }
+
+  .w-a11y-result__selector {
+    background: theme('colors.grey.50');
+    color: theme('colors.grey.600');
+    border-radius: theme('borderRadius.DEFAULT');
+    margin-inline-end: 0.6rem;
+    margin-bottom: 0.6rem;
+    padding: 0.375rem;
+  }
+}
+
+.w-a11y-result__count {
+  margin-inline-end: 0.5em;
+  display: flex;
+  justify-content: center;
+  background-color: theme('colors.positive.100');
+  border-radius: theme('borderRadius.full');
+  font-size: theme('fontSize.14');
+  height: theme('spacing.5');
+  width: theme('spacing.5');
+  color: theme('colors.white.DEFAULT');
+
+  &.has-errors {
+    background-color: theme('colors.critical.200');
   }
 }
 
@@ -275,6 +360,14 @@ $positions: (
   .w-userbar-items::after {
     border: $width-arrow solid Canvas;
   }
+
+  .w-userbar-axe-count {
+    border: theme('spacing.px') solid ButtonText;
+  }
+
+  .w-a11y-result__selector {
+    border: theme('spacing.px') solid ButtonText;
+  }
 }
 
 // =============================================================================

+ 121 - 16
client/src/entrypoints/admin/userbar.js

@@ -1,5 +1,7 @@
 import axe from 'axe-core';
 
+import { dialog } from '../../includes/dialog';
+
 // This entrypoint is not bundled with any polyfills to keep it as light as possible
 // Please stick to old JS APIs and avoid importing anything that might require a vendored module
 // More background can be found in webpack.config.js
@@ -270,6 +272,9 @@ class Userbar extends HTMLElement {
     });
   }
 
+  // Integrating Axe accessibility checker to improve ATAG compliance, adapted for content authors to identify and fix accessibility issues.
+  // Scans loaded page for errors with 3 initial rules ('empty-heading', 'p-as-heading', 'heading-order') and outputs the results in GUI.
+  // See documentation: https://github.com/dequelabs/axe-core/tree/develop/doc
   getAxeConfiguration() {
     const script = this.shadowRoot.getElementById(
       'accessibility-axe-configuration',
@@ -282,7 +287,7 @@ class Userbar extends HTMLElement {
       console.error(err);
     }
 
-    // If the config fails to load, we won’t initialise Axe.
+    // Skip initialization of Axe if config fails to load
     return null;
   }
 
@@ -302,25 +307,125 @@ class Userbar extends HTMLElement {
       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);
 
-    // draft UI output for testing purposes
+    const a11yErrorsNumber = results.violations.reduce(
+      (sum, violation) => sum + violation.nodes.length,
+      0,
+    );
+
     if (results.violations.length) {
-      const axeCount = document.createElement('div');
-      axeCount.textContent = results.violations.length;
-      axeCount.classList.add('w-userbar-axe-count');
-      this.trigger.appendChild(axeCount);
-
-      const showAxeResults = () => {
-        results.violations.forEach((violation) => {
-          const annotation = document.createElement('div');
-          annotation.textContent =
-            config.messages[violation.id] || violation.description;
-          accessibilityTrigger.appendChild(annotation);
-        });
-      };
-      accessibilityTrigger.addEventListener('click', showAxeResults);
+      const a11yErrorBadge = document.createElement('span');
+      a11yErrorBadge.textContent = a11yErrorsNumber;
+      a11yErrorBadge.classList.add('w-userbar-axe-count');
+      this.trigger.appendChild(a11yErrorBadge);
     }
+
+    const dialogtemplates = document.querySelectorAll('[data-wagtail-dialog]');
+    const dialogs = dialog(dialogtemplates, this.shadowRoot);
+
+    if (!dialogs.length) {
+      return;
+    }
+    const modal = dialogs[0];
+    const modalBody = modal.$el.querySelector('[data-dialog-body]');
+    const accessibilityResultsBox = this.shadowRoot.querySelector(
+      '#accessibility-results',
+    );
+    const a11yRowTemplate = this.shadowRoot.querySelector(
+      '#w-a11y-result-row-template',
+    );
+    const a11ySelectorTemplate = this.shadowRoot.querySelector(
+      '#w-a11y-result-selector-template',
+    );
+
+    const modalErrorBadge = document.createElement('span');
+    modalErrorBadge.setAttribute('data-a11y-result-count', '');
+    modalErrorBadge.classList.add('w-a11y-result__count');
+    const headerElement = modal.$el.querySelector('.w-dialog__subtitle');
+    headerElement.appendChild(modalErrorBadge);
+
+    // Solution for future refactoring to move badges to Django template
+    const innerErrorBadges = this.shadowRoot.querySelectorAll(
+      '[data-a11y-result-count]',
+    );
+    innerErrorBadges.forEach((badge) => {
+      // eslint-disable-next-line no-param-reassign
+      badge.textContent = a11yErrorsNumber || '0';
+      if (results.violations.length) {
+        badge.classList.add('has-errors');
+      } else {
+        badge.classList.remove('has-errors');
+      }
+    });
+
+    const showAxeResults = () => {
+      modal.show();
+      modalBody.innerHTML = '';
+
+      if (results.violations.length) {
+        results.violations.forEach((violation, violationIndex) => {
+          modalBody.appendChild(a11yRowTemplate.content.cloneNode(true));
+          const currentA11yRow = modalBody.querySelectorAll(
+            '[data-a11y-result-row]',
+          )[violationIndex];
+
+          const a11yErrorName = currentA11yRow.querySelector(
+            '[data-a11y-result-name]',
+          );
+          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
+          a11yErrorName.textContent =
+            config.messages[violation.id] || violation.help;
+          const a11yErrorCount = currentA11yRow.querySelector(
+            '[data-a11y-result-count]',
+          );
+          a11yErrorCount.textContent = violation.nodes.length;
+
+          const a11yErrorContainer = currentA11yRow.querySelector(
+            '[data-a11y-result-container]',
+          );
+
+          violation.nodes.forEach((node, nodeIndex) => {
+            a11yErrorContainer.appendChild(
+              a11ySelectorTemplate.content.cloneNode(true),
+            );
+            const currentA11ySelector = a11yErrorContainer.querySelectorAll(
+              '[data-a11y-result-selector]',
+            )[nodeIndex];
+
+            currentA11ySelector.setAttribute(
+              'aria-describedby',
+              a11yErrorName.id,
+            );
+            // Remove unnecessary details before displaying selectors to the user
+            currentA11ySelector.textContent = `${node.target}`.replace(
+              /\[data-block-key="\w{5}"\]/,
+              '',
+            );
+            currentA11ySelector.addEventListener('click', () => {
+              const inaccessibleElement = document.querySelector(node.target);
+              inaccessibleElement.style.scrollMargin = '6.25rem';
+              inaccessibleElement.scrollIntoView({
+                behavior: 'smooth',
+              });
+              inaccessibleElement.focus();
+            });
+          });
+        });
+      }
+    };
+
+    const toggleAxeResults = () => {
+      if (accessibilityResultsBox.getAttribute('aria-hidden') === 'true') {
+        showAxeResults();
+      } else {
+        modal.hide();
+      }
+    };
+
+    accessibilityTrigger.addEventListener('click', toggleAxeResults);
   }
 }
 

+ 20 - 13
client/src/includes/dialog.js

@@ -1,28 +1,31 @@
 import A11yDialog from 'a11y-dialog';
 
 export const dialog = (
-  dialogs = document.querySelectorAll('[data-dialog]'),
+  dialogTemplates = document.querySelectorAll('[data-wagtail-dialog]'),
+  rootElement = document.body,
 ) => {
-  dialogs.forEach((template) => {
+  const dialogs = Array.from(dialogTemplates).map((template) => {
     const html = document.documentElement;
     const templateContent = template.content.firstElementChild;
 
-    const { dialogRootSelector } = templateContent.dataset;
+    const { dialogRootSelector, theme } = templateContent.dataset;
     const dialogRoot =
-      (dialogRootSelector && document.querySelector(dialogRootSelector)) ||
-      document.body;
+      (dialogRootSelector && rootElement.querySelector(dialogRootSelector)) ||
+      rootElement;
     dialogRoot.appendChild(templateContent);
 
     const dialogTemplate = new A11yDialog(templateContent);
 
-    // Prevent scrolling when dialog is open
-    dialogTemplate
-      .on('show', () => {
-        html.style.overflowY = 'hidden';
-      })
-      .on('hide', () => {
-        html.style.overflowY = '';
-      });
+    if (theme !== 'floating') {
+      // Prevent scrolling when dialog is open
+      dialogTemplate
+        .on('show', () => {
+          html.style.overflowY = 'hidden';
+        })
+        .on('hide', () => {
+          html.style.overflowY = '';
+        });
+    }
 
     // Attach event listeners to the dialog root (element with id), so it's
     // possible to show/close the dialog somewhere else with no access to the
@@ -33,5 +36,9 @@ export const dialog = (
     templateContent.addEventListener('wagtail:hide', () =>
       dialogTemplate.hide(),
     );
+
+    return dialogTemplate;
   });
+
+  return dialogs;
 };

+ 1 - 1
docs/releases/4.2.md

@@ -61,7 +61,7 @@ This feature was developed by Matt Westcott, and sponsored by [YouGov](https://y
  * Add a system check to warn when a `django-storages` backend is configured to allow overwriting (Rishabh jain)
  * Update admin focus outline color to have higher contrast against white backgrounds (Thibaud Colas)
  * Implement latest design for the admin dashboard header (Thibaud Colas, Steven Steinwand)
- * Add base Axe accessibility checker integration within userbar, with error count (Albina Starykova)
+ * Add base Axe accessibility checker integration within userbar, with count and list of errors (Albina Starykova)
  * Allow configuring Axe accessibility checker integration via `construct_wagtail_userbar` hook (Sage Abdullah)
 
 ### Bug fixes

+ 4 - 4
wagtail/admin/templates/wagtailadmin/icons/folder-inverse.svg

@@ -1,10 +1,10 @@
 {% load wagtailadmin_tags i18n %}
-<template data-dialog>
-    <div
-        id="{{ id }}"
+<template data-wagtail-dialog>
+    <div id="{{ id }}"
         aria-labelledby="title-{{ id }}"
         aria-hidden="true"
-        class="w-dialog{% if classname %} {{ classname }}{% endif %}"
+        class="w-dialog {% if theme %}w-dialog--{{ theme }}{% endif %} {% if classname %} {{ classname }}{% endif %}"
+        {% if theme %}data-theme="{{ theme }}"{% endif %}
         {% if dialog_root_selector %}
             data-dialog-root-selector="{{ dialog_root_selector }}"
         {% endif %}

+ 19 - 0
wagtail/admin/templates/wagtailadmin/userbar/base.html

@@ -35,6 +35,25 @@
         </div>
     </div>
 </template>
+{% trans 'Warnings' as dialog_title %}
+{% trans 'Issues found' as issues_found %}
+{% dialog id="accessibility-results" theme="floating" classname="w-dialog--userbar" title=dialog_title subtitle=issues_found %}
+    {# Contents of the dialog created in JS based on these templates. #}
+    <template id="w-a11y-result-row-template">
+        <div class="w-a11y-result__row" data-a11y-result-row>
+            <h3 class="w-a11y-result__header">
+                <span class="w-a11y-result__name" data-a11y-result-name></span>
+                <span class="w-sr-only">{% trans 'Issues found' %}</span><span class="w-a11y-result__subtotal_count" data-a11y-result-count></span>
+            </h3>
+            <div class="w-a11y-result__container" data-a11y-result-container></div>
+        </div>
+    </template>
+    <template id="w-a11y-result-selector-template">
+        <button class="w-a11y-result__selector" data-a11y-result-selector type="button">
+        </button>
+    </template>
+{% enddialog %}
 <wagtail-userbar></wagtail-userbar>
+<script src="{% versioned_static 'wagtailadmin/js/vendor.js' %}"></script>
 <script src="{% versioned_static 'wagtailadmin/js/userbar.js' %}"></script>
 <!-- end Wagtail user bar embed code -->

+ 1 - 1
wagtail/admin/templates/wagtailadmin/userbar/item_accessibility.html

@@ -3,7 +3,7 @@
 
 {% block item_content %}
     <button type="button" id="accessibility-trigger" role="menuitem">
-        {% icon name="tick" class_name="w-action-icon" %}
+        <span class="w-sr-only">{% trans 'Issues found' %}</span><span class="w-a11y-result__count" data-a11y-result-count></span>
         {% trans 'Accessibility' %}
     </button>
     {{ axe_configuration|json_script:"accessibility-axe-configuration" }}

+ 1 - 1
wagtail/admin/tests/test_userbar.py

@@ -163,7 +163,7 @@ class TestUserbarTag(TestCase, WagtailTestUtils):
             content,
         )
         # Should include the custom error message
-        self.assertIn("Use the correct heading order", content)
+        self.assertIn("Empty heading found", content)
 
 
 class TestUserbarFrontend(TestCase, WagtailTestUtils):

+ 9 - 3
wagtail/admin/userbar.py

@@ -46,9 +46,15 @@ class AccessibilityItem(BaseItem):
             },
             # Wagtail-specific translatable custom error messages.
             "messages": {
-                "empty-heading": _("Avoid empty headings"),
-                "heading-order": _("Use the correct heading order"),
-                "p-as-heading": _("Use heading elements for headings"),
+                "empty-heading": _(
+                    "Empty heading found. Use meaningful text in headings."
+                ),
+                "heading-order": _(
+                    "Incorrect heading hierarchy. Avoid skipping levels."
+                ),
+                "p-as-heading": _(
+                    "Misusing paragraphs as headings. Use proper heading tags."
+                ),
             },
         }
 

+ 7 - 0
wagtail/contrib/styleguide/templates/wagtailstyleguide/base.html

@@ -490,6 +490,13 @@
                         <p class="w-base-text">This dialog message was generated by passing message_status=critical as well as message_heading and message_description to the dialog template tag</p>
                     {% enddialog %}
                 </div>
+                <div>
+                    {% dialog_toggle classname='button button-primary' dialog_id='dialog-6' text='Floating dialog' %}
+
+                    {% dialog theme="floating" icon_name="doc-full-inverse" id="dialog-6" title="Floating dialog" subtitle="This is a testing subtitle" %}
+                        <p class="w-base-text">This dialog allows other page content to be seen</p>
+                    {% enddialog %}
+                </div>
             </div>
         </section>