Browse Source

Move accessibility checker inside the editor (#11478). Fix #10136

Thibaud Colas 1 year ago
parent
commit
4790b44ba1

+ 1 - 1
CHANGELOG.txt

@@ -46,7 +46,7 @@ Changelog
  * Replace legacy dropdown component with new Tippy dropdown-button (Thibaud Colas)
  * Add ability to filter by existence of child pages in the page listing view (Matt Westcott)
  * Polish dark theme styles and update color tokens (Thibaud Colas, Rohit Sharma)
- * Keep database state of pages and snippets updated while in draft state (Stefan Hammer)
+ * Add the accessibility checker within the page and snippets editor (Thibaud Colas)
  * Fix: Update system check for overwriting storage backends to recognise the `STORAGES` setting introduced in Django 4.2 (phijma-leukeleu)
  * Fix: Prevent password change form from raising a validation error when browser autocomplete fills in the "Old password" field (Chiemezuo Akujobi)
  * Fix: Ensure that the legacy dropdown options, when closed, do not get accidentally clicked by other interactions wide viewports (CheesyPhoenix, Christer Jensen)

+ 85 - 0
client/scss/components/_a11y-result.scss

@@ -0,0 +1,85 @@
+.w-a11y-result__row {
+  border-top: 1px solid theme('colors.border-furniture');
+}
+
+.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;
+  font-weight: theme('fontWeight.bold');
+}
+
+.w-a11y-result__name {
+  color: theme('colors.text-label');
+}
+
+.w-a11y-result__container {
+  display: flex;
+  flex-wrap: wrap;
+  padding: 0 theme('spacing.5') theme('spacing.5') theme('spacing.5');
+}
+
+.w-a11y-result__subtotal_count {
+  color: theme('colors.text-context');
+  width: theme('spacing.5');
+  text-align: center;
+}
+
+.w-a11y-result__selector {
+  display: flex;
+  align-items: center;
+  background: theme('colors.surface-field-inactive');
+  color: theme('colors.text-context');
+  border-radius: theme('borderRadius.DEFAULT');
+  margin-inline-end: theme('spacing.[2.5]');
+  margin-bottom: theme('spacing.[2.5]');
+  padding: theme('spacing.[1.5]');
+
+  &:hover,
+  &:focus {
+    background: theme('colors.surface-button-default');
+    color: theme('colors.text-button');
+
+    .w-a11y-result__icon {
+      fill: theme('colors.text-button');
+    }
+  }
+
+  @media (forced-colors: active) {
+    border: theme('spacing.px') solid ButtonText;
+  }
+}
+
+.w-a11y-result__icon {
+  flex-shrink: 0;
+  fill: theme('colors.surface-button-default');
+  height: theme('spacing.[3.5]');
+  width: theme('spacing.[3.5]');
+  margin-inline-end: theme('spacing.[2.5]');
+}
+
+.w-a11y-result__count {
+  display: flex;
+  flex-shrink: 0;
+  justify-content: center;
+  align-items: center;
+  background-color: theme('colors.positive.100');
+  border-radius: theme('borderRadius.full');
+  font-size: theme('fontSize.14');
+  line-height: theme('lineHeight.none');
+  height: theme('spacing.5');
+  width: theme('spacing.5');
+  color: theme('colors.text-button');
+
+  &.has-errors {
+    background-color: theme('colors.critical.200');
+  }
+
+  @media (forced-colors: active) {
+    border: theme('spacing.px') solid ButtonText;
+  }
+}

+ 0 - 86
client/scss/components/_userbar.scss

@@ -261,92 +261,6 @@ $positions: (
     gap: theme('spacing.2');
     margin-bottom: 0;
   }
-
-  .w-a11y-result__row {
-    border-top: 1px solid theme('colors.border-furniture');
-  }
-
-  .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;
-    font-weight: theme('fontWeight.bold');
-  }
-
-  .w-a11y-result__name {
-    color: theme('colors.text-label');
-  }
-
-  .w-a11y-result__container {
-    display: flex;
-    flex-wrap: wrap;
-    padding: 0 theme('spacing.5') theme('spacing.5') theme('spacing.5');
-  }
-
-  .w-a11y-result__subtotal_count {
-    color: theme('colors.text-context');
-    width: theme('spacing.5');
-    text-align: center;
-  }
-
-  .w-a11y-result__selector {
-    display: flex;
-    align-items: center;
-    background: theme('colors.surface-field-inactive');
-    color: theme('colors.text-context');
-    border-radius: theme('borderRadius.DEFAULT');
-    margin-inline-end: theme('spacing.[2.5]');
-    margin-bottom: theme('spacing.[2.5]');
-    padding: theme('spacing.[1.5]');
-
-    &:hover,
-    &:focus {
-      background: theme('colors.surface-button-default');
-      color: theme('colors.text-button');
-
-      .w-a11y-result__icon {
-        fill: theme('colors.text-button');
-      }
-    }
-
-    @media (forced-colors: active) {
-      border: theme('spacing.px') solid ButtonText;
-    }
-  }
-
-  .w-a11y-result__icon {
-    flex-shrink: 0;
-    fill: theme('colors.surface-button-default');
-    height: theme('spacing.[3.5]');
-    width: theme('spacing.[3.5]');
-    margin-inline-end: theme('spacing.[2.5]');
-  }
-}
-
-.w-a11y-result__count {
-  display: flex;
-  flex-shrink: 0;
-  justify-content: center;
-  align-items: center;
-  background-color: theme('colors.positive.100');
-  border-radius: theme('borderRadius.full');
-  font-size: theme('fontSize.14');
-  line-height: theme('lineHeight.none');
-  height: theme('spacing.5');
-  width: theme('spacing.5');
-  color: theme('colors.text-button');
-
-  &.has-errors {
-    background-color: theme('colors.critical.200');
-  }
-
-  @media (forced-colors: active) {
-    border: theme('spacing.px') solid ButtonText;
-  }
 }
 
 //Media for Windows High Contrast

+ 1 - 0
client/scss/core.scss

@@ -140,6 +140,7 @@ These are classes for components.
 @import 'components/preview-panel';
 @import 'components/preview-error';
 @import 'components/form-side';
+@import 'components/a11y-result';
 @import 'components/userbar';
 @import 'components/breadcrumbs';
 @import 'components/pill';

+ 82 - 0
client/src/entrypoints/admin/preview-panel.js

@@ -1,11 +1,75 @@
+import axe from 'axe-core';
+import {
+  getAxeConfiguration,
+  renderA11yResults,
+} from '../../includes/a11y-result';
 import { WAGTAIL_CONFIG } from '../../config/wagtailConfig';
 import { debounce } from '../../utils/debounce';
 import { gettext } from '../../utils/gettext';
 
+const runAccessibilityChecks = async (onClickSelector) => {
+  const a11yRowTemplate = document.querySelector('#w-a11y-result-row-template');
+  const a11ySelectorTemplate = document.querySelector(
+    '#w-a11y-result-selector-template',
+  );
+  const checksPanel = document.querySelector('[data-checks-panel]');
+  const config = getAxeConfiguration(document.body);
+  const toggleCounter = document.querySelector(
+    '[data-side-panel-toggle="checks"] [data-side-panel-toggle-counter]',
+  );
+  const panelCounter = document.querySelector(
+    '[data-side-panel="checks"] [data-a11y-result-count]',
+  );
+
+  if (
+    !a11yRowTemplate ||
+    !a11ySelectorTemplate ||
+    !config ||
+    !toggleCounter ||
+    !panelCounter
+  ) {
+    return;
+  }
+
+  // Ensure we only test within the preview iframe, but nonetheless with the correct selectors.
+  const context = {
+    include: {
+      fromFrames: ['#preview-iframe'].concat(config.context.include),
+    },
+  };
+  if (config.context.exclude?.length > 0) {
+    context.exclude = {
+      fromFrames: ['#preview-iframe'].concat(config.context.exclude),
+    };
+  }
+
+  const results = await axe.run(context, config.options);
+
+  const a11yErrorsNumber = results.violations.reduce(
+    (sum, violation) => sum + violation.nodes.length,
+    0,
+  );
+
+  toggleCounter.innerText = a11yErrorsNumber.toString();
+  toggleCounter.hidden = a11yErrorsNumber === 0;
+  panelCounter.innerText = a11yErrorsNumber.toString();
+  panelCounter.classList.toggle('has-errors', a11yErrorsNumber > 0);
+
+  renderA11yResults(
+    checksPanel,
+    results,
+    config,
+    a11yRowTemplate,
+    a11ySelectorTemplate,
+    onClickSelector,
+  );
+};
+
 function initPreview() {
   const previewSidePanel = document.querySelector(
     '[data-side-panel="preview"]',
   );
+  const checksSidePanel = document.querySelector('[data-side-panel="checks"]');
 
   // Preview side panel is not shown if the object does not have any preview modes
   if (!previewSidePanel) return;
@@ -141,6 +205,9 @@ function initPreview() {
 
       // Remove the load event listener so it doesn't fire when switching modes
       newIframe.removeEventListener('load', handleLoad);
+
+      const onClickSelector = () => newTabButton.click();
+      runAccessibilityChecks(onClickSelector);
     };
 
     newIframe.addEventListener('load', handleLoad);
@@ -271,15 +338,30 @@ function initPreview() {
       );
     });
 
+    // Use the same processing as the preview panel.
+    checksSidePanel?.addEventListener('show', () => {
+      checkAndUpdatePreview();
+      updateInterval = setInterval(
+        checkAndUpdatePreview,
+        WAGTAIL_CONFIG.WAGTAIL_AUTO_UPDATE_PREVIEW_INTERVAL,
+      );
+    });
+
     previewSidePanel.addEventListener('hide', () => {
       clearInterval(updateInterval);
     });
+    checksSidePanel?.addEventListener('hide', () => {
+      clearInterval(updateInterval);
+    });
   } else {
     // Even if the preview is not updated automatically, we still need to
     // initialise the preview data when the panel is shown
     previewSidePanel.addEventListener('show', () => {
       setPreviewData();
     });
+    checksSidePanel?.addEventListener('show', () => {
+      setPreviewData();
+    });
   }
 
   //

+ 1 - 1
client/src/includes/userbar.test.ts → client/src/includes/a11y-result.test.ts

@@ -1,5 +1,5 @@
 import { AxeResults } from 'axe-core';
-import { sortAxeViolations } from './userbar';
+import { sortAxeViolations } from './a11y-result';
 
 const mockDocument = `
 <div id="a"></div>

+ 136 - 0
client/src/includes/a11y-result.ts

@@ -0,0 +1,136 @@
+import {
+  AxeResults,
+  ElementContext,
+  NodeResult,
+  Result,
+  RunOptions,
+} from 'axe-core';
+
+const sortAxeNodes = (nodeResultA?: NodeResult, nodeResultB?: NodeResult) => {
+  if (!nodeResultA || !nodeResultB) return 0;
+  const nodeA = document.querySelector<HTMLElement>(nodeResultA.target[0]);
+  const nodeB = document.querySelector<HTMLElement>(nodeResultB.target[0]);
+  if (!nodeA || !nodeB) return 0;
+  // Method works with bitwise https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition
+  // eslint-disable-next-line no-bitwise
+  return nodeA.compareDocumentPosition(nodeB) & Node.DOCUMENT_POSITION_PRECEDING
+    ? 1
+    : -1;
+};
+
+/**
+ * Sort Axe violations by position of the violation’s first node in the DOM.
+ */
+export const sortAxeViolations = (violations: Result[]) =>
+  violations.sort((violationA, violationB) => {
+    const earliestNodeA = violationA.nodes.sort(sortAxeNodes)[0];
+    const earliestNodeB = violationB.nodes.sort(sortAxeNodes)[0];
+    return sortAxeNodes(earliestNodeA, earliestNodeB);
+  });
+
+/**
+ * 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>;
+}
+
+/**
+ * Get the Axe configuration from the page.
+ */
+export const getAxeConfiguration = (
+  container: ShadowRoot | HTMLElement | null,
+): WagtailAxeConfiguration | null => {
+  const script = container?.querySelector<HTMLScriptElement>(
+    '#accessibility-axe-configuration',
+  );
+
+  if (!script || !script.textContent) return null;
+
+  try {
+    return JSON.parse(script.textContent);
+  } catch (err) {
+    // eslint-disable-next-line no-console
+    console.error('Error loading Axe config');
+    // eslint-disable-next-line no-console
+    console.error(err);
+  }
+
+  // Skip initialization of Axe if config fails to load
+  return null;
+};
+
+/**
+ * Render A11y results based on template elements.
+ */
+export const renderA11yResults = (
+  container: HTMLElement,
+  results: AxeResults,
+  config: WagtailAxeConfiguration,
+  a11yRowTemplate: HTMLTemplateElement,
+  a11ySelectorTemplate: HTMLTemplateElement,
+  onClickSelector: (selectorName: string, event: MouseEvent) => void,
+) => {
+  // Reset contents ahead of rendering new results.
+  // eslint-disable-next-line no-param-reassign
+  container.innerHTML = '';
+
+  if (results.violations.length) {
+    const sortedViolations = sortAxeViolations(results.violations);
+    sortedViolations.forEach((violation, violationIndex) => {
+      container.appendChild(a11yRowTemplate.content.cloneNode(true));
+      const currentA11yRow = container.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 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]',
+      ) 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<HTMLButtonElement>(
+            '[data-a11y-result-selector]',
+          )[nodeIndex];
+
+        currentA11ySelector.setAttribute('aria-describedby', a11yErrorName.id);
+        const currentA11ySelectorText = currentA11ySelector.querySelector(
+          '[data-a11y-result-selector-text]',
+        ) as HTMLSpanElement;
+        // Special-case when displaying accessibility results within the admin interface.
+        const selectorName =
+          node.target[0] === '#preview-iframe'
+            ? node.target[1]
+            : node.target[0];
+        // Remove unnecessary details before displaying selectors to the user
+        currentA11ySelectorText.textContent = selectorName.replace(
+          /\[data-block-key="\w{5}"\]/,
+          '',
+        );
+        currentA11ySelector.addEventListener(
+          'click',
+          onClickSelector.bind(null, selectorName),
+        );
+      });
+    });
+  }
+};

+ 73 - 174
client/src/includes/userbar.ts

@@ -1,7 +1,8 @@
-import axe, { ElementContext, NodeResult, Result, RunOptions } from 'axe-core';
+import axe from 'axe-core';
 
 import A11yDialog from 'a11y-dialog';
 import { Application } from '@hotwired/stimulus';
+import { getAxeConfiguration, renderA11yResults } from './a11y-result';
 import { DialogController } from '../controllers/DialogController';
 import { TeleportController } from '../controllers/TeleportController';
 
@@ -14,35 +15,6 @@ 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]);
-  const nodeB = document.querySelector<HTMLElement>(nodeResultB.target[0]);
-  if (!nodeA || !nodeB) return 0;
-  // Method works with bitwise https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition
-  // eslint-disable-next-line no-bitwise
-  return nodeA.compareDocumentPosition(nodeB) & Node.DOCUMENT_POSITION_PRECEDING
-    ? 1
-    : -1;
-};
-
-export const sortAxeViolations = (violations: Result[]) =>
-  violations.sort((violationA, violationB) => {
-    const earliestNodeA = violationA.nodes.sort(sortAxeNodes)[0];
-    const earliestNodeB = violationB.nodes.sort(sortAxeNodes)[0];
-    return sortAxeNodes(earliestNodeA, earliestNodeB);
-  });
-
 export class Userbar extends HTMLElement {
   declare trigger: HTMLElement;
 
@@ -329,33 +301,13 @@ export class Userbar extends HTMLElement {
   See documentation: https://github.com/dequelabs/axe-core/tree/develop/doc
   */
 
-  getAxeConfiguration(): WagtailAxeConfiguration | null {
-    const script = this.shadowRoot?.querySelector<HTMLScriptElement>(
-      '#accessibility-axe-configuration',
-    );
-
-    if (!script || !script.textContent) return null;
-
-    try {
-      return JSON.parse(script.textContent);
-    } catch (err) {
-      // eslint-disable-next-line no-console
-      console.error('Error loading Axe config');
-      // eslint-disable-next-line no-console
-      console.error(err);
-    }
-
-    // Skip initialization of Axe if config fails to load
-    return null;
-  }
-
   // Initialise axe accessibility checker
   async initialiseAxe() {
     const accessibilityTrigger = this.shadowRoot?.getElementById(
       'accessibility-trigger',
     );
 
-    const config = this.getAxeConfiguration();
+    const config = getAxeConfiguration(this.shadowRoot);
 
     if (!this.shadowRoot || !accessibilityTrigger || !config) return;
 
@@ -432,136 +384,83 @@ export class Userbar extends HTMLElement {
     innerErrorBadges.forEach((badge) => {
       // eslint-disable-next-line no-param-reassign
       badge.textContent = String(a11yErrorsNumber) || '0';
-      if (results.violations.length) {
-        badge.classList.add('has-errors');
-      } else {
-        badge.classList.remove('has-errors');
-      }
+      badge.classList.toggle('has-errors', results.violations.length > 0);
     });
 
-    const showAxeResults = () => {
-      modal.show();
-      // Reset modal contents to support multiple runs of Axe checks in the preview panel
-      modalBody.innerHTML = '';
-
-      if (results.violations.length) {
-        const sortedViolations = sortAxeViolations(results.violations);
-        sortedViolations.forEach((violation, violationIndex) => {
-          modalBody.appendChild(a11yRowTemplate.content.cloneNode(true));
-          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 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]',
-          ) 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<HTMLButtonElement>(
-                '[data-a11y-result-selector]',
-              )[nodeIndex];
-
-            currentA11ySelector.setAttribute(
-              'aria-describedby',
-              a11yErrorName.id,
-            );
-            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(
-              /\[data-block-key="\w{5}"\]/,
-              '',
-            );
-            currentA11ySelector.addEventListener('click', () => {
-              const inaccessibleElement =
-                document.querySelector<HTMLElement>(selectorName);
-              const a11yOutlineContainer =
-                this.shadowRoot?.querySelector<HTMLElement>(
-                  '[data-a11y-result-outline-container]',
-                );
-              if (a11yOutlineContainer?.firstElementChild) {
-                a11yOutlineContainer.removeChild(
-                  a11yOutlineContainer.firstElementChild,
-                );
-              }
-              a11yOutlineContainer?.appendChild(
-                a11yOutlineTemplate.content.cloneNode(true),
-              );
-              const currentA11yOutline =
-                this.shadowRoot?.querySelector<HTMLElement>(
-                  '[data-a11y-result-outline]',
-                );
-              if (
-                !this.shadowRoot ||
-                !inaccessibleElement ||
-                !currentA11yOutline ||
-                !a11yOutlineContainer
-              )
-                return;
-
-              const styleA11yOutline = () => {
-                const rect = inaccessibleElement.getBoundingClientRect();
-                currentA11yOutline.style.cssText = `
-                top: ${
-                  rect.height < 5
-                    ? `${rect.top + window.scrollY - 2.5}px`
-                    : `${rect.top + window.scrollY}px`
-                };
-                left: ${
-                  rect.width < 5
-                    ? `${rect.left + window.scrollX - 2.5}px`
-                    : `${rect.left + window.scrollX}px`
-                };
-                width: ${Math.max(rect.width, 5)}px;
-                height: ${Math.max(rect.height, 5)}px;
-                position: absolute;
-                z-index: 129;
-                outline: 1px solid #CD4444;
-                box-shadow: 0px 0px 12px 1px #FF0000;
-                `;
-              };
-
-              styleA11yOutline();
-
-              window.addEventListener('resize', styleA11yOutline);
-
-              inaccessibleElement.style.scrollMargin = '6.25rem';
-              inaccessibleElement.scrollIntoView();
-              inaccessibleElement.focus();
-
-              accessibilityResultsBox.addEventListener('hide', () => {
-                currentA11yOutline.style.cssText = '';
-
-                window.removeEventListener('resize', styleA11yOutline);
-              });
-            });
-          });
-        });
+    const onClickSelector = (selectorName: string) => {
+      const inaccessibleElement =
+        document.querySelector<HTMLElement>(selectorName);
+      const a11yOutlineContainer = this.shadowRoot?.querySelector<HTMLElement>(
+        '[data-a11y-result-outline-container]',
+      );
+      if (a11yOutlineContainer?.firstElementChild) {
+        a11yOutlineContainer.removeChild(
+          a11yOutlineContainer.firstElementChild,
+        );
       }
+      a11yOutlineContainer?.appendChild(
+        a11yOutlineTemplate.content.cloneNode(true),
+      );
+      const currentA11yOutline = this.shadowRoot?.querySelector<HTMLElement>(
+        '[data-a11y-result-outline]',
+      );
+      if (
+        !this.shadowRoot ||
+        !inaccessibleElement ||
+        !currentA11yOutline ||
+        !a11yOutlineContainer
+      )
+        return;
+
+      const styleA11yOutline = () => {
+        const rect = inaccessibleElement.getBoundingClientRect();
+        currentA11yOutline.style.cssText = `
+        top: ${
+          rect.height < 5
+            ? `${rect.top + window.scrollY - 2.5}px`
+            : `${rect.top + window.scrollY}px`
+        };
+        left: ${
+          rect.width < 5
+            ? `${rect.left + window.scrollX - 2.5}px`
+            : `${rect.left + window.scrollX}px`
+        };
+        width: ${Math.max(rect.width, 5)}px;
+        height: ${Math.max(rect.height, 5)}px;
+        position: absolute;
+        z-index: 129;
+        outline: 1px solid #CD4444;
+        box-shadow: 0px 0px 12px 1px #FF0000;
+        `;
+      };
+
+      styleA11yOutline();
+
+      window.addEventListener('resize', styleA11yOutline);
+
+      inaccessibleElement.style.scrollMargin = '6.25rem';
+      inaccessibleElement.scrollIntoView();
+      inaccessibleElement.focus();
+
+      accessibilityResultsBox.addEventListener('hide', () => {
+        currentA11yOutline.style.cssText = '';
+
+        window.removeEventListener('resize', styleA11yOutline);
+      });
     };
 
     const toggleAxeResults = () => {
       if (accessibilityResultsBox.getAttribute('aria-hidden') === 'true') {
-        showAxeResults();
+        modal.show();
+
+        renderA11yResults(
+          modalBody,
+          results,
+          config,
+          a11yRowTemplate,
+          a11ySelectorTemplate,
+          onClickSelector,
+        );
       } else {
         modal.hide();
       }

+ 5 - 0
docs/_static/wagtail_icons_table.txt

@@ -243,6 +243,11 @@
 <td> rectangle-list (solid): Font Awesome Pro 6.4.0 </td>
 <td><code>wagtailadmin/icons/form.svg</code></td> </tr>
 <tr>
+<td><svg width="32" height="32" fill="currentColor"><svg id="icon-glasses" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! glasses (classic): Font Awesome Pro 6.5.1 --><path d="M118.6 80c-11.5 0-21.4 7.9-24 19.1L57 260.3c20.5-6.2 48.3-12.3 78.7-12.3c32.3 0 61.8 6.9 82.8 13.5c10.6 3.3 19.3 6.7 25.4 9.2c3.1 1.3 5.5 2.4 7.3 3.2c.9 .4 1.6 .7 2.1 1l.6 .3 .2 .1 .1 0 0 0 0 0s0 0-6.3 12.7h0l6.3-12.7c5.8 2.9 10.4 7.3 13.5 12.7h40.6c3.1-5.3 7.7-9.8 13.5-12.7l6.3 12.7h0c-6.3-12.7-6.3-12.7-6.3-12.7l0 0 0 0 .1 0 .2-.1 .6-.3c.5-.2 1.2-.6 2.1-1c1.8-.8 4.2-1.9 7.3-3.2c6.1-2.6 14.8-5.9 25.4-9.2c21-6.6 50.4-13.5 82.8-13.5c30.4 0 58.2 6.1 78.7 12.3L481.4 99.1c-2.6-11.2-12.6-19.1-24-19.1c-3.1 0-6.2 .6-9.2 1.8L416.9 94.3c-12.3 4.9-26.3-1.1-31.2-13.4s1.1-26.3 13.4-31.2l31.3-12.5c8.6-3.4 17.7-5.2 27-5.2c33.8 0 63.1 23.3 70.8 56.2l43.9 188c1.7 7.3 2.9 14.7 3.5 22.1c.3 1.9 .5 3.8 .5 5.7v6.7V352v16c0 61.9-50.1 112-112 112H419.7c-59.4 0-108.5-46.4-111.8-105.8L306.6 352H269.4l-1.2 22.2C264.9 433.6 215.8 480 156.3 480H112C50.1 480 0 429.9 0 368V352 310.7 304c0-1.9 .2-3.8 .5-5.7c.6-7.4 1.8-14.8 3.5-22.1l43.9-188C55.5 55.3 84.8 32 118.6 32c9.2 0 18.4 1.8 27 5.2l31.3 12.5c12.3 4.9 18.3 18.9 13.4 31.2s-18.9 18.3-31.2 13.4L127.8 81.8c-2.9-1.2-6-1.8-9.2-1.8zM64 325.4V368c0 26.5 21.5 48 48 48h44.3c25.5 0 46.5-19.9 47.9-45.3l2.5-45.6c-2.3-.8-4.9-1.7-7.5-2.5c-17.2-5.4-39.9-10.5-63.6-10.5c-23.7 0-46.2 5.1-63.2 10.5c-3.1 1-5.9 1.9-8.5 2.9zM512 368V325.4c-2.6-.9-5.5-1.9-8.5-2.9c-17-5.4-39.5-10.5-63.2-10.5c-23.7 0-46.4 5.1-63.6 10.5c-2.7 .8-5.2 1.7-7.5 2.5l2.5 45.6c1.4 25.4 22.5 45.3 47.9 45.3H464c26.5 0 48-21.5 48-48z"/></svg> </svg></td>
+<td><code>glasses</code></td>
+<td> glasses (classic): Font Awesome Pro 6.5.1 </td>
+<td><code>wagtailadmin/icons/glasses.svg</code></td> </tr>
+<tr>
 <td><svg width="32" height="32" fill="currentColor"><svg id="icon-globe" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><!--! globe (solid): Font Awesome Pro 6.4.0 --><path d="M11 8c0 .719-.063 1.375-.125 2H5.094a19.61 19.61 0 0 1-.125-2c0-.688.062-1.344.125-2h5.781c.063.656.125 1.313.125 2Zm4.719-2c.187.656.281 1.313.281 2 0 .719-.094 1.375-.281 2h-3.844c.063-.625.125-1.313.125-2 0-.688-.063-1.344-.125-2h3.844Zm-.313-1H11.75c-.313-1.969-.938-3.656-1.719-4.719C12.47.937 14.47 2.687 15.406 5ZM10.75 5H5.219c.187-1.125.5-2.125.843-2.938.344-.75.688-1.28 1.063-1.624C7.469.124 7.75 0 8 0c.219 0 .5.125.844.438.375.343.719.875 1.062 1.625.344.812.656 1.812.844 2.937ZM.562 5C1.5 2.687 3.5.937 5.938.281 5.157 1.344 4.532 3.031 4.22 5H.562Zm3.532 1c-.063.656-.125 1.313-.125 2 0 .688.062 1.375.125 2H.25C.062 9.375 0 8.719 0 8c0-.688.063-1.344.25-2h3.844Zm1.968 7.969A12.984 12.984 0 0 1 5.22 11h5.531a12.984 12.984 0 0 1-.844 2.969c-.344.75-.687 1.281-1.062 1.625-.344.312-.625.406-.875.406-.219 0-.5-.094-.844-.406-.375-.344-.719-.875-1.063-1.625Zm-.125 1.781A8.02 8.02 0 0 1 .563 11H4.22c.312 2 .937 3.688 1.718 4.75Zm4.094 0c.781-1.063 1.406-2.75 1.719-4.75h3.656a8.02 8.02 0 0 1-5.375 4.75Z"></path></svg> </svg></td>
 <td><code>globe</code></td>
 <td> globe (solid): Font Awesome Pro 6.4.0 </td>

+ 1 - 1
docs/advanced_topics/accessibility_considerations.md

@@ -128,7 +128,7 @@ A number of built-in tools and additional resources are available to help create
 
 ### Built-in accessibility checker
 
-Wagtail includes an accessibility checker built into the [user bar](wagtailuserbar_tag). The checker can help authors create more accessible websites following best practices and accessibility standards like [WCAG](https://www.w3.org/WAI/standards-guidelines/wcag/).
+Wagtail includes an accessibility checker built into the [user bar](wagtailuserbar_tag) and editing views supporting previews. The checker can help authors create more accessible websites following best practices and accessibility standards like [WCAG](https://www.w3.org/WAI/standards-guidelines/wcag/).
 
 The checker is based on the [Axe](https://github.com/dequelabs/axe-core) testing engine and scans the loaded page for errors.
 

+ 7 - 1
docs/releases/6.0.md

@@ -27,7 +27,7 @@ Following design improvements to the page listing view, Wagtail now provides a u
 
 In this release, the universal listing interface is available for Pages, Snippets, and Forms.
 
-This feature was developed by Ben Enright, Matt Westcott, Thibaud Colas, and Sage Abdullah.
+This feature was developed by Ben Enright, Matt Westcott, Nick Lee, Thibaud Colas, and Sage Abdullah.
 
 ### Right-to-left language support
 
@@ -35,6 +35,12 @@ The admin interface now supports right-to-left languages, such as Persian, Arabi
 
 Thank you to Thibaud Colas, Badr Fourane, and Sage Abdullah for their work on this long-requested improvement.
 
+### Accessibility checker in page editor
+
+The [built-in accessibility checker](authoring_accessible_content) now displays as a side panel within page and snippet editors supporting preview. The new "Checks" side panel only shows accessibility-related issues for pages with the userbar enabled in this release, but will be updated to support [any content checks](https://github.com/wagtail/wagtail/discussions/11063) in the future.
+
+This feature was implemented by Nick Lee, Thibaud Colas, and Sage Abdullah.
+
 ### Other features
 
  * Added `search_index` option to StreamField blocks to control whether the block is indexed for searching (Vedant Pandey)

+ 1 - 0
wagtail/admin/templates/wagtailadmin/icons/glasses.svg

@@ -0,0 +1 @@
+<svg id="icon-glasses" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! glasses (classic): Font Awesome Pro 6.5.1 --><path d="M118.6 80c-11.5 0-21.4 7.9-24 19.1L57 260.3c20.5-6.2 48.3-12.3 78.7-12.3c32.3 0 61.8 6.9 82.8 13.5c10.6 3.3 19.3 6.7 25.4 9.2c3.1 1.3 5.5 2.4 7.3 3.2c.9 .4 1.6 .7 2.1 1l.6 .3 .2 .1 .1 0 0 0 0 0s0 0-6.3 12.7h0l6.3-12.7c5.8 2.9 10.4 7.3 13.5 12.7h40.6c3.1-5.3 7.7-9.8 13.5-12.7l6.3 12.7h0c-6.3-12.7-6.3-12.7-6.3-12.7l0 0 0 0 .1 0 .2-.1 .6-.3c.5-.2 1.2-.6 2.1-1c1.8-.8 4.2-1.9 7.3-3.2c6.1-2.6 14.8-5.9 25.4-9.2c21-6.6 50.4-13.5 82.8-13.5c30.4 0 58.2 6.1 78.7 12.3L481.4 99.1c-2.6-11.2-12.6-19.1-24-19.1c-3.1 0-6.2 .6-9.2 1.8L416.9 94.3c-12.3 4.9-26.3-1.1-31.2-13.4s1.1-26.3 13.4-31.2l31.3-12.5c8.6-3.4 17.7-5.2 27-5.2c33.8 0 63.1 23.3 70.8 56.2l43.9 188c1.7 7.3 2.9 14.7 3.5 22.1c.3 1.9 .5 3.8 .5 5.7v6.7V352v16c0 61.9-50.1 112-112 112H419.7c-59.4 0-108.5-46.4-111.8-105.8L306.6 352H269.4l-1.2 22.2C264.9 433.6 215.8 480 156.3 480H112C50.1 480 0 429.9 0 368V352 310.7 304c0-1.9 .2-3.8 .5-5.7c.6-7.4 1.8-14.8 3.5-22.1l43.9-188C55.5 55.3 84.8 32 118.6 32c9.2 0 18.4 1.8 27 5.2l31.3 12.5c12.3 4.9 18.3 18.9 13.4 31.2s-18.9 18.3-31.2 13.4L127.8 81.8c-2.9-1.2-6-1.8-9.2-1.8zM64 325.4V368c0 26.5 21.5 48 48 48h44.3c25.5 0 46.5-19.9 47.9-45.3l2.5-45.6c-2.3-.8-4.9-1.7-7.5-2.5c-17.2-5.4-39.9-10.5-63.6-10.5c-23.7 0-46.2 5.1-63.2 10.5c-3.1 1-5.9 1.9-8.5 2.9zM512 368V325.4c-2.6-.9-5.5-1.9-8.5-2.9c-17-5.4-39.5-10.5-63.2-10.5c-23.7 0-46.4 5.1-63.6 10.5c-2.7 .8-5.2 1.7-7.5 2.5l2.5 45.6c1.4 25.4 22.5 45.3 47.9 45.3H464c26.5 0 48-21.5 48-48z"/></svg>

+ 23 - 0
wagtail/admin/templates/wagtailadmin/shared/side_panels/checks.html

@@ -0,0 +1,23 @@
+{% load i18n wagtailadmin_tags %}
+
+<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">
+        {% icon name="link-external" classname="w-a11y-result__icon" %}
+        <span data-a11y-result-selector-text></span>
+    </button>
+</template>
+
+<div class="w-mt-12">
+    <h2 class="w-flex w-items-center w-gap-2"><span>{% trans 'Issues found' %}</span><span class="w-a11y-result__count" data-a11y-result-count>0</span></h2>
+    <div data-checks-panel></div>
+</div>
+{{ axe_configuration|json_script:"accessibility-axe-configuration" }}

+ 1 - 1
wagtail/admin/templates/wagtailadmin/shared/side_panels/preview.html

@@ -54,7 +54,7 @@
         </div>
 
         <div class="preview-panel__wrapper">
-            <iframe loading="lazy" title="{% trans 'Preview' %}" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">
+            <iframe id="preview-iframe" loading="lazy" title="{% trans 'Preview' %}" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">
                 <div>
                      {# Add placeholder element to support styling content when iframe has loaded #}
                 </div>

+ 2 - 1
wagtail/admin/templates/wagtailadmin/userbar/base.html

@@ -1,7 +1,8 @@
 {% load wagtailadmin_tags i18n %}
 <!-- Wagtail user bar embed code -->
 <template id="wagtail-userbar-template">
-    <aside>
+    {# In preview panels, we still render the userbar UI, but hidden by default. #}
+    <aside {% if request.in_preview_panel %}hidden{% endif %}>
         <div class="w-userbar w-userbar--{{ position|default:'bottom-right' }} {% admin_theme_classname %}" data-wagtail-userbar part="userbar">
             <link rel="stylesheet" href="{% versioned_static 'wagtailadmin/css/core.css' %}">
             {% hook_output 'insert_global_admin_css' %}

+ 7 - 4
wagtail/admin/templatetags/wagtailuserbar.py

@@ -47,9 +47,8 @@ def wagtailuserbar(context, position="bottom-right"):
     if not user.has_perm("wagtailadmin.access_admin"):
         return ""
 
-    # Don't render if page is loaded in page editor's preview panel iframe
-    if getattr(request, "in_preview_panel", False):
-        return ""
+    # Render the userbar differently within the preview panel.
+    in_preview_panel = getattr(request, "in_preview_panel", False)
 
     # Render the userbar using the user's preferred admin language
     userprofile = UserProfile.get_for_user(user)
@@ -61,7 +60,11 @@ def wagtailuserbar(context, position="bottom-right"):
         except AttributeError:
             revision_id = None
 
-        if page and page.id:
+        if in_preview_panel:
+            items = [
+                AccessibilityItem(),
+            ]
+        elif page and page.id:
             if revision_id:
                 revision = Revision.page_revisions.get(id=revision_id)
                 items = [

+ 4 - 4
wagtail/admin/tests/pages/test_preview.py

@@ -454,7 +454,7 @@ class TestEnablePreview(WagtailTestUtils, TestCase):
         # Should show the iframe
         self.assertContains(
             response,
-            '<iframe loading="lazy" title="Preview" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">',
+            '<iframe id="preview-iframe" loading="lazy" title="Preview" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">',
         )
 
         # Should show the new tab button with the default mode set
@@ -482,7 +482,7 @@ class TestEnablePreview(WagtailTestUtils, TestCase):
         # Should show the iframe
         self.assertContains(
             response,
-            '<iframe loading="lazy" title="Preview" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">',
+            '<iframe id="preview-iframe" loading="lazy" title="Preview" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">',
         )
 
         # Should show the new tab button with the default mode set and correctly quoted
@@ -516,7 +516,7 @@ class TestEnablePreview(WagtailTestUtils, TestCase):
         # Should show the iframe
         self.assertContains(
             response,
-            '<iframe loading="lazy" title="Preview" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">',
+            '<iframe id="preview-iframe" loading="lazy" title="Preview" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">',
         )
 
         # Should show the new tab button with the default mode set
@@ -544,7 +544,7 @@ class TestEnablePreview(WagtailTestUtils, TestCase):
         # Should show the iframe
         self.assertContains(
             response,
-            '<iframe loading="lazy" title="Preview" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">',
+            '<iframe id="preview-iframe" loading="lazy" title="Preview" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">',
         )
 
         # Should show the new tab button with the default mode set and correctly quoted

+ 2 - 3
wagtail/admin/tests/test_userbar.py

@@ -150,7 +150,7 @@ class TestUserbarTag(WagtailTestUtils, TestCase):
             reverse("wagtailadmin_pages:edit", args=(self.homepage.id,)), content
         )
 
-    def test_userbar_not_in_preview_panel(self):
+    def test_userbar_hidden_in_preview_panel(self):
         template = Template("{% load wagtailuserbar %}{% wagtailuserbar %}")
         content = template.render(
             Context(
@@ -163,8 +163,7 @@ class TestUserbarTag(WagtailTestUtils, TestCase):
             )
         )
 
-        # Make sure nothing was rendered
-        self.assertEqual(content, "")
+        self.assertIn("<aside hidden>", content)
 
 
 class TestAccessibilityCheckerConfig(WagtailTestUtils, TestCase):

+ 28 - 0
wagtail/admin/ui/side_panels.py

@@ -2,7 +2,9 @@ from django.urls import reverse
 from django.utils.text import capfirst
 from django.utils.translation import gettext_lazy, ngettext
 
+from wagtail import hooks
 from wagtail.admin.ui.components import Component
+from wagtail.admin.userbar import AccessibilityItem
 from wagtail.models import DraftStateMixin, LockableMixin, Page, ReferenceIndex
 
 
@@ -305,6 +307,32 @@ class CommentsSidePanel(BaseSidePanel):
         return context
 
 
+class ChecksSidePanel(BaseSidePanel):
+    class SidePanelToggle(BaseSidePanel.SidePanelToggle):
+        aria_label = gettext_lazy("Toggle checks")
+        icon_name = "glasses"
+
+    name = "checks"
+    title = gettext_lazy("Checks")
+    template_name = "wagtailadmin/shared/side_panels/checks.html"
+    order = 350
+
+    def get_axe_configuration(self):
+        # Retrieve the Axe configuration from the userbar.
+        userbar_items = [AccessibilityItem()]
+        for fn in hooks.get_hooks("construct_wagtail_userbar"):
+            fn(self.request, userbar_items)
+
+        for item in userbar_items:
+            if isinstance(item, AccessibilityItem):
+                return item.get_axe_configuration(self.request)
+
+    def get_context_data(self, parent_context):
+        context = super().get_context_data(parent_context)
+        context["axe_configuration"] = self.get_axe_configuration()
+        return context
+
+
 class PreviewSidePanel(BaseSidePanel):
     class SidePanelToggle(BaseSidePanel.SidePanelToggle):
         aria_label = gettext_lazy("Toggle preview")

+ 7 - 0
wagtail/admin/views/pages/create.py

@@ -16,6 +16,7 @@ from wagtail.admin import messages, signals
 from wagtail.admin.action_menu import PageActionMenu
 from wagtail.admin.ui.components import MediaContainer
 from wagtail.admin.ui.side_panels import (
+    ChecksSidePanel,
     CommentsSidePanel,
     PageStatusSidePanel,
     PreviewSidePanel,
@@ -365,6 +366,12 @@ class CreateView(WagtailAdminTemplateMixin, HookResponseMixin, View):
                     self.page, self.request, preview_url=self.get_preview_url()
                 )
             )
+            side_panels.append(
+                ChecksSidePanel(
+                    self.page,
+                    self.request,
+                )
+            )
         if self.form.show_comments_toggle:
             side_panels.append(CommentsSidePanel(self.page, self.request))
         return MediaContainer(side_panels)

+ 2 - 0
wagtail/admin/views/pages/edit.py

@@ -19,6 +19,7 @@ from wagtail.admin.action_menu import PageActionMenu
 from wagtail.admin.mail import send_notification
 from wagtail.admin.ui.components import MediaContainer
 from wagtail.admin.ui.side_panels import (
+    ChecksSidePanel,
     CommentsSidePanel,
     PageStatusSidePanel,
     PreviewSidePanel,
@@ -872,6 +873,7 @@ class EditView(WagtailAdminTemplateMixin, HookResponseMixin, View):
                     self.page, self.request, preview_url=self.get_preview_url()
                 )
             )
+            side_panels.append(ChecksSidePanel(self.page, self.request))
         if self.form.show_comments_toggle:
             side_panels.append(CommentsSidePanel(self.page, self.request))
         return MediaContainer(side_panels)

+ 2 - 0
wagtail/admin/views/pages/revisions.py

@@ -15,6 +15,7 @@ from wagtail.admin.action_menu import PageActionMenu
 from wagtail.admin.auth import user_has_any_page_permission, user_passes_test
 from wagtail.admin.ui.components import MediaContainer
 from wagtail.admin.ui.side_panels import (
+    ChecksSidePanel,
     CommentsSidePanel,
     PageStatusSidePanel,
     PreviewSidePanel,
@@ -95,6 +96,7 @@ def revisions_revert(request, page_id, revision_id):
     ]
     if page.is_previewable():
         side_panels.append(PreviewSidePanel(page, request, preview_url=preview_url))
+        side_panels.append(ChecksSidePanel(page, request))
     if form.show_comments_toggle:
         side_panels.append(CommentsSidePanel(page, request))
     side_panels = MediaContainer(side_panels)

+ 1 - 0
wagtail/admin/wagtail_hooks.py

@@ -1044,6 +1044,7 @@ def register_icons(icons):
         "folder-open-inverse.svg",
         "folder.svg",
         "form.svg",
+        "glasses.svg",
         "globe.svg",
         "grip.svg",
         "group.svg",

+ 4 - 4
wagtail/snippets/tests/test_preview.py

@@ -304,7 +304,7 @@ class TestEnablePreview(WagtailTestUtils, TestCase):
         # Should show the iframe
         self.assertContains(
             response,
-            '<iframe loading="lazy" title="Preview" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">',
+            '<iframe id="preview-iframe" loading="lazy" title="Preview" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">',
         )
 
         # Should show the new tab button with the default mode set
@@ -332,7 +332,7 @@ class TestEnablePreview(WagtailTestUtils, TestCase):
         # Should show the iframe
         self.assertContains(
             response,
-            '<iframe loading="lazy" title="Preview" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">',
+            '<iframe id="preview-iframe" loading="lazy" title="Preview" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">',
         )
 
         # Should show the new tab button with the default mode set and correctly quoted
@@ -368,7 +368,7 @@ class TestEnablePreview(WagtailTestUtils, TestCase):
         # Should show the iframe
         self.assertContains(
             response,
-            '<iframe loading="lazy" title="Preview" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">',
+            '<iframe id="preview-iframe" loading="lazy" title="Preview" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">',
         )
 
         # Should show the new tab button with the default mode set
@@ -398,7 +398,7 @@ class TestEnablePreview(WagtailTestUtils, TestCase):
         # Should show the iframe
         self.assertContains(
             response,
-            '<iframe loading="lazy" title="Preview" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">',
+            '<iframe id="preview-iframe" loading="lazy" title="Preview" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">',
         )
 
         # Should show the new tab button with the default mode set and correctly quoted

+ 3 - 1
wagtail/snippets/views/snippets.py

@@ -16,7 +16,7 @@ from wagtail import hooks
 from wagtail.admin.checks import check_panels_in_model
 from wagtail.admin.panels import ObjectList, extract_panel_definitions_from_model_class
 from wagtail.admin.ui.components import MediaContainer
-from wagtail.admin.ui.side_panels import PreviewSidePanel
+from wagtail.admin.ui.side_panels import ChecksSidePanel, PreviewSidePanel
 from wagtail.admin.ui.tables import (
     BulkActionsCheckboxColumn,
     Column,
@@ -266,6 +266,7 @@ class CreateView(generic.CreateEditViewOptionalFeaturesMixin, generic.CreateView
                     self.form.instance, self.request, preview_url=self.get_preview_url()
                 )
             )
+            side_panels.append(ChecksSidePanel(self.form.instance, self.request))
         return MediaContainer(side_panels)
 
     def get_context_data(self, **kwargs):
@@ -322,6 +323,7 @@ class EditView(generic.CreateEditViewOptionalFeaturesMixin, generic.EditView):
                     self.object, self.request, preview_url=self.get_preview_url()
                 )
             )
+            side_panels.append(ChecksSidePanel(self.object, self.request))
         return MediaContainer(side_panels)
 
     def get_context_data(self, **kwargs):