Skip to content

Commit 5cdb6b1

Browse files
committed
Release v1.6.0: Enhanced ERB template handling and dynamic ID support
- Added intelligent ERB template handling to preserve dynamic ID structure - Fixed false positives for checkbox/radio groups with ERB-generated IDs - Improved label matching for dynamic IDs from ERB templates - Enhanced duplicate ID detection to exclude ERB placeholders - Fixed missing accessible name detection for links with href='#' - Refactored RSpec test file with proper assertions - Updated documentation with ERB handling details
1 parent bbfcf8f commit 5cdb6b1

10 files changed

Lines changed: 208 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.6.0] - 2024-12-XX
9+
10+
### Added
11+
- **Intelligent ERB Template Handling**: Enhanced ERB extractor to preserve dynamic ID structure from ERB templates
12+
- **Dynamic ID Support**: Static scanner now correctly handles checkbox/radio inputs with ERB-generated IDs (e.g., `collection_answers_<%= question.id %>_<%= option.id %>_`)
13+
- **Label Matching for Dynamic IDs**: Form labels check now correctly matches labels to inputs with dynamic IDs by preserving ERB placeholder structure
14+
- **Smart Duplicate ID Detection**: Duplicate ID check now excludes dynamic IDs with ERB placeholders, preventing false positives for checkbox/radio groups in loops
15+
16+
### Fixed
17+
- Fixed false positive "Form input missing label [id: collection_answers]" for checkbox groups with dynamic IDs
18+
- Fixed false positive "Duplicate ID 'collection_answers' found" when IDs contain ERB expressions
19+
- Fixed missing accessible name detection for links with `href="#"` - now only flags links that truly lack accessible names (visible text, aria-label, or aria-labelledby)
20+
- Improved `label_tag` helper conversion to handle string interpolation in ID arguments
21+
- Enhanced raw HTML input element processing to preserve ERB structure in attributes
22+
23+
### Changed
24+
- **ERB Extractor**: Now processes raw HTML elements with ERB in attributes before removing ERB tags, preserving dynamic ID structure
25+
- **Form Labels Check**: Updated to handle ERB placeholders in IDs when checking for associated labels
26+
- **Duplicate IDs Check**: Now filters out IDs containing `ERB_CONTENT` placeholders from duplicate detection
27+
- **Interactive Elements Check**: Enhanced to correctly detect accessible names for links with `href="#"`, avoiding false positives
28+
- **RSpec Test File**: Refactored `all_pages_accessibility_spec.rb` to extract formatting helper and add proper assertions that fail tests when errors are found
29+
30+
### Documentation
31+
- Added comprehensive documentation on ERB template handling and dynamic ID processing
32+
- Updated architecture documentation with details on how dynamic IDs are preserved
33+
- Added examples and explanations for checkbox/radio groups with ERB-generated IDs
34+
835
## [1.5.10] - 2024-12-01
936

1037
### Changed

docs_site/architecture.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,61 @@ graph TB
167167
style Report fill:#ffeaa7
168168
```
169169

170+
### ERB Template Handling and Dynamic IDs
171+
172+
The static scanner intelligently handles ERB templates with dynamic content, particularly for form inputs with dynamic IDs.
173+
174+
#### How Dynamic IDs Are Preserved
175+
176+
When the scanner encounters ERB templates with dynamic IDs, it preserves the structure instead of collapsing them:
177+
178+
**Example ERB Template:**
179+
```erb
180+
<% question.collection_options.each do |option| %>
181+
<input type="checkbox"
182+
id="collection_answers_<%= question.id %>_<%= option.id %>_"
183+
name="collection_answers[<%= question.id %>][]" />
184+
<%= label_tag "collection_answers_#{question.id}_#{option.id}_", option.value %>
185+
<% end %>
186+
```
187+
188+
**How It's Processed:**
189+
- ERB expressions (`<%= question.id %>`, `<%= option.id %>`) are replaced with `ERB_CONTENT` placeholders
190+
- The structure is preserved: `collection_answers_ERB_CONTENT_ERB_CONTENT_`
191+
- This allows the scanner to correctly match inputs with their labels, even when IDs are dynamic
192+
193+
#### Label Matching with Dynamic IDs
194+
195+
The **Form Labels Check** correctly matches labels to inputs with dynamic IDs:
196+
- Input: `id="collection_answers_ERB_CONTENT_ERB_CONTENT_"`
197+
- Label: `for="collection_answers_ERB_CONTENT_ERB_CONTENT_"`
198+
-**Match found** - The scanner recognizes these as matching pairs
199+
200+
#### Duplicate ID Detection
201+
202+
The **Duplicate IDs Check** intelligently handles dynamic IDs:
203+
- IDs containing `ERB_CONTENT` are excluded from duplicate checking
204+
- These represent dynamic IDs that will have different values at runtime
205+
- Only static IDs (without ERB placeholders) are checked for duplicates
206+
- This prevents false positives for checkbox/radio groups in loops
207+
208+
#### Links with href="#"
209+
210+
The **Interactive Elements Check** correctly handles anchor links:
211+
- Only flags links with `href="#"` that have **no accessible name**
212+
- An accessible name can be: visible text, `aria-label`, or `aria-labelledby`
213+
- Links with `href="#"` that have proper labeling are **not flagged** (avoids false positives)
214+
215+
**Example - Valid (not flagged):**
216+
```erb
217+
<%= link_to "Click me", "#", aria: { label: "Navigate to section" } %>
218+
```
219+
220+
**Example - Invalid (flagged):**
221+
```erb
222+
<a href="#"></a> <!-- No text, no aria-label, no aria-labelledby -->
223+
```
224+
170225
---
171226

172227
## Configuration & Profiles

docs_site/getting_started.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,20 @@ end
142142
Rails Accessibility Testing runs **11 comprehensive checks** automatically. These checks are WCAG 2.1 AA aligned:
143143

144144
1. **Form Labels** - All form inputs have associated labels
145+
- ✅ Correctly handles dynamic IDs from ERB templates (e.g., `collection_answers_<%= question.id %>_<%= option.id %>_`)
146+
- ✅ Matches labels to inputs even when IDs contain ERB expressions
145147
2. **Image Alt Text** - All images have descriptive alt attributes
146148
3. **Interactive Elements** - Buttons, links have accessible names
149+
- ✅ Only flags links with `href="#"` that have no accessible name (visible text, `aria-label`, or `aria-labelledby`)
150+
- ✅ Avoids false positives for valid anchor links with proper labeling
147151
4. **Heading Hierarchy** - Proper h1-h6 structure without skipping levels (checked across complete page: layout + view + partials)
148152
5. **Keyboard Accessibility** - All interactive elements are keyboard accessible
149153
6. **ARIA Landmarks** - Proper use of ARIA landmark roles (checked across complete page: layout + view + partials)
150154
7. **Form Error Associations** - Form errors are properly linked to form fields
151155
8. **Table Structure** - Tables have proper headers
152156
9. **Duplicate IDs** - No duplicate ID attributes (checked across complete page: layout + view + partials)
157+
- ✅ Intelligently excludes dynamic IDs with ERB placeholders from duplicate checking
158+
- ✅ Prevents false positives for checkbox/radio groups in loops
153159
10. **Skip Links** - Skip navigation links present
154160
11. **Color Contrast** - Text meets WCAG contrast requirements (optional, disabled by default)
155161

docs_site/index.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ title: Home
77

88
**The RSpec + RuboCop of accessibility for Rails. Catch WCAG violations before they reach production.**
99

10-
**Version:** 1.5.9
10+
**Version:** 1.6.0
1111

1212
Rails Accessibility Testing is a comprehensive, opinionated but configurable gem that makes accessibility testing as natural as unit testing. It integrates seamlessly into your Rails workflow, catching accessibility issues as you code—not after deployment.
1313

@@ -36,6 +36,10 @@ Rails Accessibility Testing fills a critical gap in the Rails testing ecosystem.
3636
- **Continuous monitoring**: Watches for file changes and re-scans automatically
3737
- **YAML configuration**: Fully configurable via `config/accessibility.yml`
3838
- **Reuses existing checks**: Leverages all 11 accessibility checks via RuleEngine
39+
- **Intelligent ERB handling**: Correctly processes dynamic IDs and ERB expressions
40+
- Preserves structure of dynamic IDs (e.g., `collection_answers_<%= question.id %>_<%= option.id %>_`)
41+
- Matches labels to inputs with dynamic IDs
42+
- Excludes dynamic IDs from duplicate checking (prevents false positives)
3943

4044
#### 🎯 Live Accessibility Scanner
4145
- **Real-time scanning**: Automatically scans pages as you browse during development
@@ -140,15 +144,15 @@ This will:
140144

141145
The gem automatically runs **11 comprehensive accessibility checks**:
142146

143-
1.**Form Labels** - All form inputs have associated labels
147+
1.**Form Labels** - All form inputs have associated labels (handles dynamic IDs from ERB templates)
144148
2.**Image Alt Text** - All images have descriptive alt attributes
145-
3.**Interactive Elements** - Buttons, links have accessible names (including links with images that have alt text)
149+
3.**Interactive Elements** - Buttons, links have accessible names (including links with images that have alt text; correctly handles links with `href="#"`)
146150
4.**Heading Hierarchy** - Proper h1-h6 structure (detects missing h1, multiple h1s, skipped levels, and h2+ without h1)
147151
5.**Keyboard Accessibility** - All interactive elements keyboard accessible
148152
6.**ARIA Landmarks** - Proper use of ARIA landmark roles
149153
7.**Form Error Associations** - Errors linked to form fields
150154
8.**Table Structure** - Tables have proper headers
151-
9.**Duplicate IDs** - No duplicate ID attributes
155+
9.**Duplicate IDs** - No duplicate ID attributes (intelligently handles dynamic IDs from ERB templates)
152156
10.**Skip Links** - Skip navigation links present (detects various patterns)
153157
11.**Color Contrast** - Text meets contrast requirements (optional)
154158

lib/rails_accessibility_testing/checks/duplicate_ids_check.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ module Checks
66
#
77
# WCAG 2.1 AA: 4.1.1 Parsing (Level A)
88
#
9+
# @note ERB template handling:
10+
# - IDs containing "ERB_CONTENT" placeholders are excluded from duplicate checking
11+
# - These are dynamic IDs that will have different values at runtime
12+
# - Example: "collection_answers_ERB_CONTENT_ERB_CONTENT_" represents a dynamic ID
13+
# that will be unique for each checkbox/radio option when rendered
14+
# - Static analysis cannot determine if dynamic IDs will be duplicates, so they are skipped
15+
#
916
# @api private
1017
class DuplicateIdsCheck < BaseCheck
1118
def self.rule_name
@@ -14,7 +21,11 @@ def self.rule_name
1421

1522
def check
1623
violations = []
17-
all_ids = page.all('[id]').map { |el| el[:id] }.compact
24+
# Collect all IDs, filtering out those with ERB_CONTENT placeholders
25+
# IDs with ERB_CONTENT are dynamic and can't be statically verified for duplicates
26+
# Example: "collection_answers_ERB_CONTENT_ERB_CONTENT_" - the actual IDs will be different at runtime
27+
# because the ERB expressions will evaluate to different values
28+
all_ids = page.all('[id]').map { |el| el[:id] }.compact.reject { |id| id.include?('ERB_CONTENT') }
1829
duplicates = all_ids.group_by(&:itself).select { |_k, v| v.length > 1 }.keys
1930

2031
if duplicates.any?

lib/rails_accessibility_testing/checks/form_labels_check.rb

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ module Checks
66
#
77
# WCAG 2.1 AA: 1.3.1 Info and Relationships (Level A)
88
#
9+
# @note ERB template handling:
10+
# - Dynamic IDs with ERB placeholders (e.g., "collection_answers_ERB_CONTENT_ERB_CONTENT_")
11+
# are matched against labels with the same ERB structure
12+
# - ErbExtractor ensures that IDs and label "for" attributes with matching ERB patterns
13+
# will have the same "ERB_CONTENT" placeholder structure, allowing exact matching
14+
# - This correctly handles cases like:
15+
# <input id="collection_answers_<%= question.id %>_<%= option.id %>_">
16+
# <%= label_tag "collection_answers_#{question.id}_#{option.id}_", option.value %>
17+
#
918
# @api private
1019
class FormLabelsCheck < BaseCheck
1120
def self.rule_name
@@ -16,11 +25,21 @@ def check
1625
violations = []
1726
page_context = self.page_context
1827

19-
page.all('input[type="text"], input[type="email"], input[type="password"], input[type="number"], input[type="tel"], input[type="url"], input[type="search"], input[type="date"], input[type="time"], input[type="datetime-local"], textarea, select').each do |input|
28+
# Also check checkbox and radio inputs (they need labels too)
29+
page.all('input[type="text"], input[type="email"], input[type="password"], input[type="number"], input[type="tel"], input[type="url"], input[type="search"], input[type="date"], input[type="time"], input[type="datetime-local"], input[type="checkbox"], input[type="radio"], textarea, select').each do |input|
2030
id = input[:id]
2131
next if id.nil? || id.to_s.strip.empty?
2232

33+
# Skip ERB_CONTENT placeholder - it's not a real ID, just a marker for dynamic content
34+
next if id == 'ERB_CONTENT'
35+
36+
# Check for label with matching for attribute
37+
# Handle ERB placeholders in IDs: ErbExtractor preserves the structure of dynamic IDs
38+
# so "collection_answers_<%= question.id %>_<%= option.id %>_" becomes
39+
# "collection_answers_ERB_CONTENT_ERB_CONTENT_", and label_tag with the same pattern
40+
# will also become "collection_answers_ERB_CONTENT_ERB_CONTENT_", so they should match exactly
2341
has_label = page.has_css?("label[for='#{id}']", wait: false)
42+
2443
aria_label = input[:"aria-label"]
2544
aria_labelledby = input[:"aria-labelledby"]
2645
has_aria_label = aria_label && !aria_label.to_s.strip.empty?

lib/rails_accessibility_testing/checks/interactive_elements_check.rb

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ module Checks
66
#
77
# WCAG 2.1 AA: 2.4.4 Link Purpose (Level A), 4.1.2 Name, Role, Value (Level A)
88
#
9+
# @note Links with href="#":
10+
# - Only flags anchors with href="#" that have NO accessible name
11+
# - An accessible name can be: visible text, aria-label, or aria-labelledby
12+
# - Links with href="#" that have visible text or ARIA attributes are valid and NOT flagged
13+
# - This avoids false positives for valid anchor links that use href="#" with proper labeling
14+
#
915
# @api private
1016
class InteractiveElementsCheck < BaseCheck
1117
def self.rule_name
@@ -45,6 +51,7 @@ def check
4551
aria_label = element[:"aria-label"]
4652
aria_labelledby = element[:"aria-labelledby"]
4753
title = element[:title]
54+
href = element[:href]
4855

4956
# Check if element contains ERB placeholder (for static scanning)
5057
# ErbExtractor replaces <%= ... %> with "ERB_CONTENT" so we can detect it
@@ -68,12 +75,24 @@ def check
6875
aria_labelledby_empty = aria_labelledby.nil? || aria_labelledby.to_s.strip.empty?
6976
title_empty = title.nil? || title.to_s.strip.empty?
7077

78+
# Only report violation if element has no accessible name
79+
# For links with href="#", we only flag if they have no text AND no aria-label AND no aria-labelledby
80+
# Links with href="#" that have visible text or ARIA attributes are valid and should not be flagged
7181
if text_empty && aria_label_empty && aria_labelledby_empty && title_empty && !has_image_with_alt
7282
element_ctx = element_context(element)
7383
tag = element.tag_name
7484

85+
# Special message for empty links with href="#"
86+
# This rule correctly detects anchors with href="#" that have no accessible name,
87+
# but avoids false positives when they have visible text or aria-label/aria-labelledby
88+
message = if tag == 'a' && (href == '#' || href.to_s.strip == '#')
89+
"Link missing accessible name [href: #]"
90+
else
91+
"#{tag.capitalize} missing accessible name"
92+
end
93+
7594
violations << violation(
76-
message: "#{tag.capitalize} missing accessible name",
95+
message: message,
7796
element_context: element_ctx,
7897
wcag_reference: tag == 'a' ? "2.4.4" : "4.1.2",
7998
remediation: generate_remediation(tag, element_ctx)

lib/rails_accessibility_testing/composed_page_scanner.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,10 @@ def scan_duplicate_ids
203203
next if id.nil? || id.to_s.strip.empty?
204204
# Skip ERB_CONTENT placeholder - it's not a real ID
205205
next if id == 'ERB_CONTENT'
206+
# Skip IDs that contain ERB_CONTENT - these are dynamic IDs that can't be statically verified
207+
# Example: "collection_answers_ERB_CONTENT_ERB_CONTENT_" - the actual IDs will be different at runtime
208+
# because the ERB expressions will evaluate to different values
209+
next if id.include?('ERB_CONTENT')
206210

207211
id_map[id] ||= []
208212
line = find_line_number_for_element(content, element)

lib/rails_accessibility_testing/erb_extractor.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ module RailsAccessibilityTesting
44
# Extracts HTML from ERB templates by converting Rails helpers to HTML
55
# This allows static analysis of view files without rendering them
66
#
7+
# @note ERB and ID handling:
8+
# - Dynamic IDs like "collection_answers_<%= question.id %>_<%= option.id %>_" are preserved
9+
# - ERB expressions are replaced with "ERB_CONTENT" placeholder to maintain structure
10+
# - This ensures IDs like "collection_answers_ERB_CONTENT_ERB_CONTENT_" are not collapsed
11+
# - Labels with matching ERB structure will also have "ERB_CONTENT" and can be matched
12+
#
713
# @api private
814
class ErbExtractor
915
# Convert ERB template to HTML for static analysis
@@ -26,8 +32,57 @@ def extract
2632

2733
private
2834

35+
# Convert raw HTML elements that have ERB in their attributes
36+
# This handles cases like: <input id="collection_answers_<%= question.id %>_<%= option.id %>_">
37+
# We need to preserve the structure of dynamic IDs so they don't get collapsed
38+
#
39+
# @note This must run BEFORE remove_erb_tags to preserve ERB structure in attributes
40+
def convert_raw_html_with_erb
41+
# Handle input elements (checkbox, radio, text, etc.) with ERB in attributes
42+
# Pattern: <input ... id="...<%= ... %>..." ... />
43+
@content.gsub!(/<input\s+([^>]*?)>/i) do |match|
44+
attrs = $1
45+
# Replace ERB in attributes with placeholder, preserving structure
46+
# This ensures "id='collection_answers_<%= question.id %>_<%= option.id %>_'"
47+
# becomes "id='collection_answers_ERB_CONTENT_ERB_CONTENT_'"
48+
attrs_with_placeholders = attrs.gsub(/<%=(.*?)%>/m, 'ERB_CONTENT')
49+
"<input #{attrs_with_placeholders}>"
50+
end
51+
52+
# Handle label_tag with string interpolation in first argument: <%= label_tag "collection_answers_#{question.id}_#{option.id}_", option.value %>
53+
# This handles string interpolation like "collection_answers_#{question.id}_#{option.id}_"
54+
# Must match BEFORE the simple string pattern
55+
@content.gsub!(/<%=\s*label_tag\s+["']([^"']*#\{[^}]+\}[^"']*)["'],\s*([^,]+)(?:,\s*[^%]*)?%>/) do
56+
id_template = $1
57+
text_expr = $2
58+
# Replace Ruby interpolation with placeholder
59+
id_with_placeholder = id_template.gsub(/#\{[^}]+\}/, 'ERB_CONTENT')
60+
# Handle text expression - could be a variable, string, or method call
61+
text_placeholder = if text_expr.strip.match?(/^["']/)
62+
# It's a string literal
63+
text_expr.strip.gsub(/^["']|["']$/, '')
64+
else
65+
# It's a variable or method call - will produce content at runtime
66+
'ERB_CONTENT'
67+
end
68+
"<label for=\"#{id_with_placeholder}\">#{text_placeholder}</label>"
69+
end
70+
71+
# Handle label_tag helper with simple string: <%= label_tag "id_string", "text", options %>
72+
# Pattern: label_tag "id_string", "text", options
73+
@content.gsub!(/<%=\s*label_tag\s+["']([^"']+)["'],\s*["']?([^"']*?)["']?[^%]*%>/) do
74+
id = $1
75+
text = $2
76+
# Replace ERB in id string with placeholder (if any)
77+
id_with_placeholder = id.gsub(/<%=(.*?)%>/m, 'ERB_CONTENT')
78+
"<label for=\"#{id_with_placeholder}\">#{text}</label>"
79+
end
80+
end
81+
2982
# Convert Rails helpers to placeholder HTML
3083
def convert_rails_helpers
84+
# First, handle raw HTML elements with ERB in attributes (before removing ERB tags)
85+
convert_raw_html_with_erb
3186
convert_form_helpers
3287
convert_image_helpers
3388
convert_link_helpers
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# frozen_string_literal: true
22

33
module RailsAccessibilityTesting
4-
VERSION = "1.5.10"
4+
VERSION = "1.6.0"
55
end
66

0 commit comments

Comments
 (0)