بنقرة واحدة
accessibility
WCAG 2.2 compliance, keyboard navigation, ARIA patterns, and automated a11y testing for Drupal
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
القائمة
WCAG 2.2 compliance, keyboard navigation, ARIA patterns, and automated a11y testing for Drupal
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
استنادا إلى تصنيف SOC المهني
Use when reviewing Drupal code for standards compliance across PHP, JavaScript, CSS, Twig, YAML, SQL, and markup — dynamically loads relevant standards per file type using Drupal's official community guidelines.
How to write, modify, and style React components for Drupal Canvas and Drupal CMS. Covers the technology stack (React 19, Tailwind CSS 4, CVA variants, cn utility), component.yml prop and slot definitions, enum naming, image and video prop types, formatted text fields, color scheme variants, theme tokens from global.css, required folder structure (index.jsx + component.yml), and matching source site visual styles including extracting design tokens, dark backgrounds, gradient text, glass/blur effects, and updating global.css theme. Use when building, editing, or fixing any component in src/components/.
Managing content in Drupal CMS via JSON:API. Covers listing, fetching, creating, updating, and deleting pages and content entities using npm run content commands. Includes page component structure (uuid, component_id, inputs, parent_uuid, slot), image uploading and media target_id handling, formatted HTML text fields, UUID generation, input format reference mapping component.yml prop types to JSON formats, menu item listing (menu_items--main, menu_items--footer, menu_items--social-media are read-only via API — must use admin UI for create/update/delete), page path alias management, and common pitfalls like wrong media IDs, link props as objects instead of strings, plain strings for HTML fields, target_id as integer instead of string, langcode errors, and JSON:API read-only mode. Use when composing pages, adding components to pages, uploading images, or interacting with the Drupal CMS content API.
Search and fetch Canvas and Drupal CMS documentation pages. Takes a query describing what you need to know (e.g., "code components", "page regions", "known issues", "data fetching") and returns the full content of matching documentation pages. Invoke with /canvas-docs-explorer <query>. Use this whenever you need to understand how Canvas or Drupal CMS works, verify platform behavior, check for limitations, or find configuration options.
Use when setting up a new Drupal CMS project with Canvas for component development. Covers DDEV, JSON:API write mode, Canvas OAuth client, permissions, page regions, menus, project scaffolding, .env, CSS layer fix, and Storybook validation. Every step is idempotent — safe to re-run.
Step-by-step guide for scaffolding a new React component from scratch. Covers copying an existing component as a base, creating the required index.jsx and component.yml files, naming conventions (snake_case machineName, no project prefixes), creating the matching Storybook story file, reusing existing components via imports, composability patterns, and internal component setup (status false). Use when a needed component does not yet exist in src/components/ and must be created.
| name | accessibility |
| description | WCAG 2.2 compliance, keyboard navigation, ARIA patterns, and automated a11y testing for Drupal |
| version | 1.0.0 |
Activate when:
| Level | Target | Criteria Count | Typical Requirement |
|---|---|---|---|
| A | Minimum | 30 | Legal baseline in most jurisdictions |
| AA | Standard | 24 additional | Target this — industry standard, most regulations |
| AAA | Enhanced | 28 additional | Rarely required site-wide, useful for specific content |
{# CORRECT: proper heading hierarchy #}
<article{{ attributes }}>
<h2{{ title_attributes }}>{{ label }}</h2>
<div{{ content_attributes }}>
<h3>Section Title</h3>
<p>{{ content.field_body }}</p>
</div>
</article>
{# WRONG: skipping heading levels #}
<div>
<h1>Title</h1>
<h4>Subsection</h4> {# Skipped h2 and h3! #}
</div>
{# page.html.twig with proper landmarks #}
<header role="banner">
{{ page.header }}
</header>
<nav role="navigation" aria-label="{{ 'Main navigation'|t }}">
{{ page.primary_menu }}
</nav>
<main role="main" id="main-content">
<a id="main-content-anchor" tabindex="-1"></a>
{{ page.content }}
</main>
<aside role="complementary" aria-label="{{ 'Sidebar'|t }}">
{{ page.sidebar }}
</aside>
<footer role="contentinfo">
{{ page.footer }}
</footer>
{# menu.html.twig — accessible menu with current page indication #}
{% import _self as menus %}
{{ menus.menu_links(items, attributes, 0) }}
{% macro menu_links(items, attributes, menu_level) %}
{% import _self as menus %}
{% if items %}
<ul{{ attributes.addClass(menu_level == 0 ? 'menu' : 'menu--child') }} role="list">
{% for item in items %}
{% set item_classes = [
'menu__item',
item.in_active_trail ? 'menu__item--active-trail',
item.below ? 'menu__item--expanded',
] %}
<li{{ item.attributes.addClass(item_classes) }}>
<a href="{{ item.url }}"
{{ item.in_active_trail ? 'aria-current="page"' }}
{{ item.below ? 'aria-expanded="false" aria-haspopup="true"' }}>
{{ item.title }}
</a>
{% if item.below %}
{{ menus.menu_links(item.below, attributes.removeClass(attributes.class), menu_level + 1) }}
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
{% endmacro %}
<button>, <nav>, <dialog>)<h2 role="tab"> — wrong)role="presentation" or aria-hidden="true" on focusable elements{# Accessible dropdown / disclosure #}
<div class="dropdown">
<button
aria-expanded="false"
aria-controls="dropdown-menu-{{ id }}"
type="button">
{{ label }}
</button>
<ul id="dropdown-menu-{{ id }}" role="menu" hidden>
{% for item in items %}
<li role="menuitem">
<a href="{{ item.url }}">{{ item.title }}</a>
</li>
{% endfor %}
</ul>
</div>
{# Accessible tabs #}
<div class="tabs">
<ul role="tablist" aria-label="{{ 'Content sections'|t }}">
{% for tab in tabs %}
<li role="presentation">
<button
role="tab"
id="tab-{{ tab.id }}"
aria-controls="panel-{{ tab.id }}"
aria-selected="{{ loop.first ? 'true' : 'false' }}"
tabindex="{{ loop.first ? '0' : '-1' }}">
{{ tab.label }}
</button>
</li>
{% endfor %}
</ul>
{% for tab in tabs %}
<div
role="tabpanel"
id="panel-{{ tab.id }}"
aria-labelledby="tab-{{ tab.id }}"
{{ not loop.first ? 'hidden' }}
tabindex="0">
{{ tab.content }}
</div>
{% endfor %}
</div>
{# Live region for dynamic updates #}
<div aria-live="polite" aria-atomic="true" class="visually-hidden">
{{ status_message }}
</div>
| Element | Expected Keyboard Behavior |
|---|---|
| Links/buttons | Enter to activate, visible focus indicator |
| Menus | Arrow keys to navigate, Escape to close |
| Tabs | Arrow keys between tabs, Tab to enter panel |
| Modals/dialogs | Focus trapped inside, Escape to close, focus returns on close |
| Carousels | Arrow keys, pause on focus, button to stop auto-play |
| Autocomplete | Arrow keys to navigate, Enter to select, Escape to dismiss |
(function (Drupal) {
'use strict';
Drupal.behaviors.accessibleModal = {
attach(context) {
const triggers = once('a11y-modal', '[data-modal-trigger]', context);
triggers.forEach((trigger) => {
trigger.addEventListener('click', (e) => {
const modal = document.getElementById(trigger.dataset.modalTrigger);
if (!modal) return;
// Store trigger to return focus on close
modal._triggerElement = trigger;
// Show and trap focus
modal.showModal();
const firstFocusable = modal.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
});
});
// Return focus on close
once('a11y-modal-close', 'dialog', context).forEach((dialog) => {
dialog.addEventListener('close', () => {
dialog._triggerElement?.focus();
});
});
},
};
})(Drupal);
{# Must be first focusable element in page.html.twig #}
<a href="#main-content" class="visually-hidden focusable skip-link">
{{ 'Skip to main content'|t }}
</a>
.skip-link {
position: absolute;
inset-block-start: -100%;
}
.skip-link:focus {
position: fixed;
inset-block-start: 0;
inset-inline-start: 0;
z-index: 1000;
padding: var(--space-xs) var(--space-sm);
background: var(--color-focus);
color: var(--color-on-focus);
}
| Text Type | AA Ratio | AAA Ratio |
|---|---|---|
| Normal text (< 18pt) | 4.5:1 | 7:1 |
| Large text (≥ 18pt or 14pt bold) | 3:1 | 4.5:1 |
| UI components / graphical objects | 3:1 | — |
/* Use custom properties for easy theme-wide contrast management */
:root {
--color-text: #1a1a1a; /* 15.3:1 on white */
--color-text-muted: #595959; /* 7.0:1 on white */
--color-link: #0055b8; /* 7.1:1 on white */
--color-link-visited: #6b3fa0; /* 5.6:1 on white */
--color-focus: #1a73e8;
--focus-outline: 3px solid var(--color-focus);
--focus-outline-offset: 2px;
}
/* Consistent focus indicators — NEVER use outline: none without replacement */
:focus-visible {
outline: var(--focus-outline);
outline-offset: var(--focus-outline-offset);
}
/* High contrast mode support */
@media (forced-colors: active) {
.button {
border: 2px solid ButtonText;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
{# Accessible form field with error #}
<div class="form-item{{ errors ? ' form-item--error' }}">
<label for="edit-email" class="form-item__label{{ required ? ' form-required' }}">
{{ 'Email address'|t }}
{% if required %}
<span class="form-required__marker" aria-hidden="true">*</span>
{% endif %}
</label>
{% if description %}
<div id="edit-email-description" class="form-item__description">
{{ description }}
</div>
{% endif %}
<input
type="email"
id="edit-email"
name="email"
class="form-email{{ errors ? ' error' }}"
{{ required ? 'required aria-required="true"' }}
{{ description ? 'aria-describedby="edit-email-description"'|raw }}
{{ errors ? 'aria-invalid="true" aria-errormessage="edit-email-error"'|raw }}
autocomplete="email"
/>
{% if errors %}
<div id="edit-email-error" class="form-item__error-message" role="alert">
{{ errors }}
</div>
{% endif %}
</div>
{# Informative image — describe the content #}
<img src="{{ file_url }}" alt="{{ image_alt }}" />
{# Decorative image — empty alt, not missing #}
<img src="{{ decorative_url }}" alt="" role="presentation" />
{# Complex image — long description #}
<figure>
<img src="{{ chart_url }}" alt="{{ 'Chart showing quarterly revenue growth'|t }}"
aria-describedby="chart-description-{{ id }}" />
<figcaption id="chart-description-{{ id }}">
{{ detailed_description }}
</figcaption>
</figure>
{# Video with captions #}
<video controls>
<source src="{{ video_url }}" type="video/mp4" />
<track kind="captions" src="{{ captions_url }}" srclang="en" label="{{ 'English'|t }}" default />
<track kind="descriptions" src="{{ descriptions_url }}" srclang="en" label="{{ 'Audio descriptions'|t }}" />
</video>
(function (Drupal) {
'use strict';
Drupal.behaviors.liveUpdates = {
attach(context) {
once('live-filter', '.views-exposed-form', context).forEach((form) => {
form.addEventListener('change', () => {
// After AJAX completes, announce result count
const resultCount = document.querySelectorAll('.views-row').length;
Drupal.announce(
Drupal.t('@count results found', { '@count': resultCount }),
'polite' // 'polite' waits, 'assertive' interrupts
);
});
});
},
};
})(Drupal);
{# status-messages.html.twig #}
<div data-drupal-messages>
{% for type, messages in message_list %}
<div
role="{{ type == 'error' ? 'alert' : 'status' }}"
aria-label="{{ status_headings[type] }}"
class="messages messages--{{ type }}">
{% if status_headings[type] %}
<h2 class="visually-hidden">{{ status_headings[type] }}</h2>
{% endif %}
{% if messages|length > 1 %}
<ul class="messages__list">
{% for message in messages %}
<li class="messages__item">{{ message }}</li>
{% endfor %}
</ul>
{% else %}
<p class="messages__item">{{ messages|first }}</p>
{% endif %}
</div>
{% endfor %}
</div>
# Install
npm install --save-dev @axe-core/cli
# Run against local site
npx axe http://my-drupal-site.ddev.site --tags wcag2a,wcag2aa,wcag22aa
# With specific rules
npx axe http://my-drupal-site.ddev.site --rules color-contrast,label,image-alt
# Install
npm install --save-dev @lhci/cli
# Run accessibility audit
npx lhci autorun --collect.url=http://my-drupal-site.ddev.site \
--assert.assertions='categories:accessibility>=0.9'
# Install and run
npm install --save-dev pa11y
npx pa11y http://my-drupal-site.ddev.site --standard WCAG2AA
# Multiple pages
npx pa11y-ci --config .pa11yci.json
declare(strict_types=1);
namespace Drupal\Tests\my_module\Functional;
use Drupal\Tests\BrowserTestBase;
final class AccessibilityTest extends BrowserTestBase {
protected $defaultTheme = 'stark';
public function testPageHasProperHeadingHierarchy(): void {
$this->drupalGet('/node/1');
$session = $this->assertSession();
// Check h1 exists exactly once
$h1_elements = $this->getSession()->getPage()->findAll('css', 'h1');
$this->assertCount(1, $h1_elements, 'Page has exactly one h1 element');
// Check skip link exists
$session->elementExists('css', 'a.skip-link');
// Check images have alt attributes
$images = $this->getSession()->getPage()->findAll('css', 'img');
foreach ($images as $image) {
$this->assertTrue(
$image->hasAttribute('alt'),
sprintf('Image %s missing alt attribute', $image->getAttribute('src'))
);
}
// Check form labels
$inputs = $this->getSession()->getPage()->findAll('css', 'input:not([type="hidden"])');
foreach ($inputs as $input) {
$id = $input->getAttribute('id');
if ($id) {
$session->elementExists('css', "label[for=\"{$id}\"]");
}
}
}
}
| Issue | Where | Fix |
|---|---|---|
| Views output missing headings | Views block/page | Add header with heading tag in views settings |
| Admin toolbar not keyboard accessible | Admin toolbar module | Use Gin theme which fixes this |
| AJAX updates not announced | Custom AJAX | Use Drupal.announce() after AJAX completes |
| Modal dialogs — focus not trapped | jQuery UI dialog | Use native <dialog> element or add focus trap JS |
| Dropdown menus — keyboard inaccessible | Custom menus | Add arrow key navigation + Escape handler |
| Status messages not role="status" | Custom templates | Use proper role="status" or role="alert" |
| Entity reference autocomplete | Core widget | Generally accessible, but test with screen reader |
| CKEditor content | WYSIWYG | Train editors on heading hierarchy, alt text, link text |
| Responsive tables | Views tables | Use <caption>, proper <th scope>, consider reflow pattern |
| Color picker fields | Custom forms | Provide text input alternative alongside visual picker |