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.
How 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.
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 fieldsnpx claudepluginhub saadrahman01/claude-moodle-dev --plugin moodle-devWeb 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.
Builds WCAG AA-compliant web UIs with semantic HTML, ARIA roles, keyboard navigation, color contrast checks, and accessibility testing strategies. Ideal for frontend inclusive design.
Implements WCAG 2.1/2.2 compliance, ARIA patterns, keyboard navigation, focus management, and accessibility testing for web components.