---
name: drupal-accessibility
description: 'Ensure Drupal 11 frontend output meets WCAG 2.1 Level AA. Use when: reviewing or building forms, navigation, modals, dialogs, carousels, accordions, tables, interactive components, colour contrast, ARIA attributes, keyboard navigation, focus management, skip links, live regions, screen reader support, auditing Twig templates for accessibility violations, Drupal form API accessibility.'
---

# Drupal 11 — Accessibility (WCAG 2.1 AA)

WCAG 2.1 Level AA is the minimum bar. Treat it as the floor, not the ceiling.

---

## Quick Audit Checklist

Run mentally on every template review:

- [ ] Heading hierarchy correct (h1 → h2 → h3, no skips)
- [ ] All landmark regions present (`<main>`, `<nav>`, `<header>`, `<footer>`, `<aside>`)
- [ ] Every `<img>` has `alt=""` (decorative) or descriptive alt text
- [ ] Every form input has `<label>` association or `aria-label`
- [ ] Colour contrast: 4.5:1 normal text, 3:1 large text, 3:1 UI components
- [ ] All interactive elements keyboard-operable (Tab, Enter, Space, Arrow keys where applicable)
- [ ] Visible focus indicator on all focusable elements
- [ ] Skip links present and functional
- [ ] No information conveyed by colour alone
- [ ] Error messages use `role="alert"` or are associated via `aria-describedby`

---

## Semantic HTML

Always prefer native semantic HTML over ARIA roles:

```twig
{# Landmarks #}
<header role="banner">...</header>
<nav aria-label="Main navigation">...</nav>
<main id="main-content">...</main>
<aside aria-label="Related content">...</aside>
<footer role="contentinfo">...</footer>

{# Headings — never skip levels #}
<h1>Page Title</h1>
  <h2>Section</h2>
    <h3>Subsection</h3>
```

---

## Skip Links

Every page template must include skip links as the first focusable elements:

```twig
<div class="skip-links">
  <a class="skip-link" href="#main-content">{{ 'Skip to main content'|t }}</a>
  <a class="skip-link" href="#main-navigation">{{ 'Skip to navigation'|t }}</a>
</div>
```

```scss
.skip-link {
  position: absolute;
  top: -40px;
  left: 6px;
  z-index: 999999;
  background: #000;
  color: #fff;
  padding: 8px 16px;
  text-decoration: underline;

  &:focus {
    top: 6px;
  }
}
```

---

## Forms

### Input Labels (WCAG 1.3.1, 4.1.2)

```twig
{# Explicit label association #}
<label for="edit-email">{{ 'Email address'|t }}</label>
<input type="email" id="edit-email" name="email"
  aria-required="true"
  aria-describedby="email-hint">
<div id="email-hint">{{ 'We will never share your email.'|t }}</div>
```

### Error States (WCAG 3.3.1, 3.3.3)

```twig
<input type="email" id="edit-email"
  aria-required="true"
  aria-invalid="true"
  aria-describedby="email-error">
<div id="email-error" role="alert">
  {{ 'Please enter a valid email address.'|t }}
</div>
```

### Drupal Form API — Accessibility Hooks

```php
// Add description via #description — Drupal renders it as aria-describedby automatically
$form['email'] = [
  '#type' => 'email',
  '#title' => $this->t('Email address'),
  '#required' => TRUE,
  '#description' => $this->t('We will never share your email.'),
];
```

---

## ARIA Attributes

Use ARIA only when native HTML semantics are insufficient.

### States on Interactive Elements

```twig
{# Accordion button #}
<button type="button"
  aria-expanded="false"
  aria-controls="accordion-panel-1">
  {{ 'Section title'|t }}
</button>
<div id="accordion-panel-1" hidden>
  {% block content %}{% endblock %}
</div>

{# Current page in nav #}
<a href="/about" {% if is_current %}aria-current="page"{% endif %}>
  {{ 'About'|t }}
</a>

{# Modal trigger #}
<button type="button"
  aria-haspopup="dialog"
  aria-controls="dialog-1">
  {{ 'Open dialog'|t }}
</button>
```

### Live Regions

```twig
{# Status messages (polite — doesn't interrupt) #}
<div aria-live="polite" aria-atomic="true" id="status-region"></div>

{# Urgent alerts (assertive — interrupts immediately — use sparingly) #}
<div aria-live="assertive" aria-atomic="true" id="alert-region"></div>
```

---

## Focus Management

### Visible Focus Indicator (WCAG 2.4.7, 2.4.11)

```scss
// Minimum — 2px outline with offset
:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}

// Never do this:
// :focus { outline: none; }
```

### Modal / Dialog Focus (WCAG 2.1.2)

When a dialog opens:
1. Move focus to the dialog container or first focusable element
2. Trap focus within the dialog (Tab cycles inside, Shift+Tab reverses)
3. On close, return focus to the trigger element

```javascript
// Focus trap pattern
once('dialog-focus', '[role="dialog"]', context).forEach((dialog) => {
  const focusable = dialog.querySelectorAll(
    'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  dialog.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;
    if (e.shiftKey) {
      if (document.activeElement === first) { e.preventDefault(); last.focus(); }
    } else {
      if (document.activeElement === last) { e.preventDefault(); first.focus(); }
    }
  });
});
```

---

## Colour Contrast

| Text type | Minimum ratio | WCAG criterion |
|---|---|---|
| Normal text (< 18pt) | 4.5:1 | 1.4.3 |
| Large text (≥ 18pt or ≥ 14pt bold) | 3:1 | 1.4.3 |
| UI components, focus indicators | 3:1 | 1.4.11 |

Tools:
- Browser DevTools → Accessibility pane → colour contrast
- `yarn test:nightwatch --tag a11y` — Drupal's built-in accessibility test suite

---

## Images (WCAG 1.1.1)

```twig
{# Informative image #}
<img src="{{ image_url }}" alt="{{ image.alt }}">

{# Decorative image — empty alt, not missing alt #}
<img src="{{ decorative_url }}" alt="">

{# SVG icons — title for meaning, aria-hidden for decoration #}
<svg aria-hidden="true" focusable="false">...</svg>
<svg role="img" aria-labelledby="icon-title">
  <title id="icon-title">{{ 'Download'|t }}</title>
  ...
</svg>
```

---

## Tables (WCAG 1.3.1)

```twig
<table>
  <caption>{{ 'Upcoming events'|t }}</caption>
  <thead>
    <tr>
      <th scope="col">{{ 'Date'|t }}</th>
      <th scope="col">{{ 'Title'|t }}</th>
    </tr>
  </thead>
  <tbody>
    {% for row in rows %}
    <tr>
      <td>{{ row.date }}</td>
      <td>{{ row.title }}</td>
    </tr>
    {% endfor %}
  </tbody>
</table>
```

---

## Common Anti-Patterns

| Anti-pattern | Fix |
|---|---|
| `:focus { outline: none }` | Use `:focus-visible` with visible indicator |
| `<div onclick="...">` | Use `<button>` for actions, `<a href>` for navigation |
| `tabindex="2"` (positive) | Remove — restructure DOM order instead |
| `aria-label` on non-interactive element | Use native semantic element or visually-hidden text |
| `role="presentation"` hiding meaningful content | Remove — use `aria-hidden="true"` only on truly decorative elements |
| Missing `alt` entirely | `alt=""` for decorative, descriptive text for meaningful |
| Colour as sole error indicator | Add text label, icon, or pattern alongside colour |
