Skip to content

Commit 8076348

Browse files
authored
Merge pull request #34 from github/kh-refine-generic-text-logic
Refine generic link text linter logic
2 parents acb1d6f + dc66318 commit 8076348

File tree

3 files changed

+188
-20
lines changed

3 files changed

+188
-20
lines changed

docs/rules/accessibility/avoid-generic-link-text-counter.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,34 @@ Additionally, generic link text can also problematic for heavy zoom users where
1111
Ensure that your link text is descriptive and the purpose of the link is clear even when read out of context of surrounding text.
1212
Learn more about how to write descriptive link text at [Access Guide: Write descriptive link text](https://www.accessguide.io/guide/descriptive-link-text)
1313

14+
### Use of ARIA attributes
15+
16+
If you _must_ use ARIA to replace the visible link text, include the visible text at the beginning.
17+
18+
For example, on a pricing plans page, the following are good:
19+
- Visible text: `Learn more`
20+
- Accessible label: `Learn more about GitHub pricing plans`
21+
22+
Accessible ✅
23+
```html
24+
<a href="..." aria-label="Learn more about GitHub pricing plans">Learn more</a>
25+
```
26+
27+
Inaccessible 🚫
28+
```html
29+
<a href="..." aria-label="GitHub pricing plans">Learn more</a>
30+
```
31+
32+
Including the visible text in the ARIA label satisfies [SC 2.5.3: Label in Name](https://www.w3.org/WAI/WCAG21/Understanding/label-in-name.html).
33+
34+
#### False negatives
35+
36+
Caution: because of the restrictions of static code analysis, we may not catch all violations.
37+
38+
Please perform browser tests and spot checks:
39+
- when `aria-label` is set dynamically
40+
- when using `aria-labelledby`
41+
1442
## Resources
1543

1644
- [Primer: Links](https://primer.style/design/accessibility/links)
@@ -43,6 +71,16 @@ Learn more about how to write descriptive link text at [Access Guide: Write desc
4371
<%= link_to "Learn more", "#" %>
4472
```
4573

74+
```erb
75+
<!-- also bad -->
76+
<a href="github.com/about" aria-label="Why dogs are awesome">Read more</a>
77+
```
78+
79+
```erb
80+
<!-- also bad. `aria-describedby` does not count towards accessible name of an element-->
81+
<a href="github.com/about" aria-describedby="element123">Read more</a>
82+
```
83+
4684
### **Correct** code for this rule 👍
4785

4886
```erb
@@ -52,3 +90,8 @@ Learn more about how to write descriptive link text at [Access Guide: Write desc
5290
```erb
5391
<a href="github.com/new">Create a new repository</a>
5492
```
93+
94+
```erb
95+
<!-- not ideal but won't be flagged -->
96+
<a aria-label="Learn more about GitHub" href="github.com/about">Learn more</a>
97+
```

lib/erblint-github/linters/github/accessibility/avoid_generic_link_text_counter.rb

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@ class AvoidGenericLinkTextCounter < Linter
1818
"Link",
1919
"Here"
2020
].freeze
21+
ARIA_LABEL_ATTRIBUTES = %w[aria-labelledby aria-label].freeze
22+
2123
MESSAGE = "Avoid using generic link text such as #{BANNED_GENERIC_TEXT.join(', ')} which do not make sense in isolation."
2224

2325
def run(processed_source)
2426
processed_source.ast.children.each_with_index do |node, index|
2527
next unless node.methods.include?(:type) && node.type == :text
2628

2729
text = node.children.join.strip
30+
2831
# Checks HTML tags
2932
if banned_text?(text)
3033
prev_node = processed_source.ast.children[index - 1]
@@ -36,29 +39,58 @@ def run(processed_source)
3639
prev_node_tag = BetterHtml::Tree::Tag.from_node(prev_node)
3740
next_node_tag = BetterHtml::Tree::Tag.from_node(next_node)
3841

39-
# We only report if the text is nested between two link tags.
42+
aria_label = possible_attribute_values(prev_node_tag, "aria-label")
43+
aria_labelledby = possible_attribute_values(prev_node_tag, "aria-labelledby")
44+
45+
# Checks if nested between two link tags.
4046
if link_tag?(prev_node_tag) && link_tag?(next_node_tag) && next_node_tag.closing?
47+
# Skip because we cannot reliably check accessible name from aria-labelledby, or an aria-label that is set to a variable
48+
# with static code analysis.
49+
next if aria_labelledby.present? || (aria_label.present? && aria_label.join.include?("<%="))
50+
# Skip because aria-label starts with visible text which we allow. Related to Success Criterion 2.5.3: Label in Name
51+
next if aria_label.present? && valid_accessible_name?(aria_label.join, text)
52+
4153
range = prev_node_tag.loc.begin_pos...text_node_tag.loc.end_pos
4254
source_range = processed_source.to_source_range(range)
4355
generate_offense_from_source_range(self.class, source_range)
4456
end
4557
end
4658

4759
# Checks Rails link helpers like `link_to`
48-
erb_node = node.type == :erb ? node : node.descendants(:erb).first
49-
next unless erb_node
50-
51-
_, _, code_node = *erb_node
52-
source = code_node.loc.source
53-
ruby_node = extract_ruby_node(source)
54-
send_node = ruby_node&.descendants(:send)&.first
55-
next unless send_node.methods.include?(:method_name) && send_node.method_name == :link_to
56-
57-
send_node.child_nodes.each do |child_node|
58-
if child_node.methods.include?(:type) && child_node.type == :str && banned_text?(child_node.children.join)
60+
node.descendants(:erb).each do |erb_node|
61+
_, _, code_node = *erb_node
62+
source = code_node.loc.source
63+
ruby_node = extract_ruby_node(source)
64+
send_node = ruby_node&.descendants(:send)&.first
65+
next unless send_node.methods.include?(:method_name) && send_node.method_name == :link_to
66+
67+
banned_text = nil
68+
69+
send_node.child_nodes.each do |child_node|
70+
banned_text = child_node.children.join if child_node.methods.include?(:type) && child_node.type == :str && banned_text?(child_node.children.join)
71+
next if banned_text.blank?
72+
next unless child_node.methods.include?(:type) && child_node.type == :hash
73+
74+
child_node.descendants(:pair).each do |pair_node|
75+
next unless pair_node.children.first.type?(:sym)
76+
77+
# Skips if `link_to` has `aria-labelledby` or `aria-label` which cannot be evaluated accurately with ERB lint alone.
78+
# ERB lint removes Ruby string interpolation so the `aria-label` for "<%= link_to 'Learn more', "aria-label": "Learn #{@some_variable}" %>" will
79+
# only be `Learn` which is unreliable so we can't do checks :(
80+
key_value = pair_node.children.first.children.join
81+
banned_text = nil if ARIA_LABEL_ATTRIBUTES.include?(key_value)
82+
next unless key_value == "aria"
83+
84+
pair_node.children[1].descendants(:sym).each do |sym_node|
85+
banned_text = nil if sym_node.children.join == "label" || sym_node.children.join == "labelledby"
86+
end
87+
end
88+
end
89+
if banned_text.present?
5990
tag = BetterHtml::Tree::Tag.from_node(code_node)
6091
generate_offense(self.class, processed_source, tag)
61-
end
92+
end
93+
banned_text = nil
6294
end
6395
end
6496
counter_correct?(processed_source)
@@ -84,6 +116,10 @@ def banned_text?(text)
84116
BANNED_GENERIC_TEXT.map(&:downcase).include?(text.downcase)
85117
end
86118

119+
def valid_accessible_name?(aria_label, text)
120+
aria_label.downcase.include?(text.downcase)
121+
end
122+
87123
def extract_ruby_node(source)
88124
BetterHtml::TestHelper::RubyNode.parse(source)
89125
rescue ::Parser::SyntaxError

test/linters/accessibility/avoid_generic_link_text_counter_test.rb

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,20 +49,90 @@ def test_does_not_warn_when_banned_text_is_part_of_more_text
4949
assert_empty @linter.offenses
5050
end
5151

52+
def test_ignores_when_aria_label_with_variable_is_set_on_link_tag
53+
@file = <<~ERB
54+
<a aria-label="<%= tooltip_text %>">Learn more</a>
55+
ERB
56+
@linter.run(processed_source)
57+
58+
assert_empty @linter.offenses
59+
end
60+
61+
def test_flags_when_aria_label_does_not_include_visible_link_text
62+
@file = <<~ERB
63+
<a aria-label="GitHub Sponsors">Learn more</a>
64+
ERB
65+
@linter.run(processed_source)
66+
67+
refute_empty @linter.offenses
68+
end
69+
70+
def test_does_not_flag_when_aria_label_includes_visible_link_text
71+
@file = <<~ERB
72+
<a aria-label="Learn more about GitHub Sponsors">Learn more</a>
73+
ERB
74+
@linter.run(processed_source)
75+
76+
assert_empty @linter.offenses
77+
end
78+
79+
def test_ignores_when_aria_labelledby_is_set_on_link_tag_since_cannot_be_evaluated_accurately_by_erb_linter
80+
@file = "<a aria-labelledby='someElement'>Click here</a>"
81+
@linter.run(processed_source)
82+
83+
assert_empty @linter.offenses
84+
end
85+
5286
def test_warns_when_link_rails_helper_text_is_banned_text
53-
@file = "<%= link_to('click here', redirect_url, id: 'redirect') %>"
87+
@file = "<%= link_to('click here', redirect_url) %>"
5488
@linter.run(processed_source)
5589

5690
refute_empty @linter.offenses
5791
end
5892

59-
def test_does_not_warn_when_generic_text_is_link_rails_helper_sub_text
60-
@file = "<%= link_to('click here to learn about github', redirect_url, id: 'redirect') %>"
93+
def test_warns_when_link_rails_helper_text_is_banned_text_with_aria_description
94+
@file = <<~ERB
95+
<%= link_to('click here', redirect_url, 'aria-describedby': 'element123', id: 'redirect') %>
96+
ERB
97+
@linter.run(processed_source)
98+
99+
refute_empty @linter.offenses
100+
end
101+
102+
def test_ignores_when_link_rails_helper_text_is_banned_text_with_any_aria_label_attributes_since_cannot_be_evaluated_accurately_by_erb_linter
103+
@file = <<~ERB
104+
<%= link_to('learn more', redirect_url, 'aria-labelledby': 'element1234', id: 'redirect') %>
105+
<%= link_to('learn more', redirect_url, 'aria-label': some_variable, id: 'redirect') %>
106+
<%= link_to('learn more', redirect_url, 'aria-label': "Learn #{@variable}", id: 'redirect') %>
107+
<%= link_to('learn more', redirect_url, 'aria-label': 'learn more about GitHub', id: 'redirect') %>
108+
<%= link_to('learn more', redirect_url, aria: { label: 'learn more about GitHub' }, id: 'redirect') %>
109+
ERB
61110
@linter.run(processed_source)
62111

63112
assert_empty @linter.offenses
64113
end
65114

115+
def test_handles_files_with_various_links
116+
@file = <<~ERB
117+
<p>
118+
<a href="github.com" aria-label='Click here to learn more'>Click here</a>
119+
<%= link_to "learn more", billing_path, "aria-label": "something" %>
120+
<a href="github.com" aria-label='Some totally different text'>Click here</a>
121+
<a href="github.com" aria-labelledby='someElement'>Click here</a>
122+
</p>
123+
<p>
124+
<%= link_to "learn more", billing_path, aria: { label: "something" } %>
125+
<%= link_to "learn more", billing_path, aria: { describedby: "element123" } %>
126+
<%= link_to "learn more", billing_path, "aria-describedby": "element123" %>
127+
</p>
128+
ERB
129+
@linter.run(processed_source)
130+
131+
refute_empty @linter.offenses
132+
# 3 offenses, 1 related to matching counter comment not present despite violations
133+
assert_equal 4, @linter.offenses.count
134+
end
135+
66136
def test_does_not_warns_if_element_has_correct_counter_comment
67137
@file = <<~ERB
68138
<%# erblint:counter GitHub::Accessibility::AvoidGenericLinkTextCounter 1 %>
@@ -75,14 +145,33 @@ def test_does_not_warns_if_element_has_correct_counter_comment
75145

76146
def test_autocorrects_when_ignores_are_not_correct
77147
@file = <<~ERB
78-
<%# erblint:counter GitHub::Accessibility::AvoidGenericLinkTextCounter 2 %>
79-
<a>Link</a>
148+
<p>
149+
<a href="github.com" aria-label='Click here to learn more'>Click here</a>
150+
<a href="github.com" aria-label='Some totally different text'>Click here</a>
151+
<a href="github.com" aria-labelledby='someElement'>Click here</a>
152+
</p>
153+
<p>
154+
<%= link_to "learn more", billing_path, "aria-label": "something" %>
155+
<%= link_to "learn more", billing_path, aria: { label: "something" } %>
156+
<%= link_to "learn more", billing_path, aria: { describedby: "element123" } %>
157+
<%= link_to "learn more", billing_path, "aria-describedby": "element123" %>
158+
</p>
80159
ERB
81160
refute_equal @file, corrected_content
82161

83162
expected_content = <<~ERB
84-
<%# erblint:counter GitHub::Accessibility::AvoidGenericLinkTextCounter 1 %>
85-
<a>Link</a>
163+
<%# erblint:counter GitHub::Accessibility::AvoidGenericLinkTextCounter 3 %>
164+
<p>
165+
<a href="github.com" aria-label='Click here to learn more'>Click here</a>
166+
<a href="github.com" aria-label='Some totally different text'>Click here</a>
167+
<a href="github.com" aria-labelledby='someElement'>Click here</a>
168+
</p>
169+
<p>
170+
<%= link_to "learn more", billing_path, "aria-label": "something" %>
171+
<%= link_to "learn more", billing_path, aria: { label: "something" } %>
172+
<%= link_to "learn more", billing_path, aria: { describedby: "element123" } %>
173+
<%= link_to "learn more", billing_path, "aria-describedby": "element123" %>
174+
</p>
86175
ERB
87176
assert_equal expected_content, corrected_content
88177
end

0 commit comments

Comments
 (0)