| name | accessibility-auditor |
| description | Audit UI components and pages for accessibility issues and suggest fixes to ensure Kibana is usable by everyone. |
Accessibility Auditor
Purpose
Audit UI components and pages for accessibility (a11y) issues and suggest fixes to ensure Kibana is usable by everyone.
Capabilities
- Run Scout a11y checks (page.checkA11y)
- Scan for common a11y issues: missing labels, low contrast, keyboard navigation
- Suggest fixes: add aria-label, increase contrast, add keyboard handlers
- Generate accessibility test suite
- Check WCAG 2.1 Level AA compliance
- Validate screen reader compatibility
Triggers
- "accessibility audit"
- "check a11y"
- "is this accessible"
- "add accessibility tests"
- Auto-trigger from spike-builder QA phase
Implementation
1. Scout Accessibility Checks
Scout provides built-in a11y checks via axe-core integration:
import { expect, test } from '@kbn/scout';
test.describe('Accessibility', () => {
test('should have no a11y violations on main page', async ({ page }) => {
await page.goto('/app/my-feature');
await page.waitForLoadState('networkidle');
const violations = await page.checkA11y();
expect(violations).toHaveLength(0);
});
test('should have no violations in modal', async ({ page }) => {
await page.goto('/app/my-feature');
await page.getByRole('button', { name: 'Open Settings' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
const violations = await page.checkA11y({ include: [['[role="dialog"]']] });
expect(violations).toHaveLength(0);
});
test('keyboard navigation should work', async ({ page }) => {
await page.goto('/app/my-feature');
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: 'First Button' })).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: 'Second Button' })).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.getByRole('dialog')).toBeVisible();
});
});
2. Common A11y Issues Checklist
Missing Labels
<EuiButton onClick={handleClick}>
<EuiIcon type="trash" />
</EuiButton>
<EuiButton onClick={handleClick} aria-label="Delete item">
<EuiIcon type="trash" />
</EuiButton>
<EuiButton onClick={handleClick} iconType="trash">
Delete
</EuiButton>
Detection:
grep -rn "EuiButton.*iconType" --include="*.tsx" | \
grep -v "aria-label" | \
grep -v "children"
Low Color Contrast
<EuiText color="#999999">Important message</EuiText>
<EuiText color="danger">Important message</EuiText>
<EuiText color="subdued">Secondary text</EuiText>
Detection:
Scout's checkA11y will flag contrast issues automatically.
Missing Form Labels
<EuiFieldText
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<EuiFormRow label="Email address">
<EuiFieldText
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</EuiFormRow>
<EuiFieldText
aria-label="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
Detection:
grep -rn "EuiFieldText\|EuiFieldNumber\|EuiFieldPassword" --include="*.tsx" | \
while read -r line; do
file=$(echo "$line" | cut -d: -f1)
linenum=$(echo "$line" | cut -d: -f2)
context=$(sed -n "$((linenum-5)),$((linenum+2))p" "$file")
if ! echo "$context" | grep -q "EuiFormRow\|aria-label"; then
echo "Missing label: $line"
fi
done
Missing Heading Hierarchy
<h1>Page Title</h1>
<h3>Section</h3>
<h1>Page Title</h1>
<h2>Main Section</h2>
<h3>Subsection</h3>
<EuiTitle size="l"><h1>Page Title</h1></EuiTitle>
<EuiTitle size="m"><h2>Main Section</h2></EuiTitle>
<EuiTitle size="s"><h3>Subsection</h3></EuiTitle>
Keyboard Navigation
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleClick();
}
}}
>
Custom button
</div>
<EuiButton onClick={handleClick}>
Native button (handles keyboard automatically)
</EuiButton>
ARIA Roles and States
<div role="tablist">
<button role="tab" aria-selected={activeTab === 0} aria-controls="panel-0">
Tab 1
</button>
<button role="tab" aria-selected={activeTab === 1} aria-controls="panel-1">
Tab 2
</button>
</div>
<div role="tabpanel" id="panel-0" hidden={activeTab !== 0}>
Panel 1 content
</div>
<div aria-live="polite" aria-atomic="true">
{statusMessage}
</div>
3. Generate Accessibility Test Suite
import { expect, test } from '@kbn/scout';
test.describe('My Feature - Accessibility', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/app/my-feature');
await page.waitForLoadState('networkidle');
});
test('main page should have no a11y violations', async ({ page }) => {
const violations = await page.checkA11y();
expect(violations).toHaveLength(0);
});
test('should have proper heading hierarchy', async ({ page }) => {
const h1Count = await page.locator('h1').count();
expect(h1Count).toBe(1);
const headings = await page.locator('h1, h2, h3, h4, h5, h6').all();
const levels = await Promise.all(
headings.map(async (h) => {
const tagName = await h.evaluate((el) => el.tagName);
return parseInt(tagName[1]);
})
);
for (let i = 1; i < levels.length; i++) {
const diff = levels[i] - levels[i - 1];
expect(diff).toBeLessThanOrEqual(1);
}
});
test('all interactive elements should be keyboard accessible', async ({ page }) => {
const buttons = page.locator('button, [role="button"]');
const buttonCount = await buttons.count();
for (let i = 0; i < buttonCount; i++) {
const button = buttons.nth(i);
const tabIndex = await button.getAttribute('tabindex');
const tagName = await button.evaluate((el) => el.tagName);
if (tagName !== 'BUTTON' && (!tabIndex || parseInt(tabIndex) < 0)) {
const text = await button.textContent();
throw new Error(`Button "${text}" is not keyboard accessible`);
}
}
});
test('all images should have alt text', async ({ page }) => {
const images = await page.locator('img').all();
for (const img of images) {
const alt = await img.getAttribute('alt');
expect(alt).not.toBeNull();
const role = await img.getAttribute('role');
if (role === 'presentation') {
expect(alt).toBe('');
}
}
});
test('form inputs should have labels', async ({ page }) => {
const inputs = await page.locator('input, textarea, select').all();
for (const input of inputs) {
const id = await input.getAttribute('id');
const ariaLabel = await input.getAttribute('aria-label');
const ariaLabelledBy = await input.getAttribute('aria-labelledby');
const hasLabel =
ariaLabel ||
ariaLabelledBy ||
(id && (await page.locator(`label[for="${id}"]`).count()) > 0);
expect(hasLabel).toBeTruthy();
}
});
test('modals should trap focus', async ({ page }) => {
await page.getByRole('button', { name: 'Open Settings' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
const focusable = modal.locator(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusable.first();
const lastFocusable = focusable.last();
await lastFocusable.focus();
await page.keyboard.press('Tab');
await expect(firstFocusable).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(lastFocusable).toBeFocused();
});
test('should support screen reader announcements', async ({ page }) => {
const liveRegions = page.locator('[aria-live]');
expect(await liveRegions.count()).toBeGreaterThan(0);
const toasts = page.locator('.euiToast');
for (let i = 0; i < (await toasts.count()); i++) {
const role = await toasts.nth(i).getAttribute('role');
expect(['status', 'alert']).toContain(role);
}
});
});
4. Run Accessibility Audit
node scripts/scout run-tests \
--arch stateful \
--domain classic \
--testFiles "x-pack/test/scout/features/my_feature/my_feature.a11y.scout.ts"
node scripts/scout run-tests \
--arch stateful \
--domain classic \
--config "x-pack/test/scout/config/stateful/accessibility.config.ts"
5. Manual Testing Checklist
Beyond automated tests, perform manual checks:
## Manual Accessibility Testing
### Keyboard Navigation
- [ ] Tab through all interactive elements
- [ ] Shift+Tab works in reverse
- [ ] Enter/Space activates buttons
- [ ] Arrow keys work in custom widgets (tabs, menus)
- [ ] Focus indicator is visible
- [ ] Focus order is logical (left-to-right, top-to-bottom)
- [ ] Modal/dialog traps focus
### Screen Reader (VoiceOver/NVDA/JAWS)
- [ ] All content is announced
- [ ] Form labels are read correctly
- [ ] Button purposes are clear
- [ ] Error messages are announced
- [ ] Status updates are announced (aria-live)
- [ ] Images have meaningful alt text
- [ ] Headings provide page structure
### Visual
- [ ] Text meets contrast requirements (4.5:1 for normal, 3:1 for large)
- [ ] Focus indicator is visible
- [ ] Color is not the only indicator (use icons + color)
- [ ] UI is usable at 200% zoom
- [ ] Text can be resized without breaking layout
### Cognitive
- [ ] Error messages are clear and actionable
- [ ] Instructions are easy to understand
- [ ] Timeout warnings are provided
- [ ] Consistent navigation patterns
6. Quick Fix Script
#!/bin/bash
file="$1"
if [ ! -f "$file" ]; then
echo "Usage: $0 <file.tsx>"
exit 1
fi
echo "Scanning $file for common a11y issues..."
if grep -q "EuiButton.*iconType" "$file"; then
echo ""
echo "⚠️ Found buttons with only icons (may need aria-label):"
grep -n "EuiButton.*iconType" "$file" | grep -v "aria-label" | grep -v "children"
echo ""
echo "Fix: Add aria-label or visible text"
echo " <EuiButton iconType=\"trash\" aria-label=\"Delete item\" />"
fi
if grep -E -q "EuiFieldText|EuiFieldNumber|EuiFieldPassword" "$file"; then
echo ""
echo "⚠️ Found form inputs (verify they have labels):"
grep -n -E "EuiFieldText|EuiFieldNumber|EuiFieldPassword" "$file"
echo ""
echo "Fix: Wrap in EuiFormRow or add aria-label"
fi
if grep -q "onClick.*<div\|onClick.*<span" "$file"; then
echo ""
echo "⚠️ Found onClick on non-button elements (may need keyboard support):"
grep -n "onClick.*<div\|onClick.*<span" "$file"
echo ""
echo "Fix: Add role, tabIndex, and onKeyDown"
echo " <div role=\"button\" tabIndex={0} onKeyDown={handleKeyDown} onClick={handleClick} />"
fi
echo ""
echo "Run Scout a11y test to verify:"
echo " node scripts/scout run-tests --testFiles path/to/test.a11y.scout.ts"
Example Workflow
User: "check accessibility of my feature"
Step 1: Generate a11y test suite
cat > x-pack/test/scout/features/my_feature/my_feature.a11y.scout.ts <<'EOF'
[Generated test content from template above]
EOF
Step 2: Run Scout a11y tests
node scripts/scout run-tests \
--arch stateful \
--domain classic \
--testFiles "x-pack/test/scout/features/my_feature/my_feature.a11y.scout.ts"
Step 3: Parse violations
Found 3 accessibility violations:
1. Missing aria-label on icon button (line 45)
- Element: <EuiButton iconType="trash" />
- Fix: Add aria-label="Delete item"
2. Insufficient color contrast (line 78)
- Element: <EuiText color="#999999">
- Contrast: 3.2:1 (needs 4.5:1)
- Fix: Use color="subdued" or darker color
3. Missing form label (line 102)
- Element: <EuiFieldText />
- Fix: Wrap in <EuiFormRow label="Email address">
Step 4: Apply fixes
- <EuiButton iconType="trash" onClick={handleDelete} />
+ <EuiButton iconType="trash" onClick={handleDelete} aria-label="Delete item" />
- <EuiText color="#999999">Secondary text</EuiText>
+ <EuiText color="subdued">Secondary text</EuiText>
- <EuiFieldText value={email} onChange={handleChange} />
+ <EuiFormRow label="Email address">
+ <EuiFieldText value={email} onChange={handleChange} />
+ </EuiFormRow>
Step 5: Re-run tests
node scripts/scout run-tests --testFiles my_feature.a11y.scout.ts
Integration with Other Skills
- spike-builder: Auto-run a11y audit in QA phase
- frontend-design-review: Include a11y checklist
- pr-optimizer: Suggest a11y tests for UI PRs
Quality Principles
- Accessibility is not optional (WCAG 2.1 Level AA required)
- Test with real assistive technologies (screen readers, keyboard only)
- Use semantic HTML and ARIA where needed
- Automated tests catch 30-40% of issues; manual testing required
- Consider cognitive accessibility (clear language, error messages)
References