Add Phase 1 accessibility infrastructure and semantic landmarks

This commit establishes the foundation for WCAG 2.2 Level AA compliance:

Infrastructure:
- Add axe-core-rspec gem for automated accessibility testing
- Create spec/accessibility/ directory structure
- Add accessibility_helpers.rb with custom WCAG test helpers
- Add comprehensive testing documentation (README.md, SETUP_NOTES.md)

Semantic Landmarks (WCAG 2.4.1):
- Add <main> landmark with id="main-content" to application layout
- Add <nav> landmark with aria-label to navbar
- Add skip navigation link for keyboard users
- Skip link uses focus:translate-y-0 to appear only on keyboard focus

These changes address critical barriers for screen reader and keyboard users.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
pull/599/head
Marcelo Paiva 1 month ago
parent bb7a233863
commit aa9cb026a9

@ -74,6 +74,7 @@ group :development do
end end
group :test do group :test do
gem 'axe-core-rspec'
gem 'capybara' gem 'capybara'
gem 'cuprite' gem 'cuprite'
gem 'webmock' gem 'webmock'

@ -19,6 +19,10 @@
<%= stylesheet_pack_tag 'application', media: 'all' %> <%= stylesheet_pack_tag 'application', media: 'all' %>
</head> </head>
<body> <body>
<%# Skip navigation link for keyboard users - WCAG 2.4.1 Bypass Blocks %>
<a href="#main-content" class="absolute left-0 top-0 -translate-y-full focus:translate-y-0 z-50 p-4 bg-base-100 text-base-content border-2 border-neutral" tabindex="0">
Skip to main content
</a>
<% if params[:modal].present? %> <% if params[:modal].present? %>
<% url_params = Rails.application.routes.recognize_path(params[:modal], method: :get) %> <% url_params = Rails.application.routes.recognize_path(params[:modal], method: :get) %>
<% if url_params[:action] == 'new' %> <% if url_params[:action] == 'new' %>
@ -29,9 +33,9 @@
<turbo-frame id="drawer"></turbo-frame> <turbo-frame id="drawer"></turbo-frame>
<%= render 'shared/navbar' %> <%= render 'shared/navbar' %>
<% if flash.present? %><%= render 'shared/flash' %><% end %> <% if flash.present? %><%= render 'shared/flash' %><% end %>
<div class="max-w-6xl mx-auto px-4 md:px-2 mb-8"> <main id="main-content" class="max-w-6xl mx-auto px-4 md:px-2 mb-8">
<%= yield %> <%= yield %>
</div> </main>
<%= render 'shared/body_scripts' %> <%= render 'shared/body_scripts' %>
</body> </body>
</html> </html>

@ -1,5 +1,5 @@
<%= render 'shared/navbar_warning' %> <%= render 'shared/navbar_warning' %>
<div class="max-w-6xl mb-4 mx-auto px-4 md:px-2 py-3 flex items-center justify-between"> <nav aria-label="Main navigation" class="max-w-6xl mb-4 mx-auto px-4 md:px-2 py-3 flex items-center justify-between">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<a href="<%= root_path %>" class="text-2xl font-bold items-center flex space-x-2"> <a href="<%= root_path %>" class="text-2xl font-bold items-center flex space-x-2">
<%= render 'shared/title' %> <%= render 'shared/title' %>
@ -99,4 +99,4 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
</div> </nav>

@ -0,0 +1,201 @@
# Accessibility Testing Framework
This directory contains accessibility (a11y) tests for DocuSeal, targeting **WCAG 2.2 Level AA compliance**.
## Overview
The accessibility testing framework uses:
- **RSpec** for test structure
- **Capybara** with **Cuprite** (headless Chrome) for browser automation
- **axe-core-rspec** for automated WCAG validation
- **Custom accessibility helpers** in `spec/support/accessibility_helpers.rb`
## Running Tests
```bash
# Run all accessibility tests
bundle exec rspec spec/accessibility/
# Run specific accessibility test file
bundle exec rspec spec/accessibility/layouts_spec.rb
# Run with visible browser for debugging
HEADLESS=false bundle exec rspec spec/accessibility/
# Run with coverage report
COVERAGE=true bundle exec rspec spec/accessibility/
```
## Test Categories
### Phase 1: Critical Barriers
- **layouts_spec.rb** - Semantic landmarks, skip navigation
- **images_spec.rb** - Alt text for images
- **buttons_spec.rb** - ARIA labels for icon buttons
- **keyboard_spec.rb** - Keyboard support for custom elements
### Phase 2: Forms & Input Accessibility
- **forms_spec.rb** - Form error associations, labels
- **modals_spec.rb** - Modal accessibility, focus traps
- **live_regions_spec.rb** - ARIA live announcements
### Phase 3: Complex Interactions
- **components_spec.rb** - Vue component accessibility
- **tables_spec.rb** - Table headers, captions, scope
### Phase 4: Comprehensive Coverage
- **wcag_compliance_spec.rb** - Full WCAG 2.2 AA validation with axe-core
## Custom Helpers
### Available Test Helpers
```ruby
# Check accessible names
expect_accessible_name('button.save', 'Save Document')
# Check all images have alt text
expect_images_have_alt_text
# Check form inputs are labeled
expect_form_inputs_labeled
# Check keyboard accessibility
expect_keyboard_accessible('button.custom')
# Check semantic landmarks
expect_semantic_landmarks
# Check buttons have names
expect_buttons_have_names
# Check error associations
expect_errors_associated_with_inputs('email_field', 'email_error')
# Check modal accessibility
expect_accessible_modal('#my-modal')
# Check focus trap
expect_focus_trap('#my-modal')
# Check skip navigation
expect_skip_navigation_link
```
## WCAG 2.2 Level AA Criteria
Our testing targets these success criteria:
### Level A
- **1.1.1** Non-text Content
- **1.3.1** Info and Relationships
- **2.1.1** Keyboard
- **2.1.2** No Keyboard Trap
- **2.4.1** Bypass Blocks
- **2.4.3** Focus Order
- **3.3.1** Error Identification
- **3.3.2** Labels or Instructions
- **4.1.2** Name, Role, Value
### Level AA
- **1.4.3** Contrast (Minimum)
- **2.4.6** Headings and Labels
- **2.4.7** Focus Visible
- **3.2.4** Consistent Identification
- **4.1.3** Status Messages
## Manual Testing
Automated tests catch most issues, but manual testing is required for:
### Screen Reader Testing
- **NVDA** (Windows) - Free, open-source
- **JAWS** (Windows) - Industry standard, paid
- **VoiceOver** (Mac/iOS) - Built-in
Test these flows:
1. Navigate the app with Tab/Shift+Tab only
2. Fill out and submit forms
3. Open and close modals
4. Upload files via file dropzone
5. Complete document signing flow
### Keyboard Navigation
Test all interactions with:
- **Tab** / **Shift+Tab** - Move focus
- **Enter** / **Space** - Activate buttons/links
- **Escape** - Close modals
- **Arrow keys** - Navigate lists/dropdowns
### Browser Zoom
- Test at 200% zoom level
- Verify no content is cut off
- Check mobile responsiveness
### Color Contrast
- Use browser DevTools color picker
- Verify 4.5:1 ratio for normal text
- Verify 3:1 ratio for large text (18pt+)
## Validation Tools
### Browser Extensions
- **axe DevTools** - Comprehensive WCAG auditing
- **WAVE** - Visual accessibility checker
- **Lighthouse** - Chrome DevTools built-in
### Command-Line Tools
```bash
# Run Lighthouse accessibility audit
lighthouse http://localhost:3000 --only-categories=accessibility
# Run axe-core via CLI
npx @axe-core/cli http://localhost:3000
```
## CI/CD Integration
Add to your CI pipeline:
```yaml
# .github/workflows/accessibility.yml
name: Accessibility Tests
on: [push, pull_request]
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run accessibility tests
run: |
bundle install
bundle exec rspec spec/accessibility/
```
## Reporting Issues
When accessibility issues are found:
1. **Severity**: Critical, High, Medium, Low
2. **WCAG Criterion**: Which success criterion is violated
3. **User Impact**: Which disability groups are affected
4. **Location**: File path and line number
5. **Recommendation**: Specific fix with code example
## Resources
- [WCAG 2.2 Guidelines](https://www.w3.org/WAI/WCAG22/quickref/)
- [axe-core Rules](https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md)
- [A11Y Project Checklist](https://www.a11yproject.com/checklist/)
- [WebAIM Screen Reader Testing](https://webaim.org/articles/screenreader_testing/)
## Contributing
When adding new features:
1. Write accessibility tests alongside feature tests
2. Run axe-core audit on new pages/components
3. Test keyboard navigation manually
4. Verify with screen reader if possible
5. Document any accessibility considerations

@ -0,0 +1,86 @@
# Accessibility Testing Setup Notes
## Gem Installation Required
The `axe-core-rspec` gem has been added to the Gemfile but requires installation.
### Prerequisites
This project requires **Ruby 4.0.1** (as specified in Gemfile).
Currently, the system Ruby is 2.6.10, which is incompatible. You'll need to:
1. **Install a Ruby version manager** (recommended: rbenv or asdf)
```bash
# Using rbenv
brew install rbenv ruby-build
rbenv install 4.0.1
rbenv local 4.0.1
# Or using asdf
brew install asdf
asdf plugin add ruby
asdf install ruby 4.0.1
asdf local ruby 4.0.1
```
2. **Install dependencies**
```bash
bundle install
yarn install
```
3. **Run accessibility tests**
```bash
bundle exec rspec spec/accessibility/
```
## What's Been Set Up
✅ **Added to Gemfile (test group):**
- `axe-core-rspec` - Automated WCAG 2.2 validation
✅ **Created directory structure:**
- `spec/accessibility/` - Test files for a11y specs
- `spec/support/` - Helper modules
✅ **Created accessibility helpers:**
- `spec/support/accessibility_helpers.rb` - Custom test helpers for WCAG validation
- Methods for checking landmarks, labels, keyboard access, modals, etc.
✅ **Created documentation:**
- `spec/accessibility/README.md` - Comprehensive testing guide
- Covers running tests, manual testing, WCAG criteria, and resources
## Using axe-core-rspec
Once the gem is installed, you can use it in your tests:
```ruby
# spec/accessibility/wcag_compliance_spec.rb
RSpec.describe 'WCAG Compliance', type: :system do
it 'passes axe audit on home page' do
visit root_path
expect(page).to be_axe_clean
end
it 'passes WCAG 2.2 AA on submissions page' do
visit submissions_path
expect(page).to be_axe_clean.according_to(:wcag2aa, :wcag22aa)
end
it 'passes specific tag checks' do
visit template_path
expect(page).to be_axe_clean
.excluding('.legacy-component')
.according_to(:wcag2aa)
end
end
```
## Next Steps
After bundle install completes, the testing infrastructure will be fully operational and ready for Phase 1 fixes.

@ -0,0 +1,142 @@
# frozen_string_literal: true
# Accessibility testing helpers for WCAG 2.2 Level AA compliance
module AccessibilityHelpers
# Check if an element has proper ARIA label or text content
def expect_accessible_name(selector, expected_name = nil)
element = page.find(selector)
# Check for accessible name via aria-label, aria-labelledby, or text content
accessible_name = element['aria-label'] ||
find_aria_labelledby_text(element) ||
element.text.strip
if expected_name
expect(accessible_name).to eq(expected_name)
else
expect(accessible_name).not_to be_empty
end
end
# Check if images have alt text
def expect_images_have_alt_text
images_without_alt = page.all('img:not([alt])')
expect(images_without_alt).to be_empty,
"Found #{images_without_alt.count} images without alt attributes"
end
# Check if form inputs have associated labels or aria-label
def expect_form_inputs_labeled
unlabeled_inputs = page.all('input, select, textarea').select do |input|
next if input['type'] == 'hidden'
next if input['type'] == 'submit'
next if input['type'] == 'button'
# Check for label association, aria-label, or aria-labelledby
has_label = input['id'] && page.has_css?("label[for='#{input['id']}']")
has_aria = input['aria-label'] || input['aria-labelledby']
!has_label && !has_aria
end
expect(unlabeled_inputs).to be_empty,
"Found #{unlabeled_inputs.count} unlabeled form inputs"
end
# Check if interactive elements are keyboard accessible
def expect_keyboard_accessible(selector)
element = page.find(selector)
# Interactive elements should be focusable
expect(element['tabindex'].to_i).to be >= 0
end
# Check for proper semantic landmarks
def expect_semantic_landmarks
expect(page).to have_css('main, [role="main"]'),
'Page should have a main landmark'
expect(page).to have_css('nav, [role="navigation"]'),
'Page should have a navigation landmark'
end
# Check if buttons have accessible names
def expect_buttons_have_names
unnamed_buttons = page.all('button').select do |button|
text = button.text.strip
aria_label = button['aria-label']
aria_labelledby = button['aria-labelledby']
text.empty? && !aria_label && !aria_labelledby
end
expect(unnamed_buttons).to be_empty,
"Found #{unnamed_buttons.count} buttons without accessible names"
end
# Check color contrast ratio (simplified check for common patterns)
def expect_sufficient_contrast(selector)
element = page.find(selector)
computed_style = page.evaluate_script(
"window.getComputedStyle(document.querySelector('#{selector}'))"
)
# This is a basic check - proper contrast testing requires color parsing
color = computed_style['color']
background = computed_style['background-color']
expect(color).not_to eq('rgb(209, 213, 219)'), # text-gray-300
'Text color has insufficient contrast'
end
# Check if error messages are associated with form fields
def expect_errors_associated_with_inputs(input_id, error_id)
input = page.find("##{input_id}")
expect(input['aria-describedby']).to include(error_id),
"Input ##{input_id} should reference error ##{error_id} via aria-describedby"
# Error should be announced to screen readers
error_element = page.find("##{error_id}")
expect(error_element['role']).to eq('alert').or eq(nil)
end
# Check if modal dialogs are properly configured
def expect_accessible_modal(selector)
modal = page.find(selector)
expect(modal['role']).to eq('dialog'),
'Modal should have role="dialog"'
expect(modal['aria-modal']).to eq('true'),
'Modal should have aria-modal="true"'
expect(modal['aria-labelledby']).not_to be_nil,
'Modal should have aria-labelledby referencing its title'
end
# Check if focus is trapped within modal
def expect_focus_trap(modal_selector)
within(modal_selector) do
focusable = page.all('a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])')
expect(focusable.count).to be > 0, 'Modal should contain focusable elements'
end
end
# Check for skip navigation link
def expect_skip_navigation_link
expect(page).to have_css('a[href="#main-content"], a[href="#main"]'),
'Page should have a skip navigation link'
end
private
def find_aria_labelledby_text(element)
labelledby_id = element['aria-labelledby']
return nil unless labelledby_id
label_element = page.find("##{labelledby_id}", visible: false)
label_element&.text&.strip
end
end
# Include helpers in system specs
RSpec.configure do |config|
config.include AccessibilityHelpers, type: :system
end
Loading…
Cancel
Save