rails_accessibility_testing 1.5.5 → 1.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -115,6 +115,43 @@ module RailsAccessibilityTesting
115
115
 
116
116
  nil
117
117
  end
118
+
119
+ # Find partial in layouts directory based on element context
120
+ def find_partial_in_layouts(element_context)
121
+ return nil unless element_context
122
+
123
+ extensions = %w[erb haml slim]
124
+ id = element_context[:id].to_s
125
+ classes = element_context[:classes].to_s
126
+
127
+ # Common layout partial names
128
+ partial_names = []
129
+ partial_names << id.split('-').first if id.present?
130
+ partial_names << id.split('_').first if id.present?
131
+
132
+ if classes.present?
133
+ classes.split(/\s+/).each do |cls|
134
+ partial_names << cls.split('-').first
135
+ partial_names << cls.split('_').first
136
+ end
137
+ end
138
+
139
+ # Check all partials in layouts directory
140
+ extensions.each do |ext|
141
+ # First try specific names
142
+ partial_names.uniq.each do |partial_name|
143
+ next if partial_name.blank?
144
+ partial_path = "app/views/layouts/_#{partial_name}.html.#{ext}"
145
+ return partial_path if File.exist?(partial_path)
146
+ end
147
+
148
+ # If no match, scan all layout partials and try to match by content
149
+ layout_partials = Dir.glob("app/views/layouts/_*.html.#{ext}")
150
+ # Could add content-based matching here if needed
151
+ end
152
+
153
+ nil
154
+ end
118
155
  end
119
156
 
120
157
  # Include PartialDetection in AccessibilityHelper so methods are available
@@ -598,6 +635,16 @@ module RailsAccessibilityTesting
598
635
  end
599
636
 
600
637
  # Determine likely view file based on Rails path and element context
638
+ #
639
+ # Priority order (based on Rails HTML structure):
640
+ # 1. View file (yield content) - most common location for page-specific issues
641
+ # 2. Partials rendered in the view file
642
+ # 3. Layout partials (navbar, footer) - site-wide components
643
+ # 4. Layout file (application.html.erb) - wrapper structure
644
+ #
645
+ # This ensures errors are attributed to the correct file:
646
+ # - Page-specific issues → view files (yield content)
647
+ # - Site-wide issues → layout partials
601
648
  def determine_view_file(path, url, element_context = nil)
602
649
  return nil unless path
603
650
 
@@ -610,10 +657,12 @@ module RailsAccessibilityTesting
610
657
  controller = route[:controller]
611
658
  action = route[:action]
612
659
 
613
- # Try to find the exact view file
660
+ # Priority 1: Try to find the exact view file (yield content)
661
+ # This is where most page-specific accessibility issues occur
614
662
  view_file = find_view_file_for_controller_action(controller, action)
615
663
 
616
- # If we found the view file, check for partials that might contain the element
664
+ # Priority 2: Check for partials rendered in the view file
665
+ # These are page-specific partials (e.g., _form, _item_card)
617
666
  if view_file && element_context
618
667
  # Scan the view file for rendered partials
619
668
  partials_in_view = find_partials_in_view_file(view_file)
@@ -623,23 +672,25 @@ module RailsAccessibilityTesting
623
672
  return partial_file if partial_file
624
673
  end
625
674
 
626
- # If element might be in a partial or layout, check those too
627
- if element_context
628
- # Check if element is likely in a layout (navbar, footer, etc.)
629
- if element_in_layout?(element_context)
630
- layout_file = find_layout_file
631
- return layout_file if layout_file
632
-
633
- # Also check layout partials
634
- layout_partial = find_partial_in_layouts(element_context)
635
- return layout_partial if layout_partial
636
- end
675
+ # Priority 3: Check if element is in a layout partial (navbar, footer, etc.)
676
+ # These are site-wide components rendered in the layout
677
+ if element_context && element_in_layout?(element_context)
678
+ # Check layout partials first (more specific than layout file)
679
+ layout_partial = find_partial_in_layouts(element_context)
680
+ return layout_partial if layout_partial
637
681
 
638
- # Check if element is in a partial based on context
682
+ # Then check layout file itself
683
+ layout_file = find_layout_file
684
+ return layout_file if layout_file
685
+ end
686
+
687
+ # Priority 4: Check other partials (shared partials, etc.)
688
+ if element_context
639
689
  partial_file = find_partial_for_element(controller, element_context)
640
690
  return partial_file if partial_file
641
691
  end
642
692
 
693
+ # Return view file if found (yield content - most common case)
643
694
  return view_file if view_file
644
695
  rescue StandardError => e
645
696
  # Fall through to path-based detection
@@ -713,6 +764,15 @@ module RailsAccessibilityTesting
713
764
  end
714
765
 
715
766
  # Check if element is likely in a layout (navbar, footer, etc.)
767
+ # vs yield content (main area)
768
+ #
769
+ # Based on Rails HTML structure:
770
+ # - Layout elements: navbar, footer, header (outside <main>)
771
+ # - Yield content: elements inside <main id="maincontent"> (view files)
772
+ #
773
+ # This helps correctly attribute errors:
774
+ # - Layout issues → layout partials/files
775
+ # - Page-specific issues → view files (yield content)
716
776
  def element_in_layout?(element_context)
717
777
  return false unless element_context
718
778
 
@@ -720,8 +780,18 @@ module RailsAccessibilityTesting
720
780
  parent = element_context[:parent]
721
781
  return false unless parent
722
782
 
723
- # Common layout class/id patterns
724
- layout_indicators = ['navbar', 'nav', 'footer', 'header', 'main-nav', 'sidebar']
783
+ # If element is inside <main>, it's likely yield content (view file), not layout
784
+ # Check if parent or ancestor is main element
785
+ parent_tag = parent[:tag].to_s.downcase
786
+ parent_id = parent[:id].to_s.downcase
787
+
788
+ # Elements inside <main> are yield content, not layout
789
+ if parent_tag == 'main' || parent_id.include?('maincontent') || parent_id.include?('main-content')
790
+ return false
791
+ end
792
+
793
+ # Common layout class/id patterns (navbar, footer, header)
794
+ layout_indicators = ['navbar', 'nav', 'footer', 'header', 'main-nav', 'sidebar', 'skip']
725
795
 
726
796
  classes = parent[:classes].to_s.downcase
727
797
  id = parent[:id].to_s.downcase
@@ -137,8 +137,12 @@ module RailsAccessibilityTesting
137
137
  nil
138
138
  end
139
139
 
140
- # Determine likely view file (simplified version)
141
- # Also checks for partials that might contain the element
140
+ # Determine likely view file (simplified version for checks)
141
+ # Uses same priority logic as AccessibilityHelper:
142
+ # 1. View file (yield content) - most common
143
+ # 2. Partials in view file
144
+ # 3. Layout partials
145
+ # 4. Layout file
142
146
  def determine_view_file(element_context = nil)
143
147
  return nil unless safe_page_path
144
148
 
@@ -150,20 +154,43 @@ module RailsAccessibilityTesting
150
154
  controller = route[:controller]
151
155
  action = route[:action]
152
156
 
157
+ # Priority 1: View file (yield content)
153
158
  view_file = find_view_file_for_controller_action(controller, action)
154
159
 
155
- # If we found the view file and have element context, check for partials
160
+ # Priority 2: Partials rendered in the view file
156
161
  if view_file && element_context
157
- # Scan the view file for rendered partials
158
162
  partials_in_view = find_partials_in_view_file(view_file)
159
163
 
160
- # Check if element matches any partial in the view
161
164
  if partials_in_view.any?
162
165
  partial_file = find_partial_for_element_in_list(controller, element_context, partials_in_view)
163
166
  return partial_file if partial_file
164
167
  end
165
168
  end
166
169
 
170
+ # Priority 3: Layout partials (if element is in layout area)
171
+ if element_context
172
+ # Use the same element_in_layout? logic from AccessibilityHelper
173
+ # Check if element is likely in layout (navbar, footer, etc.)
174
+ parent = element_context[:parent]
175
+ if parent
176
+ parent_tag = parent[:tag].to_s.downcase
177
+ parent_id = parent[:id].to_s.downcase
178
+
179
+ # Skip if inside <main> (yield content)
180
+ unless parent_tag == 'main' || parent_id.include?('maincontent') || parent_id.include?('main-content')
181
+ layout_indicators = ['navbar', 'nav', 'footer', 'header', 'main-nav', 'sidebar', 'skip']
182
+ classes = parent[:classes].to_s.downcase
183
+ id = parent[:id].to_s.downcase
184
+
185
+ if layout_indicators.any? { |indicator| classes.include?(indicator) || id.include?(indicator) }
186
+ layout_partial = find_partial_in_layouts(element_context)
187
+ return layout_partial if layout_partial
188
+ end
189
+ end
190
+ end
191
+ end
192
+
193
+ # Return view file (yield content) - most common case
167
194
  view_file
168
195
  rescue StandardError
169
196
  nil
@@ -18,6 +18,29 @@ module RailsAccessibilityTesting
18
18
  page.all('button, a[href], [role="button"], [role="link"]').each do |element|
19
19
  next unless element.visible?
20
20
 
21
+ # Check for headings inside button elements (accessibility violation)
22
+ # Headings should not be nested inside interactive elements
23
+ if element.tag_name == 'button' || element[:role] == 'button'
24
+ headings_inside = element.all('h1, h2, h3, h4, h5, h6', visible: true)
25
+ if headings_inside.any?
26
+ headings_inside.each do |heading|
27
+ element_ctx = element_context(element)
28
+ heading_level = heading.tag_name
29
+ element_ctx[:nested_heading] = heading_level
30
+ element_ctx[:heading_text] = heading.text.strip
31
+
32
+ violations << violation(
33
+ message: "Button contains #{heading_level.upcase} heading - headings should not be nested inside buttons",
34
+ element_context: element_ctx,
35
+ wcag_reference: "4.1.2",
36
+ remediation: "Remove the heading from inside the button. Use plain text or aria-label instead:\n\n" \
37
+ "<button aria-label='#{heading.text.strip}'>#{heading.text.strip}</button>\n\n" \
38
+ "Or use a div/span with appropriate styling instead of a heading element."
39
+ )
40
+ end
41
+ end
42
+ end
43
+
21
44
  text = element.text.to_s.strip
22
45
  aria_label = element[:"aria-label"]
23
46
  aria_labelledby = element[:"aria-labelledby"]
@@ -78,11 +78,17 @@ module RailsAccessibilityTesting
78
78
  profile_static_scanner = profile_config['static_scanner'] || {}
79
79
  merged_static_scanner = base_static_scanner.merge(profile_static_scanner)
80
80
 
81
+ # Merge system_specs config
82
+ base_system_specs = base_config['system_specs'] || {}
83
+ profile_system_specs = profile_config['system_specs'] || {}
84
+ merged_system_specs = base_system_specs.merge(profile_system_specs)
85
+
81
86
  base_config.merge(
82
87
  'checks' => merged_checks,
83
88
  'summary' => merged_summary,
84
89
  'scan_strategy' => profile_config['scan_strategy'] || base_config['scan_strategy'] || 'paths',
85
90
  'static_scanner' => merged_static_scanner,
91
+ 'system_specs' => merged_system_specs,
86
92
  'profile' => profile.to_s,
87
93
  'ignored_rules' => parse_ignored_rules(parsed, profile)
88
94
  )
@@ -125,6 +131,9 @@ module RailsAccessibilityTesting
125
131
  'ignore_warnings' => false
126
132
  },
127
133
  'scan_strategy' => 'paths',
134
+ 'system_specs' => {
135
+ 'auto_run' => false # Run accessibility checks automatically in system specs (default: false)
136
+ },
128
137
  'ignored_rules' => [],
129
138
  'profile' => 'test'
130
139
  }
@@ -138,6 +138,8 @@ module RailsAccessibilityTesting
138
138
  custom_element_remediation(error_type, element_context)
139
139
  when /Duplicate IDs found/i
140
140
  duplicate_ids_remediation(element_context)
141
+ when /Button contains.*heading|heading.*inside.*button/i
142
+ button_heading_remediation(error_type, element_context)
141
143
  else
142
144
  # Fallback: try to match on key phrases even if format is slightly different
143
145
  case error_type.to_s
@@ -169,6 +171,8 @@ module RailsAccessibilityTesting
169
171
  custom_element_remediation(error_type, element_context)
170
172
  when /duplicate.*id/i
171
173
  duplicate_ids_remediation(element_context)
174
+ when /button.*heading|heading.*button/i
175
+ button_heading_remediation(error_type, element_context)
172
176
  else
173
177
  " Please review the element details above and fix the accessibility issue."
174
178
  end
@@ -406,6 +410,30 @@ module RailsAccessibilityTesting
406
410
  remediation
407
411
  end
408
412
 
413
+ def button_heading_remediation(error_type, element_context)
414
+ heading_level = element_context[:nested_heading] || 'H2'
415
+ heading_text = element_context[:heading_text] || 'Section Title'
416
+
417
+ remediation = " Remove heading from inside button:\n\n"
418
+ remediation += " ❌ Current (Bad):\n"
419
+ remediation += " <button>\n"
420
+ remediation += " <#{heading_level.downcase}>#{heading_text}</#{heading_level.downcase}>\n"
421
+ remediation += " </button>\n\n"
422
+ remediation += " ✅ Solution 1: Use plain text with aria-label:\n"
423
+ remediation += " <button aria-label=\"#{heading_text}\">#{heading_text}</button>\n\n"
424
+ remediation += " ✅ Solution 2: Use span/div with CSS styling:\n"
425
+ remediation += " <button>\n"
426
+ remediation += " <span class=\"button-title\">#{heading_text}</span>\n"
427
+ remediation += " </button>\n\n"
428
+ remediation += " ✅ Solution 3: Separate heading and button:\n"
429
+ remediation += " <#{heading_level.downcase}>#{heading_text}</#{heading_level.downcase}>\n"
430
+ remediation += " <button>Action</button>\n\n"
431
+ remediation += " 💡 Best Practice: Headings should not be nested inside buttons.\n"
432
+ remediation += " Buttons are interactive elements, headings are structural.\n"
433
+ remediation += " Use plain text or aria-label for button labels.\n"
434
+ remediation
435
+ end
436
+
409
437
  def footer
410
438
  "📖 WCAG Reference: #{WCAG_REFERENCE}\n#{SEPARATOR}"
411
439
  end
@@ -9,7 +9,31 @@ module RailsAccessibilityTesting
9
9
  enable_spec_type_inference(config)
10
10
  include_matchers(config)
11
11
  include_helpers(config)
12
- setup_automatic_checks(config) if RailsAccessibilityTesting.config.auto_run_checks
12
+
13
+ # Check YAML config first, then fall back to initializer config
14
+ auto_run = should_auto_run_checks?
15
+ setup_automatic_checks(config) if auto_run
16
+ end
17
+
18
+ # Determine if checks should run automatically
19
+ # Checks YAML config first, then falls back to initializer config
20
+ def should_auto_run_checks?
21
+ # Try to load from YAML config
22
+ begin
23
+ require 'rails_accessibility_testing/config/yaml_loader'
24
+ profile = defined?(Rails) && Rails.env.test? ? :test : :development
25
+ yaml_config = Config::YamlLoader.load(profile: profile)
26
+
27
+ # Check if system_specs.auto_run is explicitly set in YAML
28
+ if yaml_config['system_specs'] && yaml_config['system_specs'].key?('auto_run')
29
+ return yaml_config['system_specs']['auto_run']
30
+ end
31
+ rescue StandardError
32
+ # If YAML loading fails, fall through to initializer config
33
+ end
34
+
35
+ # Fall back to initializer configuration
36
+ RailsAccessibilityTesting.config.auto_run_checks
13
37
  end
14
38
 
15
39
  private
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAccessibilityTesting
4
- VERSION = "1.5.5"
4
+ VERSION = "1.5.6"
5
5
  end
6
6
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_accessibility_testing
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.5
4
+ version: 1.5.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Regan Maharjan
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-11-20 00:00:00.000000000 Z
11
+ date: 2025-12-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: axe-core-capybara
@@ -116,6 +116,7 @@ files:
116
116
  - docs_site/ci_integration.md
117
117
  - docs_site/configuration.md
118
118
  - docs_site/contributing.md
119
+ - docs_site/favicon.svg
119
120
  - docs_site/getting_started.md
120
121
  - docs_site/index.md
121
122
  - exe/a11y_live_scanner