diff --git a/README.md b/README.md index abfd49b..70528a8 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ linters: enabled: true GitHub::Accessibility::NestedInteractiveElementsCounter: enabled: true + GitHub::Accessibility::NoAriaHiddenOnFocusableCounter: + enabled: true GitHub::Accessibility::NoAriaLabelMisuseCounter: enabled: true GitHub::Accessibility::NoPositiveTabIndexCounter: @@ -61,6 +63,7 @@ linters: - [GitHub::Accessibility::NestedInteractiveElementsCounter](./docs/rules/accessibility/nested-interactive-elements-counter.md) - [GitHub::Accessibility::IframeHasTitleCounter](./docs/rules/accessibility/iframe-has-title-counter.md) - [GitHub::Accessibility::ImageHasAltCounter](./docs/rules/accessibility/image-has-alt-counter.md) +- [GitHub::Accessibility::NoAriaHiddenOnFocusableCounter](./docs/rules/accessibility/no-aria-hidden-on-focusable-counter.md) - [GitHub::Accessibility::NoAriaLabelMisuseCounter](./docs/rules/accessibility/no-aria-label-misuse-counter.md) - [GitHub::Accessibility::NoPositiveTabIndexCounter](./docs/rules/accessibility/no-positive-tab-index-counter.md) - [GitHub::Accessibility::NoRedundantImageAltCounter](./docs/rules/accessibility/no-redundant-image-alt-counter.md) diff --git a/docs/rules/accessibility/no-aria-hidden-on-focusable-counter.md b/docs/rules/accessibility/no-aria-hidden-on-focusable-counter.md new file mode 100644 index 0000000..1d53513 --- /dev/null +++ b/docs/rules/accessibility/no-aria-hidden-on-focusable-counter.md @@ -0,0 +1,35 @@ +# No aria-hidden on focusable counter + +## Rule Details + +Elements that are focusable should never have `aria-hidden="true"` set. + +`aria-hidden="true"` hides elements from assistive technologies. `aria-hidden="true"` should only be used to hide non-interactive content such as decorative elements or redundant text. If a focusable element has `aria-hidden="true"`, it can cause confusion amongst assistive technology users who may be able to reach the element but not receive information about it. + +### Resources + +- [Accessibility insights: aria-hidden-focus](https://accessibilityinsights.io/info-examples/web/aria-hidden-focus/) +- [Deque: aria-hidden elements do not contain focusable elements](https://dequeuniversity.com/rules/axe/html/4.4/aria-hidden-focus) +- [W3: Element with aria-hidden has no content in sequential focus navigation](https://www.w3.org/WAI/standards-guidelines/act/rules/6cfa84/proposed/) + +## Examples + +### **Incorrect** code for this rule 👎 + +```erb + +``` + +```erb +
+``` + +### **Correct** code for this rule 👍 + +```erb + +``` + +```erb + +``` diff --git a/lib/erblint-github/linters/custom_helpers.rb b/lib/erblint-github/linters/custom_helpers.rb index 86b5d8a..1dbbc90 100644 --- a/lib/erblint-github/linters/custom_helpers.rb +++ b/lib/erblint-github/linters/custom_helpers.rb @@ -6,6 +6,8 @@ module ERBLint module Linters module CustomHelpers + INTERACTIVE_ELEMENTS = %w[button summary input select textarea a].freeze + def rule_disabled?(processed_source) processed_source.parser.ast.descendants(:erb).each do |node| indicator_node, _, code_node, = *node @@ -89,6 +91,15 @@ def tags(processed_source) def simple_class_name self.class.name.gsub("ERBLint::Linters::", "") end + + def focusable?(tag) + tabindex = possible_attribute_values(tag, "tabindex") + if INTERACTIVE_ELEMENTS.include?(tag.name) + tabindex.empty? || tabindex.first.to_i >= 0 + else + tabindex.any? && tabindex.first.to_i >= 0 + end + end end end end diff --git a/lib/erblint-github/linters/github/accessibility/nested_interactive_elements_counter.rb b/lib/erblint-github/linters/github/accessibility/nested_interactive_elements_counter.rb index c800a89..9b1b5d3 100644 --- a/lib/erblint-github/linters/github/accessibility/nested_interactive_elements_counter.rb +++ b/lib/erblint-github/linters/github/accessibility/nested_interactive_elements_counter.rb @@ -10,7 +10,6 @@ class NestedInteractiveElementsCounter < Linter include ERBLint::Linters::CustomHelpers include LinterRegistry - INTERACTIVE_ELEMENTS = %w[button summary input select textarea a].freeze MESSAGE = "Nesting interactive elements produces invalid HTML, and ssistive technologies, such as screen readers, might ignore or respond unexpectedly to such nested controls." def run(processed_source) diff --git a/lib/erblint-github/linters/github/accessibility/no_aria_hidden_on_focusable_counter.rb b/lib/erblint-github/linters/github/accessibility/no_aria_hidden_on_focusable_counter.rb new file mode 100644 index 0000000..194fc78 --- /dev/null +++ b/lib/erblint-github/linters/github/accessibility/no_aria_hidden_on_focusable_counter.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative "../../custom_helpers" + +module ERBLint + module Linters + module GitHub + module Accessibility + class NoAriaHiddenOnFocusableCounter < Linter + include ERBLint::Linters::CustomHelpers + include LinterRegistry + + MESSAGE = "Elements that are focusable should not have `aria-hidden='true' because it will cause confusion for assistive technology users." + + def run(processed_source) + tags(processed_source).each do |tag| + aria_hidden = possible_attribute_values(tag, "aria-hidden") + generate_offense(self.class, processed_source, tag) if aria_hidden.include?("true") && focusable?(tag) + end + + counter_correct?(processed_source) + end + + def autocorrect(processed_source, offense) + return unless offense.context + + lambda do |corrector| + if processed_source.file_content.include?("erblint:counter #{simple_class_name}") + # update the counter if exists + corrector.replace(offense.source_range, offense.context) + else + # add comment with counter if none + corrector.insert_before(processed_source.source_buffer.source_range, "#{offense.context}\n") + end + end + end + end + end + end + end +end diff --git a/test/linters/accessibility/no_aria_hidden_on_focusable_counter_test.rb b/test/linters/accessibility/no_aria_hidden_on_focusable_counter_test.rb new file mode 100644 index 0000000..cf5e540 --- /dev/null +++ b/test/linters/accessibility/no_aria_hidden_on_focusable_counter_test.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "test_helper" + +class NoAriaHiddenOnFocusableCounterTest < LinterTestCase + def linter_class + ERBLint::Linters::GitHub::Accessibility::NoAriaHiddenOnFocusableCounter + end + + def test_does_not_warn_if_link_does_not_have_aria_hidden + @file = "GitHub" + @linter.run(processed_source) + + assert_empty @linter.offenses + end + + def test_does_not_consider_aria_hidden_as_aria_hidden_true + # aria-hidden is not the same as aria-hidden="true". Not ideal code. + @file = "GitHub" + @linter.run(processed_source) + + assert_empty @linter.offenses + end + + def test_does_not_warn_if_link_has_aria_hidden_false + @file = "GitHub" + @linter.run(processed_source) + + assert_empty @linter.offenses + end + + def test_does_not_warn_when_link_has_aria_hidden_true_and_is_not_focusable + @file = "GitHub" + @linter.run(processed_source) + + assert_empty @linter.offenses + end + + def test_warns_when_element_has_aria_hidden_true_and_not_tab_focusable + @file = "