Claude Code Plugins

Community-maintained marketplace

Feedback

accessibility-testing

@aj-geddes/useful-ai-prompts
16
0

Test web applications for WCAG compliance and ensure usability for users with disabilities. Use for accessibility test, a11y, axe, ARIA, keyboard navigation, screen reader compatibility, and WCAG validation.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name accessibility-testing
description Test web applications for WCAG compliance and ensure usability for users with disabilities. Use for accessibility test, a11y, axe, ARIA, keyboard navigation, screen reader compatibility, and WCAG validation.

Accessibility Testing

Overview

Accessibility testing ensures web applications are usable by people with disabilities, including those using screen readers, keyboard navigation, or other assistive technologies. It validates compliance with WCAG (Web Content Accessibility Guidelines) and identifies barriers to accessibility.

When to Use

  • Validating WCAG 2.1/2.2 compliance
  • Testing keyboard navigation
  • Verifying screen reader compatibility
  • Testing color contrast ratios
  • Validating ARIA attributes
  • Testing form accessibility
  • Ensuring focus management
  • Testing with assistive technologies

WCAG Levels

  • Level A: Basic accessibility (must have)
  • Level AA: Intermediate accessibility (should have, legal requirement in many jurisdictions)
  • Level AAA: Advanced accessibility (nice to have)

Instructions

1. axe-core with Playwright

// tests/accessibility/homepage.a11y.test.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('Homepage Accessibility', () => {
  test('should not have any automatically detectable WCAG A or AA violations', async ({
    page,
  }) => {
    await page.goto('/');

    const accessibilityScanResults = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
      .analyze();

    expect(accessibilityScanResults.violations).toEqual([]);
  });

  test('navigation should be accessible', async ({ page }) => {
    await page.goto('/');

    const results = await new AxeBuilder({ page })
      .include('nav')
      .analyze();

    expect(results.violations).toEqual([]);
  });

  test('form should be accessible', async ({ page }) => {
    await page.goto('/contact');

    const results = await new AxeBuilder({ page })
      .include('form')
      .withTags(['wcag2a', 'wcag2aa'])
      .analyze();

    expect(results.violations).toEqual([]);

    // Additional form checks
    const inputs = await page.locator('input, select, textarea').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');

      // Every input should have an associated label
      if (id) {
        const label = await page.locator(`label[for="${id}"]`).count();
        expect(
          label > 0 || ariaLabel || ariaLabelledBy,
          `Input with id="${id}" has no associated label`
        ).toBeTruthy();
      }
    }
  });

  test('images should have alt text', async ({ page }) => {
    await page.goto('/');

    const images = await page.locator('img').all();

    for (const img of images) {
      const alt = await img.getAttribute('alt');
      const role = await img.getAttribute('role');
      const ariaLabel = await img.getAttribute('aria-label');

      // Decorative images should have empty alt or role="presentation"
      // Content images must have descriptive alt text
      expect(
        alt !== null || role === 'presentation' || ariaLabel,
        'Image missing alt text'
      ).toBeTruthy();
    }
  });

  test('color contrast should meet AA standards', async ({ page }) => {
    await page.goto('/');

    const results = await new AxeBuilder({ page })
      .withTags(['cat.color'])
      .analyze();

    expect(results.violations).toEqual([]);
  });
});

2. Keyboard Navigation Testing

// tests/accessibility/keyboard-navigation.test.ts
import { test, expect } from '@playwright/test';

test.describe('Keyboard Navigation', () => {
  test('should navigate through focusable elements with Tab', async ({
    page,
  }) => {
    await page.goto('/');

    // Get all focusable elements
    const focusableSelectors =
      'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])';

    const focusableElements = await page.locator(focusableSelectors).all();

    // Tab through all elements
    for (let i = 0; i < focusableElements.length; i++) {
      await page.keyboard.press('Tab');

      const focusedElement = await page.evaluate(() => {
        return {
          tagName: document.activeElement?.tagName,
          id: document.activeElement?.id,
          className: document.activeElement?.className,
        };
      });

      expect(focusedElement.tagName).toBeTruthy();
    }
  });

  test('should skip navigation with skip link', async ({ page }) => {
    await page.goto('/');

    // Tab to skip link (usually first focusable element)
    await page.keyboard.press('Tab');

    const skipLink = await page.locator('.skip-link');
    await expect(skipLink).toBeFocused();

    // Activate skip link
    await page.keyboard.press('Enter');

    // Focus should be on main content
    const focusedElement = await page.evaluate(() => {
      return document.activeElement?.id;
    });

    expect(focusedElement).toBe('main-content');
  });

  test('modal should trap focus', async ({ page }) => {
    await page.goto('/');

    // Open modal
    await page.click('[data-testid="open-modal"]');
    await page.waitForSelector('[role="dialog"]');

    const modal = page.locator('[role="dialog"]');
    const focusableInModal = modal.locator(
      'a[href], button, input, select, textarea'
    );

    const count = await focusableInModal.count();

    // Tab through all elements in modal
    for (let i = 0; i < count + 2; i++) {
      await page.keyboard.press('Tab');
    }

    // Focus should still be within modal
    const focusedElement = await page.evaluate(() => {
      const modal = document.querySelector('[role="dialog"]');
      return modal?.contains(document.activeElement);
    });

    expect(focusedElement).toBe(true);
  });

  test('dropdown menu should be keyboard accessible', async ({ page }) => {
    await page.goto('/');

    // Navigate to dropdown trigger
    await page.keyboard.press('Tab');
    const dropdown = page.locator('[data-testid="dropdown-menu"]');
    await dropdown.focus();

    // Open dropdown with Enter
    await page.keyboard.press('Enter');

    // Menu should be visible
    const menu = page.locator('[role="menu"]');
    await expect(menu).toBeVisible();

    // Navigate menu items with arrow keys
    await page.keyboard.press('ArrowDown');
    const firstItem = menu.locator('[role="menuitem"]').first();
    await expect(firstItem).toBeFocused();

    await page.keyboard.press('ArrowDown');
    const secondItem = menu.locator('[role="menuitem"]').nth(1);
    await expect(secondItem).toBeFocused();

    // Escape should close menu
    await page.keyboard.press('Escape');
    await expect(menu).not.toBeVisible();
    await expect(dropdown).toBeFocused();
  });

  test('form can be completed using keyboard only', async ({ page }) => {
    await page.goto('/contact');

    // Tab to first field
    await page.keyboard.press('Tab');
    await page.keyboard.type('John Doe');

    // Tab to email field
    await page.keyboard.press('Tab');
    await page.keyboard.type('john@example.com');

    // Tab to message
    await page.keyboard.press('Tab');
    await page.keyboard.type('Test message');

    // Tab to submit and activate
    await page.keyboard.press('Tab');
    await page.keyboard.press('Enter');

    // Check form was submitted
    await expect(page.locator('.success-message')).toBeVisible();
  });
});

3. ARIA Testing

// tests/accessibility/aria.test.ts
import { test, expect } from '@playwright/test';

test.describe('ARIA Attributes', () => {
  test('buttons should have accessible names', async ({ page }) => {
    await page.goto('/');

    const buttons = await page.locator('button').all();

    for (const button of buttons) {
      const text = await button.textContent();
      const ariaLabel = await button.getAttribute('aria-label');
      const ariaLabelledBy = await button.getAttribute('aria-labelledby');

      expect(
        text?.trim() || ariaLabel || ariaLabelledBy,
        'Button has no accessible name'
      ).toBeTruthy();
    }
  });

  test('icons should have aria-hidden or labels', async ({ page }) => {
    await page.goto('/');

    const icons = await page
      .locator('[class*="icon"], svg[class*="icon"]')
      .all();

    for (const icon of icons) {
      const ariaHidden = await icon.getAttribute('aria-hidden');
      const ariaLabel = await icon.getAttribute('aria-label');
      const title = await icon.locator('title').count();

      // Icon should be hidden from screen readers OR have a label
      expect(
        ariaHidden === 'true' || ariaLabel || title > 0,
        'Icon without aria-hidden or accessible name'
      ).toBeTruthy();
    }
  });

  test('custom widgets should have correct roles', async ({ page }) => {
    await page.goto('/components');

    // Tab widget
    const tablist = page.locator('[role="tablist"]');
    await expect(tablist).toHaveCount(1);

    const tabs = tablist.locator('[role="tab"]');
    const tabpanels = page.locator('[role="tabpanel"]');

    expect(await tabs.count()).toBeGreaterThan(0);
    expect(await tabs.count()).toBe(await tabpanels.count());

    // Check aria-selected
    const selectedTab = tabs.locator('[aria-selected="true"]');
    await expect(selectedTab).toHaveCount(1);

    // Check tab associations
    const firstTab = tabs.first();
    const ariaControls = await firstTab.getAttribute('aria-controls');
    const associatedPanel = page.locator(`[id="${ariaControls}"]`);
    await expect(associatedPanel).toHaveCount(1);
  });

  test('live regions announce changes', async ({ page }) => {
    await page.goto('/');

    // Find live region
    const liveRegion = page.locator('[role="status"], [aria-live]');

    // Trigger update
    await page.click('[data-testid="load-data"]');

    // Wait for content
    await liveRegion.waitFor({ state: 'visible' });

    const ariaLive = await liveRegion.getAttribute('aria-live');
    expect(['polite', 'assertive']).toContain(ariaLive);
  });
});

4. Jest with jest-axe

// tests/components/Button.a11y.test.tsx
import React from 'react';
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Button } from '../Button';

expect.extend(toHaveNoViolations);

describe('Button Accessibility', () => {
  test('should not have accessibility violations', async () => {
    const { container } = render(<Button>Click me</Button>);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  test('icon button should have aria-label', async () => {
    const { container } = render(
      <Button aria-label="Close">
        <CloseIcon />
      </Button>
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  test('disabled button should be accessible', async () => {
    const { container } = render(<Button disabled>Disabled</Button>);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

5. Cypress Accessibility Testing

// cypress/e2e/accessibility.cy.js
describe('Accessibility Tests', () => {
  beforeEach(() => {
    cy.visit('/');
    cy.injectAxe();
  });

  it('has no detectable a11y violations on load', () => {
    cy.checkA11y();
  });

  it('navigation is accessible', () => {
    cy.checkA11y('nav');
  });

  it('focuses on first error when form submission fails', () => {
    cy.get('form').within(() => {
      cy.get('[type="submit"]').click();
    });

    cy.focused().should('have.attr', 'aria-invalid', 'true');
  });

  it('modal has correct focus management', () => {
    cy.get('[data-cy="open-modal"]').click();

    // Focus should be in modal
    cy.get('[role="dialog"]').should('exist');
    cy.focused().parents('[role="dialog"]').should('exist');

    // Close modal with Escape
    cy.get('body').type('{esc}');
    cy.get('[role="dialog"]').should('not.exist');

    // Focus returns to trigger
    cy.get('[data-cy="open-modal"]').should('have.focus');
  });
});

6. Python with Selenium and axe

# tests/test_accessibility.py
import pytest
from selenium import webdriver
from axe_selenium_python import Axe

class TestAccessibility:
    @pytest.fixture
    def driver(self):
        """Setup Chrome driver."""
        options = webdriver.ChromeOptions()
        options.add_argument('--headless')
        driver = webdriver.Chrome(options=options)
        yield driver
        driver.quit()

    def test_homepage_accessibility(self, driver):
        """Test homepage for WCAG violations."""
        driver.get('http://localhost:3000')

        axe = Axe(driver)
        axe.inject()

        # Run axe accessibility tests
        results = axe.run()

        # Assert no violations
        assert len(results['violations']) == 0, \
            axe.report(results['violations'])

    def test_form_accessibility(self, driver):
        """Test form accessibility."""
        driver.get('http://localhost:3000/contact')

        axe = Axe(driver)
        axe.inject()

        # Run with specific tags
        results = axe.run(options={
            'runOnly': {
                'type': 'tag',
                'values': ['wcag2a', 'wcag2aa', 'wcag21aa']
            }
        })

        violations = results['violations']
        assert len(violations) == 0, \
            f"Found {len(violations)} accessibility violations"

    def test_keyboard_navigation(self, driver):
        """Test keyboard navigation."""
        from selenium.webdriver.common.keys import Keys
        from selenium.webdriver.common.by import By

        driver.get('http://localhost:3000')

        body = driver.find_element(By.TAG_NAME, 'body')

        # Tab through focusable elements
        for _ in range(10):
            body.send_keys(Keys.TAB)

            active = driver.switch_to.active_element
            tag_name = active.tag_name

            # Focused element should be interactive
            assert tag_name in ['a', 'button', 'input', 'select', 'textarea'], \
                f"Unexpected focused element: {tag_name}"

Manual Testing Checklist

Keyboard Navigation

  • All interactive elements accessible via Tab
  • Shift+Tab navigates backwards
  • Enter/Space activates buttons/links
  • Arrow keys work in custom widgets
  • Escape closes modals/dropdowns
  • Skip links present and functional
  • Focus visible on all elements
  • No keyboard traps

Screen Readers

  • Page has descriptive title
  • Headings form logical hierarchy
  • Images have alt text
  • Form inputs have labels
  • Error messages announced
  • Dynamic content changes announced
  • Links have descriptive text
  • Custom widgets have ARIA roles

Visual

  • Color contrast meets AA (4.5:1 normal, 3:1 large text)
  • Information not conveyed by color alone
  • Text can be resized 200% without breaking
  • Focus indicators visible
  • Content readable in dark/light modes

Best Practices

✅ DO

  • Test with real assistive technologies
  • Include keyboard-only users
  • Test color contrast
  • Use semantic HTML
  • Provide text alternatives
  • Test with screen readers
  • Run automated tests in CI
  • Follow WCAG 2.1 AA standards

❌ DON'T

  • Rely only on automated tests (they catch ~30-40% of issues)
  • Use color alone to convey information
  • Skip keyboard navigation testing
  • Forget focus management in dynamic content
  • Use div/span for interactive elements
  • Hide focusable content with display:none
  • Ignore ARIA best practices
  • Skip manual testing

Tools

  • Automated Testing: axe-core, Pa11y, Lighthouse, WAVE
  • Browser Extensions: axe DevTools, WAVE, Accessibility Insights
  • Screen Readers: NVDA, JAWS, VoiceOver, TalkBack
  • Color Contrast: WebAIM Contrast Checker, Stark
  • Frameworks: jest-axe, cypress-axe, axe-playwright

Standards

  • WCAG 2.1: Web Content Accessibility Guidelines
  • WCAG 2.2: Latest version (2023)
  • Section 508: US federal accessibility standard
  • EN 301 549: European accessibility standard
  • ADA: Americans with Disabilities Act

Examples

See also: e2e-testing-automation, visual-regression-testing, accessibility-compliance for comprehensive accessibility implementation.