Skip to content

Commit c229e0f

Browse files
JR-GRich-Harris
andauthored
feat: a11y warning for <button> / <a> without aria-label and content (#13130)
* Introduce button a11y warnings * Log changeset * Don't check for label if aria-hidden * State based flag to avoid multiple warnings firing * Update existing validators to include aria labels * false positives are okay, false negatives aren't * prefer text over aria-label in tests * remove unnecessary aria-labels * tweak message * update test * fix * simplify * various * DRY out * Apply suggestions from code review --------- Co-authored-by: Rich Harris <[email protected]> Co-authored-by: Rich Harris <[email protected]>
1 parent 758fb2a commit c229e0f

File tree

22 files changed

+125
-46
lines changed

22 files changed

+125
-46
lines changed

.changeset/odd-shirts-hear.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
feat: Add accessibility warnings for buttons and anchors without explicit labels and content

packages/svelte/messages/compile-warnings/a11y.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222

2323
> Visible, non-interactive elements with a click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as `<button type="button">` or `<a>` might be more appropriate. See https://svelte.dev/docs/accessibility-warnings#a11y-click-events-have-key-events for more details
2424
25+
## a11y_consider_explicit_label
26+
27+
> Buttons and links should either contain text or have an `aria-label` or `aria-labelledby` attribute
28+
2529
## a11y_distracting_elements
2630

2731
> Avoid `<%name%>` elements
@@ -104,7 +108,7 @@
104108
105109
## a11y_missing_content
106110

107-
> `<%name%>` element should have child content
111+
> `<%name%>` element should contain text
108112
109113
## a11y_mouse_events_have_key_events
110114

packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y.js

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -403,9 +403,9 @@ const a11y_required_attributes = {
403403
object: ['title', 'aria-label', 'aria-labelledby']
404404
};
405405
const a11y_distracting_elements = ['blink', 'marquee'];
406+
407+
// this excludes `<a>` and `<button>` because they are handled separately
406408
const a11y_required_content = [
407-
// anchor-has-content
408-
'a',
409409
// heading-has-content
410410
'h1',
411411
'h2',
@@ -989,16 +989,17 @@ export function check_element(node, state) {
989989
}
990990

991991
// element-specific checks
992-
let contains_a11y_label = false;
992+
const is_labelled = attribute_map.has('aria-label') || attribute_map.has('aria-labelledby');
993993

994-
if (node.name === 'a') {
995-
const aria_label_attribute = attribute_map.get('aria-label');
996-
if (aria_label_attribute) {
997-
if (get_static_value(aria_label_attribute) !== '') {
998-
contains_a11y_label = true;
999-
}
994+
if (node.name === 'a' || node.name === 'button') {
995+
const is_hidden = get_static_value(attribute_map.get('aria-hidden')) === 'true';
996+
997+
if (!is_hidden && !is_labelled && !has_content(node)) {
998+
w.a11y_consider_explicit_label(node);
1000999
}
1000+
}
10011001

1002+
if (node.name === 'a') {
10021003
const href = attribute_map.get('href') || attribute_map.get('xlink:href');
10031004
if (href) {
10041005
const href_value = get_static_text_value(href);
@@ -1139,11 +1140,34 @@ export function check_element(node, state) {
11391140

11401141
// Check content
11411142
if (
1142-
!contains_a11y_label &&
1143+
!is_labelled &&
11431144
!has_contenteditable_binding &&
11441145
a11y_required_content.includes(node.name) &&
1145-
node.fragment.nodes.length === 0
1146+
!has_content(node)
11461147
) {
11471148
w.a11y_missing_content(node, node.name);
11481149
}
11491150
}
1151+
1152+
/**
1153+
* @param {AST.RegularElement | AST.SvelteElement} element
1154+
*/
1155+
function has_content(element) {
1156+
for (const node of element.fragment.nodes) {
1157+
if (node.type === 'Text') {
1158+
if (node.data.trim() === '') {
1159+
continue;
1160+
}
1161+
}
1162+
1163+
if (node.type === 'RegularElement' || node.type === 'SvelteElement') {
1164+
if (!has_content(node)) {
1165+
continue;
1166+
}
1167+
}
1168+
1169+
// assume everything else has content — this will result in false positives
1170+
// (e.g. an empty `{#if ...}{/if}`) but that's probably fine
1171+
return true;
1172+
}
1173+
}

packages/svelte/src/compiler/warnings.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const codes = [
5050
"a11y_autocomplete_valid",
5151
"a11y_autofocus",
5252
"a11y_click_events_have_key_events",
53+
"a11y_consider_explicit_label",
5354
"a11y_distracting_elements",
5455
"a11y_figcaption_index",
5556
"a11y_figcaption_parent",
@@ -174,6 +175,14 @@ export function a11y_click_events_have_key_events(node) {
174175
w(node, "a11y_click_events_have_key_events", "Visible, non-interactive elements with a click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as `<button type=\"button\">` or `<a>` might be more appropriate. See https://svelte.dev/docs/accessibility-warnings#a11y-click-events-have-key-events for more details");
175176
}
176177

178+
/**
179+
* Buttons and links should either contain text or have an `aria-label` or `aria-labelledby` attribute
180+
* @param {null | NodeLike} node
181+
*/
182+
export function a11y_consider_explicit_label(node) {
183+
w(node, "a11y_consider_explicit_label", "Buttons and links should either contain text or have an `aria-label` or `aria-labelledby` attribute");
184+
}
185+
177186
/**
178187
* Avoid `<%name%>` elements
179188
* @param {null | NodeLike} node
@@ -355,12 +364,12 @@ export function a11y_missing_attribute(node, name, article, sequence) {
355364
}
356365

357366
/**
358-
* `<%name%>` element should have child content
367+
* `<%name%>` element should contain text
359368
* @param {null | NodeLike} node
360369
* @param {string} name
361370
*/
362371
export function a11y_missing_content(node, name) {
363-
w(node, "a11y_missing_content", `\`<${name}>\` element should have child content`);
372+
w(node, "a11y_missing_content", `\`<${name}>\` element should contain text`);
364373
}
365374

366375
/**

packages/svelte/tests/runtime-browser/samples/head-script/main.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77
});
88
</script>
99
</svelte:head>
10-
<button></button>
10+
<button>click me</button>

packages/svelte/tests/validator/samples/a11y-anchor-has-content/warnings.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[
22
{
3-
"code": "a11y_missing_content",
4-
"message": "`<a>` element should have child content",
3+
"code": "a11y_consider_explicit_label",
4+
"message": "Buttons and links should either contain text or have an `aria-label` or `aria-labelledby` attribute",
55
"start": {
66
"line": 1,
77
"column": 0

packages/svelte/tests/validator/samples/a11y-aria-proptypes-boolean/input.svelte

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
const abc = 'abc';
33
</script>
44

5-
<button aria-disabled="yes"></button>
6-
<button aria-disabled="no"></button>
7-
<button aria-disabled={1234}></button>
8-
<button aria-disabled={`${abc}`}></button>
5+
<button aria-disabled="yes">click me</button>
6+
<button aria-disabled="no">click me</button>
7+
<button aria-disabled={1234}>click me</button>
8+
<button aria-disabled={`${abc}`}>click me</button>

packages/svelte/tests/validator/samples/a11y-click-events-have-key-events/input.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
<div class="foo"></div>
3232

3333
<a href="http://x.y.z" on:click={noop}>foo</a>
34-
<button on:click={noop}></button>
34+
<button on:click={noop}>click me</button>
3535
<select on:click={noop}></select>
3636

3737
<input type="button" on:click={noop} />
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<button></button>
2+
<a href="/#"><b></b></a>
3+
4+
<button aria-label="Valid empty button"></button>
5+
<a href="/#" aria-label="Valid empty link"></a>
6+
7+
<button aria-hidden='true'></button>
8+
<a href="/#" aria-hidden='true'><b></b></a>
9+
10+
<button>Click me</button>
11+
<a href="/#">Link text</a>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[
2+
{
3+
"code": "a11y_consider_explicit_label",
4+
"message": "Buttons and links should either contain text or have an `aria-label` or `aria-labelledby` attribute",
5+
"start": {
6+
"line": 1,
7+
"column": 0
8+
},
9+
"end": {
10+
"line": 1,
11+
"column": 17
12+
}
13+
},
14+
{
15+
"code": "a11y_consider_explicit_label",
16+
"message": "Buttons and links should either contain text or have an `aria-label` or `aria-labelledby` attribute",
17+
"start": {
18+
"line": 2,
19+
"column": 0
20+
},
21+
"end": {
22+
"line": 2,
23+
"column": 24
24+
}
25+
}
26+
]

0 commit comments

Comments
 (0)