Help us improve
Share bugs, ideas, or general feedback.
From moodle-dev
Ensures Moodle plugin UI meets WCAG 2.1 AA with semantic Mustache, ARIA helpers, keyboard navigation, color contrast in SCSS, focus management, and Pa11y/axe automation.
npx claudepluginhub saadrahman01/claude-moodle-dev --plugin moodle-devHow this skill is triggered — by the user, by Claude, or both
Slash command
/moodle-dev:moodle-accessibilityThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Moodle targets WCAG 2.1 AA. UI in plugins must follow same standard for inclusion. Boost theme + Bootstrap 5 + Moodle's component library do most of the heavy lifting — your job is to use them correctly.
Web accessibility discipline: semantic HTML first, ARIA only when needed, keyboard access always. Invoke whenever task involves any interaction with accessible web content -- writing, reviewing, refactoring, or debugging HTML/CSS/JS for WCAG compliance, ARIA usage, keyboard navigation, focus management, screen reader support, or accessible component patterns.
Implements WCAG 2.1/2.2 compliance, ARIA patterns, keyboard navigation, focus management, and accessibility testing for web components.
Enforces WCAG AA and ARIA best practices for screen readers, keyboard navigation, and focus management. Use when building or auditing user-facing interfaces for accessibility compliance.
Share bugs, ideas, or general feedback.
Moodle targets WCAG 2.1 AA. UI in plugins must follow same standard for inclusion. Boost theme + Bootstrap 5 + Moodle's component library do most of the heavy lifting — your job is to use them correctly.
<button> for actions, <a> for navigation, <h1>–<h6> in order:focusalt; decorative images: alt=""<label for> linked to every input{{! BAD }}
<div class="btn" onclick="...">Save</div>
{{! GOOD }}
<button type="submit" class="btn btn-primary">{{#str}}save, core{{/str}}</button>
Headings:
<h2 id="ann-heading">{{#str}}announcements, local_example{{/str}}</h2>
<section aria-labelledby="ann-heading">...</section>
Skip levels = WCAG fail. <h2> then <h4> is wrong.
MoodleQuickForm outputs <label for> automatically. Don't bypass:
$mform->addElement('text', 'name', get_string('name', 'local_example'));
$mform->setType('name', PARAM_TEXT);
$mform->addRule('name', null, 'required', null, 'client');
// Help button — adds aria-described relationship
$mform->addHelpButton('name', 'name', 'local_example');
For required fields, addRule('required') adds aria-required="true".
| Element | Use for |
|---|---|
<button type="submit"> | Form submit |
<button type="button"> | JS action |
<a href="..."> | Navigation to a URL |
Never <a href="#" onclick> — use <button>. Never <div role="button"> unless you also handle keyboard (Enter + Space) and focus.
{{#pix}}t/edit, core, {{#str}}edit, core{{/str}}{{/pix}}
Pix renderer outputs <img alt="..."> with the title as alt. For decorative icons accompanying visible text:
<button>
{{#pix}}t/edit, core{{/pix}}<span class="visually-hidden"></span>
{{#str}}edit, core{{/str}}
</button>
Bootstrap 5: .visually-hidden (was .sr-only in BS4).
Boost defines $primary, $secondary, etc. Don't override to low-contrast values.
// theme/yourtheme/scss/post.scss
$primary: #0f6fc5; // contrast vs white = 5.13:1 ✓
$danger: #d9534f; // contrast vs white = 3.34:1 ✗ — fails AA for text
Test: https://webaim.org/resources/contrastchecker/
Don't rely on color only:
{{! BAD — color only }}
<span class="text-danger">{{name}}</span>
{{! GOOD — icon + color }}
<span class="text-danger">
{{#pix}}t/error, core, {{#str}}invalid, local_example{{/str}}{{/pix}}
{{name}}
</span>
<table class="table">
<caption>{{#str}}attendance, local_example{{/str}}</caption>
<thead>
<tr>
<th scope="col">{{#str}}user, core{{/str}}</th>
<th scope="col">{{#str}}status, core{{/str}}</th>
</tr>
</thead>
<tbody>
{{#rows}}
<tr>
<th scope="row">{{name}}</th>
<td>{{status}}</td>
</tr>
{{/rows}}
</tbody>
</table>
html_table from lib/outputcomponents.php renders accessibly:
$table = new \html_table();
$table->head = [get_string('name'), get_string('status')];
$table->headspan = [1, 1];
$table->caption = get_string('attendance', 'local_example');
echo \html_writer::table($table);
Native HTML > ARIA. Only add ARIA when no native equivalent.
{{! tab pattern — needs ARIA }}
<div role="tablist" aria-label="{{#str}}sections, local_example{{/str}}">
<button role="tab" aria-selected="true" aria-controls="panel-1" id="tab-1">One</button>
<button role="tab" aria-selected="false" aria-controls="panel-2" id="tab-2">Two</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">...</div>
Bootstrap 5 tab JS handles arrow keys. Don't reimplement.
Use core/modal — handles focus trap, ESC key, focus restore on close.
import Modal from 'core/modal';
const modal = await Modal.create({
title: await getString('confirm', 'core'),
body: await Templates.render('local_example/confirm', {}),
show: true,
removeOnClose: true,
});
// focus auto-traps inside; on close, focus returns to trigger element
Custom focus management:
import {trapFocus} from 'core/local/aria/focusmanager';
const release = trapFocus(modalEl);
// ... when closing:
release();
triggerEl.focus();
import {add as addToast} from 'core/toast';
addToast('Saved', {type: 'success'});
core/toast uses aria-live="polite". For urgent alerts: aria-live="assertive" (sparingly — interrupts screen readers).
Every interactive control:
tabindex="0" everything)outline: 0 without replacement)| OS | SR | Browser |
|---|---|---|
| Windows | NVDA (free) | Firefox |
| macOS | VoiceOver | Safari |
| Linux | Orca | Firefox |
| iOS | VoiceOver | Safari |
| Android | TalkBack | Chrome |
Quick checks:
# axe-core via Puppeteer
npm install -g @axe-core/cli
axe http://localhost:8000/local/example/
# Pa11y
npm install -g pa11y
pa11y --standard WCAG2AA http://localhost:8000/local/example/
CI integration: run on PRs against staging.
| Mistake | Fix |
|---|---|
<div onclick> | <button> |
Missing alt on <img> | Add — alt="" if decorative |
placeholder as label | Add real <label for> |
outline: 0 no replacement | Restore visible focus indicator |
| Heading level skip | Sequential <h1>/<h2>/<h3> |
| Link text "click here" / "more" | Descriptive (Edit user "Alice") |
role="button" without keyboard | Use <button> instead |
| Color-only error indication | Add icon + text |
| Color contrast < 4.5:1 | Adjust SCSS palette |
| Modal without focus trap | Use core/modal |
aria-label on a <div> with no role | Either add role or use text instead |
aria-hidden="true" on focusable element | Either unhide or remove from tab order |
moodle.org reviewers run a11y checks. Common rejection reasons:
<label> on form fields