rails_accessibility_testing 1.1.0

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.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +307 -0
  3. data/CHANGELOG.md +81 -0
  4. data/CODE_OF_CONDUCT.md +125 -0
  5. data/CONTRIBUTING.md +225 -0
  6. data/GUIDES/continuous_integration.md +326 -0
  7. data/GUIDES/getting_started.md +205 -0
  8. data/GUIDES/working_with_designers_and_content_authors.md +398 -0
  9. data/GUIDES/writing_accessible_views_in_rails.md +412 -0
  10. data/LICENSE +22 -0
  11. data/README.md +350 -0
  12. data/docs_site/404.html +11 -0
  13. data/docs_site/Gemfile +11 -0
  14. data/docs_site/Makefile +14 -0
  15. data/docs_site/_config.yml +41 -0
  16. data/docs_site/_includes/header.html +13 -0
  17. data/docs_site/_layouts/default.html +130 -0
  18. data/docs_site/assets/main.scss +4 -0
  19. data/docs_site/ci_integration.md +76 -0
  20. data/docs_site/configuration.md +114 -0
  21. data/docs_site/contributing.md +69 -0
  22. data/docs_site/getting_started.md +57 -0
  23. data/docs_site/index.md +57 -0
  24. data/exe/rails_a11y +12 -0
  25. data/exe/rails_server_safe +41 -0
  26. data/lib/generators/rails_a11y/install/generator.rb +51 -0
  27. data/lib/rails_accessibility_testing/accessibility_helper.rb +701 -0
  28. data/lib/rails_accessibility_testing/change_detector.rb +114 -0
  29. data/lib/rails_accessibility_testing/checks/aria_landmarks_check.rb +33 -0
  30. data/lib/rails_accessibility_testing/checks/base_check.rb +156 -0
  31. data/lib/rails_accessibility_testing/checks/color_contrast_check.rb +56 -0
  32. data/lib/rails_accessibility_testing/checks/duplicate_ids_check.rb +49 -0
  33. data/lib/rails_accessibility_testing/checks/form_errors_check.rb +40 -0
  34. data/lib/rails_accessibility_testing/checks/form_labels_check.rb +62 -0
  35. data/lib/rails_accessibility_testing/checks/heading_hierarchy_check.rb +53 -0
  36. data/lib/rails_accessibility_testing/checks/image_alt_text_check.rb +52 -0
  37. data/lib/rails_accessibility_testing/checks/interactive_elements_check.rb +66 -0
  38. data/lib/rails_accessibility_testing/checks/keyboard_accessibility_check.rb +36 -0
  39. data/lib/rails_accessibility_testing/checks/skip_links_check.rb +24 -0
  40. data/lib/rails_accessibility_testing/checks/table_structure_check.rb +36 -0
  41. data/lib/rails_accessibility_testing/cli/command.rb +259 -0
  42. data/lib/rails_accessibility_testing/config/yaml_loader.rb +131 -0
  43. data/lib/rails_accessibility_testing/configuration.rb +30 -0
  44. data/lib/rails_accessibility_testing/engine/rule_engine.rb +97 -0
  45. data/lib/rails_accessibility_testing/engine/violation.rb +58 -0
  46. data/lib/rails_accessibility_testing/engine/violation_collector.rb +59 -0
  47. data/lib/rails_accessibility_testing/error_message_builder.rb +354 -0
  48. data/lib/rails_accessibility_testing/integration/minitest_integration.rb +74 -0
  49. data/lib/rails_accessibility_testing/rspec_integration.rb +58 -0
  50. data/lib/rails_accessibility_testing/shared_examples.rb +93 -0
  51. data/lib/rails_accessibility_testing/version.rb +4 -0
  52. data/lib/rails_accessibility_testing.rb +83 -0
  53. data/lib/tasks/accessibility.rake +28 -0
  54. metadata +218 -0
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAccessibilityTesting
4
+ # Detects if relevant files have changed to determine if accessibility checks should run
5
+ class ChangeDetector
6
+ # Time window for considering files as "recently changed" (in seconds)
7
+ CHANGE_WINDOW = 300 # 5 minutes
8
+
9
+ # Directories to monitor for changes
10
+ MONITORED_DIRECTORIES = %w[app/views app/controllers app/helpers].freeze
11
+
12
+ # View file extensions to check
13
+ VIEW_EXTENSIONS = %w[erb haml slim].freeze
14
+
15
+ class << self
16
+ # Check if relevant files have changed
17
+ # @param current_path [String] The current Rails path being tested
18
+ # @return [Boolean] true if files have changed, false otherwise
19
+ def files_changed?(current_path)
20
+ return false unless current_path
21
+
22
+ view_file = determine_view_file_from_path(current_path)
23
+ return true if view_file_recently_modified?(view_file)
24
+ return true if git_has_uncommitted_changes?
25
+ return true if any_monitored_files_recently_modified?
26
+
27
+ false
28
+ end
29
+
30
+ private
31
+
32
+ # Check if a specific view file was recently modified
33
+ def view_file_recently_modified?(view_file)
34
+ return false unless view_file && File.exist?(view_file)
35
+
36
+ File.mtime(view_file) > Time.now - CHANGE_WINDOW
37
+ end
38
+
39
+ # Check if git has uncommitted changes in monitored directories
40
+ def git_has_uncommitted_changes?
41
+ git_status = `git status --porcelain #{MONITORED_DIRECTORIES.join(' ')} 2>/dev/null`
42
+ git_status.strip.length.positive?
43
+ rescue StandardError
44
+ false # Git not available or not a git repo
45
+ end
46
+
47
+ # Check if any monitored files were recently modified
48
+ def any_monitored_files_recently_modified?
49
+ monitored_files.any? do |file|
50
+ File.exist?(file) && File.mtime(file) > Time.now - CHANGE_WINDOW
51
+ end
52
+ end
53
+
54
+ # Get all monitored files
55
+ def monitored_files
56
+ view_files + controller_files + helper_files
57
+ end
58
+
59
+ # Get all view files
60
+ def view_files
61
+ VIEW_EXTENSIONS.flat_map do |ext|
62
+ Dir.glob("app/views/**/*.#{ext}")
63
+ end
64
+ end
65
+
66
+ # Get all controller files
67
+ def controller_files
68
+ Dir.glob('app/controllers/**/*.rb')
69
+ end
70
+
71
+ # Get all helper files
72
+ def helper_files
73
+ Dir.glob('app/helpers/**/*.rb')
74
+ end
75
+
76
+ # Determine view file from Rails path
77
+ def determine_view_file_from_path(path)
78
+ return nil unless path
79
+
80
+ clean_path = path.split('?').first.split('#').first
81
+ return nil unless clean_path.start_with?('/')
82
+
83
+ parts = clean_path.sub(/\A\//, '').split('/')
84
+ return nil if parts.empty?
85
+
86
+ find_view_file(parts)
87
+ end
88
+
89
+ # Find view file based on path parts
90
+ def find_view_file(parts)
91
+ if parts.length >= 2
92
+ controller = parts[0..-2].join('/')
93
+ action = parts.last
94
+ find_view_for_action(controller, action)
95
+ elsif parts.length == 1
96
+ find_view_for_action(parts[0], 'index')
97
+ end
98
+ end
99
+
100
+ # Find view file for a specific controller and action
101
+ def find_view_for_action(controller, action)
102
+ view_paths = VIEW_EXTENSIONS.flat_map do |ext|
103
+ [
104
+ "app/views/#{controller}/#{action}.html.#{ext}",
105
+ "app/views/#{controller}/_#{action}.html.#{ext}"
106
+ ]
107
+ end
108
+
109
+ view_paths.find { |vp| File.exist?(vp) }
110
+ end
111
+ end
112
+ end
113
+ end
114
+
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAccessibilityTesting
4
+ module Checks
5
+ # Checks for proper ARIA landmarks
6
+ #
7
+ # WCAG 2.1 AA: 1.3.1 Info and Relationships (Level A)
8
+ #
9
+ # @api private
10
+ class AriaLandmarksCheck < BaseCheck
11
+ def self.rule_name
12
+ :aria_landmarks
13
+ end
14
+
15
+ def check
16
+ violations = []
17
+
18
+ main_landmarks = page.all('main, [role="main"]', visible: true)
19
+ if main_landmarks.empty?
20
+ violations << violation(
21
+ message: "Page missing MAIN landmark",
22
+ element_context: { tag: 'page', text: 'Page has no MAIN landmark' },
23
+ wcag_reference: "1.3.1",
24
+ remediation: "Wrap main content in <main> tag:\n\n<main>\n <%= yield %>\n</main>"
25
+ )
26
+ end
27
+
28
+ violations
29
+ end
30
+ end
31
+ end
32
+ end
33
+
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAccessibilityTesting
4
+ module Checks
5
+ # Base class for all accessibility checks
6
+ #
7
+ # Provides common functionality and defines the interface
8
+ # that all checks must implement.
9
+ #
10
+ # @abstract Subclass and implement {#check} to create a new check
11
+ #
12
+ # @example Creating a custom check
13
+ # class MyCustomCheck < BaseCheck
14
+ # def self.rule_name
15
+ # :my_custom_check
16
+ # end
17
+ #
18
+ # def check
19
+ # violations = []
20
+ # # Check logic here
21
+ # violations
22
+ # end
23
+ # end
24
+ #
25
+ # @api private
26
+ class BaseCheck
27
+ attr_reader :page, :context
28
+
29
+ # Initialize the check
30
+ # @param page [Capybara::Session] The page to check
31
+ # @param context [Hash] Additional context (url, path, etc.)
32
+ def initialize(page:, context: {})
33
+ @page = page
34
+ @context = context
35
+ end
36
+
37
+ # Run the check and return violations
38
+ # @return [Array<Engine::Violation>]
39
+ def run
40
+ check
41
+ end
42
+
43
+ # The check implementation (must be overridden)
44
+ # @return [Array<Engine::Violation>]
45
+ def check
46
+ raise NotImplementedError, "Subclass must implement #check"
47
+ end
48
+
49
+ # Rule name for this check (must be overridden)
50
+ # @return [Symbol]
51
+ def self.rule_name
52
+ raise NotImplementedError, "Subclass must implement .rule_name"
53
+ end
54
+
55
+ protected
56
+
57
+ # Create a violation
58
+ # @param message [String] Error message
59
+ # @param element_context [Hash] Element context
60
+ # @param wcag_reference [String] WCAG reference
61
+ # @param remediation [String] Suggested fix
62
+ # @return [Engine::Violation]
63
+ def violation(message:, element_context: {}, wcag_reference: nil, remediation: nil)
64
+ Engine::Violation.new(
65
+ rule_name: self.class.rule_name,
66
+ message: message,
67
+ element_context: element_context,
68
+ page_context: page_context,
69
+ wcag_reference: wcag_reference,
70
+ remediation: remediation
71
+ )
72
+ end
73
+
74
+ # Get page context
75
+ # @return [Hash]
76
+ def page_context
77
+ {
78
+ url: safe_page_url,
79
+ path: safe_page_path,
80
+ view_file: determine_view_file
81
+ }
82
+ end
83
+
84
+ # Get element context from Capybara element
85
+ # @param element [Capybara::Node::Element] The element
86
+ # @return [Hash]
87
+ def element_context(element)
88
+ {
89
+ tag: element.tag_name,
90
+ id: element[:id],
91
+ classes: element[:class],
92
+ href: element[:href],
93
+ src: element[:src],
94
+ text: element.text.strip,
95
+ parent: safe_parent_info(element)
96
+ }
97
+ end
98
+
99
+ # Safely get page URL
100
+ def safe_page_url
101
+ page.current_url
102
+ rescue StandardError
103
+ nil
104
+ end
105
+
106
+ # Safely get page path
107
+ def safe_page_path
108
+ page.current_path
109
+ rescue StandardError
110
+ nil
111
+ end
112
+
113
+ # Safely get parent element info
114
+ def safe_parent_info(element)
115
+ parent = element.find(:xpath, '..')
116
+ {
117
+ tag: parent.tag_name,
118
+ id: parent[:id],
119
+ classes: parent[:class]
120
+ }
121
+ rescue StandardError
122
+ nil
123
+ end
124
+
125
+ # Determine likely view file (simplified version)
126
+ def determine_view_file
127
+ return nil unless safe_page_path
128
+
129
+ path = safe_page_path.split('?').first.split('#').first
130
+
131
+ if defined?(Rails) && Rails.application
132
+ begin
133
+ route = Rails.application.routes.recognize_path(path)
134
+ controller = route[:controller]
135
+ action = route[:action]
136
+
137
+ find_view_file_for_controller_action(controller, action)
138
+ rescue StandardError
139
+ nil
140
+ end
141
+ end
142
+ end
143
+
144
+ # Find view file for controller and action
145
+ def find_view_file_for_controller_action(controller, action)
146
+ extensions = %w[erb haml slim]
147
+ extensions.each do |ext|
148
+ view_path = "app/views/#{controller}/#{action}.html.#{ext}"
149
+ return view_path if File.exist?(view_path)
150
+ end
151
+ nil
152
+ end
153
+ end
154
+ end
155
+ end
156
+
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAccessibilityTesting
4
+ module Checks
5
+ # Checks color contrast ratios for text elements
6
+ #
7
+ # Validates that text meets WCAG 2.1 AA contrast requirements:
8
+ # - Normal text: 4.5:1
9
+ # - Large text (18pt+ or 14pt+ bold): 3:1
10
+ #
11
+ # Note: This is a simplified check. Full contrast checking requires
12
+ # JavaScript evaluation of computed styles.
13
+ #
14
+ # @api private
15
+ class ColorContrastCheck < BaseCheck
16
+ def self.rule_name
17
+ :color_contrast
18
+ end
19
+
20
+ def check
21
+ violations = []
22
+
23
+ # This is a placeholder implementation
24
+ # Full contrast checking requires JavaScript to compute
25
+ # actual foreground/background colors from CSS
26
+
27
+ # For now, we'll check for common contrast issues:
28
+ # - Text with low contrast classes
29
+ # - Inline styles with poor contrast
30
+ # - Elements that might have contrast issues
31
+
32
+ page.all('*[style*="color"], *[class*="text-"], p, span, div, h1, h2, h3, h4, h5, h6', visible: true).each do |element|
33
+ # Check for inline styles that might indicate contrast issues
34
+ style = element[:style]
35
+ if style && style.match?(/color:\s*(?:#(?:fff|ffffff|000|000000)|rgb\(255,\s*255,\s*255\)|rgb\(0,\s*0,\s*0\))/i)
36
+ # This is a simplified check - real contrast checking needs computed styles
37
+ # For now, we'll just warn about potential issues
38
+ next # Skip for now - requires JS evaluation
39
+ end
40
+ end
41
+
42
+ violations
43
+ end
44
+
45
+ private
46
+
47
+ # Calculate contrast ratio (simplified - would need actual color values)
48
+ def contrast_ratio(foreground, background)
49
+ # Placeholder - would need to convert colors to relative luminance
50
+ # and calculate: (L1 + 0.05) / (L2 + 0.05)
51
+ 4.5 # Default to passing
52
+ end
53
+ end
54
+ end
55
+ end
56
+
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAccessibilityTesting
4
+ module Checks
5
+ # Checks for duplicate IDs
6
+ #
7
+ # WCAG 2.1 AA: 4.1.1 Parsing (Level A)
8
+ #
9
+ # @api private
10
+ class DuplicateIdsCheck < BaseCheck
11
+ def self.rule_name
12
+ :duplicate_ids
13
+ end
14
+
15
+ def check
16
+ violations = []
17
+ all_ids = page.all('[id]').map { |el| el[:id] }.compact
18
+ duplicates = all_ids.group_by(&:itself).select { |_k, v| v.length > 1 }.keys
19
+
20
+ if duplicates.any?
21
+ first_duplicate_id = duplicates.first
22
+ first_element = page.first("[id='#{first_duplicate_id}']", wait: false)
23
+
24
+ element_ctx = if first_element
25
+ ctx = element_context(first_element)
26
+ ctx[:duplicate_ids] = duplicates
27
+ ctx
28
+ else
29
+ {
30
+ tag: 'multiple',
31
+ id: first_duplicate_id,
32
+ duplicate_ids: duplicates
33
+ }
34
+ end
35
+
36
+ violations << violation(
37
+ message: "Duplicate IDs found: #{duplicates.join(', ')}",
38
+ element_context: element_ctx,
39
+ wcag_reference: "4.1.1",
40
+ remediation: "Ensure each ID is unique on the page"
41
+ )
42
+ end
43
+
44
+ violations
45
+ end
46
+ end
47
+ end
48
+ end
49
+
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAccessibilityTesting
4
+ module Checks
5
+ # Checks that form errors are associated with inputs
6
+ #
7
+ # WCAG 2.1 AA: 3.3.1 Error Identification (Level A)
8
+ #
9
+ # @api private
10
+ class FormErrorsCheck < BaseCheck
11
+ def self.rule_name
12
+ :form_errors
13
+ end
14
+
15
+ def check
16
+ violations = []
17
+
18
+ page.all('.field_with_errors input, .field_with_errors textarea, .field_with_errors select, .is-invalid, [aria-invalid="true"]').each do |input|
19
+ id = input[:id]
20
+ next if id.blank?
21
+
22
+ has_error_message = page.has_css?("[aria-describedby*='#{id}'], .field_with_errors label[for='#{id}'] + .error, .field_with_errors label[for='#{id}'] + .invalid-feedback", wait: false)
23
+
24
+ unless has_error_message
25
+ element_ctx = element_context(input)
26
+ violations << violation(
27
+ message: "Form input error message not associated",
28
+ element_context: element_ctx,
29
+ wcag_reference: "3.3.1",
30
+ remediation: "Associate error message with input using aria-describedby"
31
+ )
32
+ end
33
+ end
34
+
35
+ violations
36
+ end
37
+ end
38
+ end
39
+ end
40
+
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAccessibilityTesting
4
+ module Checks
5
+ # Checks that form inputs have associated labels
6
+ #
7
+ # WCAG 2.1 AA: 1.3.1 Info and Relationships (Level A)
8
+ #
9
+ # @api private
10
+ class FormLabelsCheck < BaseCheck
11
+ def self.rule_name
12
+ :form_labels
13
+ end
14
+
15
+ def check
16
+ violations = []
17
+ page_context = self.page_context
18
+
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|
20
+ id = input[:id]
21
+ next if id.blank?
22
+
23
+ has_label = page.has_css?("label[for='#{id}']", wait: false)
24
+ aria_label = input[:"aria-label"].present?
25
+ aria_labelledby = input[:"aria-labelledby"].present?
26
+
27
+ unless has_label || aria_label || aria_labelledby
28
+ element_ctx = element_context(input)
29
+ element_ctx[:input_type] = input[:type] || input.tag_name
30
+
31
+ violations << violation(
32
+ message: "Form input missing label",
33
+ element_context: element_ctx,
34
+ wcag_reference: "1.3.1",
35
+ remediation: generate_remediation(element_ctx)
36
+ )
37
+ end
38
+ end
39
+
40
+ violations
41
+ end
42
+
43
+ private
44
+
45
+ def generate_remediation(element_context)
46
+ id = element_context[:id]
47
+ input_type = element_context[:input_type] || 'text'
48
+
49
+ "Choose ONE of these solutions:\n\n" \
50
+ "1. Add a <label> element:\n" \
51
+ " <label for=\"#{id}\">Field Label</label>\n" \
52
+ " <input type=\"#{input_type}\" id=\"#{id}\" name=\"field_name\">\n\n" \
53
+ "2. Add aria-label attribute:\n" \
54
+ " <input type=\"#{input_type}\" id=\"#{id}\" aria-label=\"Field Label\">\n\n" \
55
+ "3. Use Rails helper:\n" \
56
+ " <%= form.label :field_name, 'Field Label' %>\n" \
57
+ " <%= form.text_field :field_name, id: '#{id}' %>"
58
+ end
59
+ end
60
+ end
61
+ end
62
+
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAccessibilityTesting
4
+ module Checks
5
+ # Checks for proper heading hierarchy
6
+ #
7
+ # WCAG 2.1 AA: 1.3.1 Info and Relationships (Level A)
8
+ #
9
+ # @api private
10
+ class HeadingHierarchyCheck < BaseCheck
11
+ def self.rule_name
12
+ :heading_hierarchy
13
+ end
14
+
15
+ def check
16
+ violations = []
17
+ headings = page.all('h1, h2, h3, h4, h5, h6', visible: true)
18
+
19
+ if headings.empty?
20
+ return violations # Warning only, not error
21
+ end
22
+
23
+ h1_count = headings.count { |h| h.tag_name == 'h1' }
24
+ if h1_count == 0
25
+ violations << violation(
26
+ message: "Page missing H1 heading",
27
+ element_context: { tag: 'page', text: 'Page has no H1 heading' },
28
+ wcag_reference: "1.3.1",
29
+ remediation: "Add an <h1> heading to your page:\n\n<h1>Main Page Title</h1>"
30
+ )
31
+ end
32
+
33
+ previous_level = 0
34
+ headings.each do |heading|
35
+ current_level = heading.tag_name[1].to_i
36
+ if current_level > previous_level + 1
37
+ element_ctx = element_context(heading)
38
+ violations << violation(
39
+ message: "Heading hierarchy skipped (h#{previous_level} to h#{current_level})",
40
+ element_context: element_ctx,
41
+ wcag_reference: "1.3.1",
42
+ remediation: "Fix the heading hierarchy. Don't skip levels."
43
+ )
44
+ end
45
+ previous_level = current_level
46
+ end
47
+
48
+ violations
49
+ end
50
+ end
51
+ end
52
+ end
53
+
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAccessibilityTesting
4
+ module Checks
5
+ # Checks that images have alt attributes
6
+ #
7
+ # WCAG 2.1 AA: 1.1.1 Non-text Content (Level A)
8
+ #
9
+ # @api private
10
+ class ImageAltTextCheck < BaseCheck
11
+ def self.rule_name
12
+ :image_alt_text
13
+ end
14
+
15
+ def check
16
+ violations = []
17
+
18
+ page.all('img', visible: :all).each do |img|
19
+ has_alt_attribute = page.evaluate_script("arguments[0].hasAttribute('alt')", img.native)
20
+
21
+ if has_alt_attribute == false
22
+ element_ctx = element_context(img)
23
+
24
+ violations << violation(
25
+ message: "Image missing alt attribute",
26
+ element_context: element_ctx,
27
+ wcag_reference: "1.1.1",
28
+ remediation: generate_remediation(element_ctx)
29
+ )
30
+ end
31
+ end
32
+
33
+ violations
34
+ end
35
+
36
+ private
37
+
38
+ def generate_remediation(element_context)
39
+ src = element_context[:src] || 'image.png'
40
+
41
+ "Choose ONE of these solutions:\n\n" \
42
+ "1. Add alt text for informative images:\n" \
43
+ " <img src=\"#{src}\" alt=\"Description of image\">\n\n" \
44
+ "2. Add empty alt for decorative images:\n" \
45
+ " <img src=\"#{src}\" alt=\"\">\n\n" \
46
+ "3. Use Rails image_tag helper:\n" \
47
+ " <%= image_tag 'image.png', alt: 'Description' %>"
48
+ end
49
+ end
50
+ end
51
+ end
52
+
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAccessibilityTesting
4
+ module Checks
5
+ # Checks that interactive elements have accessible names
6
+ #
7
+ # WCAG 2.1 AA: 2.4.4 Link Purpose (Level A), 4.1.2 Name, Role, Value (Level A)
8
+ #
9
+ # @api private
10
+ class InteractiveElementsCheck < BaseCheck
11
+ def self.rule_name
12
+ :interactive_elements
13
+ end
14
+
15
+ def check
16
+ violations = []
17
+
18
+ page.all('button, a[href], [role="button"], [role="link"]').each do |element|
19
+ next unless element.visible?
20
+
21
+ text = element.text.strip
22
+ aria_label = element[:"aria-label"]
23
+ aria_labelledby = element[:"aria-labelledby"]
24
+ title = element[:title]
25
+
26
+ if text.blank? && aria_label.blank? && aria_labelledby.blank? && title.blank?
27
+ element_ctx = element_context(element)
28
+ tag = element.tag_name
29
+
30
+ violations << violation(
31
+ message: "#{tag.capitalize} missing accessible name",
32
+ element_context: element_ctx,
33
+ wcag_reference: tag == 'a' ? "2.4.4" : "4.1.2",
34
+ remediation: generate_remediation(tag, element_ctx)
35
+ )
36
+ end
37
+ end
38
+
39
+ violations
40
+ end
41
+
42
+ private
43
+
44
+ def generate_remediation(tag, element_context)
45
+ if tag == 'a'
46
+ "Choose ONE of these solutions:\n\n" \
47
+ "1. Add visible link text:\n" \
48
+ " <%= link_to 'Descriptive Link Text', path %>\n\n" \
49
+ "2. Add aria-label (for icon-only links):\n" \
50
+ " <%= link_to path, aria: { label: 'Descriptive action' } do %>\n" \
51
+ " <i class='icon'></i>\n" \
52
+ " <% end %>"
53
+ else
54
+ "Choose ONE of these solutions:\n\n" \
55
+ "1. Add visible button text:\n" \
56
+ " <button>Descriptive Button Text</button>\n\n" \
57
+ "2. Add aria-label (for icon-only buttons):\n" \
58
+ " <button aria-label='Descriptive action'>\n" \
59
+ " <i class='icon'></i>\n" \
60
+ " </button>"
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+