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 <rich.harris@vercel.com>
Co-authored-by: Rich Harris <hello@rich-harris.dev>
pull/13294/head
James Glenn 1 year ago committed by GitHub
parent 758fb2aa0f
commit c229e0f80b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
feat: Add accessibility warnings for buttons and anchors without explicit labels and content

@ -22,6 +22,10 @@
> 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 > 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
## a11y_consider_explicit_label
> Buttons and links should either contain text or have an `aria-label` or `aria-labelledby` attribute
## a11y_distracting_elements ## a11y_distracting_elements
> Avoid `<%name%>` elements > Avoid `<%name%>` elements
@ -104,7 +108,7 @@
## a11y_missing_content ## a11y_missing_content
> `<%name%>` element should have child content > `<%name%>` element should contain text
## a11y_mouse_events_have_key_events ## a11y_mouse_events_have_key_events

@ -403,9 +403,9 @@ const a11y_required_attributes = {
object: ['title', 'aria-label', 'aria-labelledby'] object: ['title', 'aria-label', 'aria-labelledby']
}; };
const a11y_distracting_elements = ['blink', 'marquee']; const a11y_distracting_elements = ['blink', 'marquee'];
// this excludes `<a>` and `<button>` because they are handled separately
const a11y_required_content = [ const a11y_required_content = [
// anchor-has-content
'a',
// heading-has-content // heading-has-content
'h1', 'h1',
'h2', 'h2',
@ -989,16 +989,17 @@ export function check_element(node, state) {
} }
// element-specific checks // element-specific checks
let contains_a11y_label = false; const is_labelled = attribute_map.has('aria-label') || attribute_map.has('aria-labelledby');
if (node.name === 'a') { if (node.name === 'a' || node.name === 'button') {
const aria_label_attribute = attribute_map.get('aria-label'); const is_hidden = get_static_value(attribute_map.get('aria-hidden')) === 'true';
if (aria_label_attribute) {
if (get_static_value(aria_label_attribute) !== '') { if (!is_hidden && !is_labelled && !has_content(node)) {
contains_a11y_label = true; w.a11y_consider_explicit_label(node);
}
} }
}
if (node.name === 'a') {
const href = attribute_map.get('href') || attribute_map.get('xlink:href'); const href = attribute_map.get('href') || attribute_map.get('xlink:href');
if (href) { if (href) {
const href_value = get_static_text_value(href); const href_value = get_static_text_value(href);
@ -1139,11 +1140,34 @@ export function check_element(node, state) {
// Check content // Check content
if ( if (
!contains_a11y_label && !is_labelled &&
!has_contenteditable_binding && !has_contenteditable_binding &&
a11y_required_content.includes(node.name) && a11y_required_content.includes(node.name) &&
node.fragment.nodes.length === 0 !has_content(node)
) { ) {
w.a11y_missing_content(node, node.name); w.a11y_missing_content(node, node.name);
} }
} }
/**
* @param {AST.RegularElement | AST.SvelteElement} element
*/
function has_content(element) {
for (const node of element.fragment.nodes) {
if (node.type === 'Text') {
if (node.data.trim() === '') {
continue;
}
}
if (node.type === 'RegularElement' || node.type === 'SvelteElement') {
if (!has_content(node)) {
continue;
}
}
// assume everything else has content — this will result in false positives
// (e.g. an empty `{#if ...}{/if}`) but that's probably fine
return true;
}
}

@ -50,6 +50,7 @@ export const codes = [
"a11y_autocomplete_valid", "a11y_autocomplete_valid",
"a11y_autofocus", "a11y_autofocus",
"a11y_click_events_have_key_events", "a11y_click_events_have_key_events",
"a11y_consider_explicit_label",
"a11y_distracting_elements", "a11y_distracting_elements",
"a11y_figcaption_index", "a11y_figcaption_index",
"a11y_figcaption_parent", "a11y_figcaption_parent",
@ -174,6 +175,14 @@ export function a11y_click_events_have_key_events(node) {
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"); 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");
} }
/**
* Buttons and links should either contain text or have an `aria-label` or `aria-labelledby` attribute
* @param {null | NodeLike} node
*/
export function a11y_consider_explicit_label(node) {
w(node, "a11y_consider_explicit_label", "Buttons and links should either contain text or have an `aria-label` or `aria-labelledby` attribute");
}
/** /**
* Avoid `<%name%>` elements * Avoid `<%name%>` elements
* @param {null | NodeLike} node * @param {null | NodeLike} node
@ -355,12 +364,12 @@ export function a11y_missing_attribute(node, name, article, sequence) {
} }
/** /**
* `<%name%>` element should have child content * `<%name%>` element should contain text
* @param {null | NodeLike} node * @param {null | NodeLike} node
* @param {string} name * @param {string} name
*/ */
export function a11y_missing_content(node, name) { export function a11y_missing_content(node, name) {
w(node, "a11y_missing_content", `\`<${name}>\` element should have child content`); w(node, "a11y_missing_content", `\`<${name}>\` element should contain text`);
} }
/** /**

@ -7,4 +7,4 @@
}); });
</script> </script>
</svelte:head> </svelte:head>
<button></button> <button>click me</button>

@ -1,7 +1,7 @@
[ [
{ {
"code": "a11y_missing_content", "code": "a11y_consider_explicit_label",
"message": "`<a>` element should have child content", "message": "Buttons and links should either contain text or have an `aria-label` or `aria-labelledby` attribute",
"start": { "start": {
"line": 1, "line": 1,
"column": 0 "column": 0

@ -2,7 +2,7 @@
const abc = 'abc'; const abc = 'abc';
</script> </script>
<button aria-disabled="yes"></button> <button aria-disabled="yes">click me</button>
<button aria-disabled="no"></button> <button aria-disabled="no">click me</button>
<button aria-disabled={1234}></button> <button aria-disabled={1234}>click me</button>
<button aria-disabled={`${abc}`}></button> <button aria-disabled={`${abc}`}>click me</button>

@ -31,7 +31,7 @@
<div class="foo"></div> <div class="foo"></div>
<a href="http://x.y.z" on:click={noop}>foo</a> <a href="http://x.y.z" on:click={noop}>foo</a>
<button on:click={noop}></button> <button on:click={noop}>click me</button>
<select on:click={noop}></select> <select on:click={noop}></select>
<input type="button" on:click={noop} /> <input type="button" on:click={noop} />

@ -0,0 +1,11 @@
<button></button>
<a href="/#"><b></b></a>
<button aria-label="Valid empty button"></button>
<a href="/#" aria-label="Valid empty link"></a>
<button aria-hidden='true'></button>
<a href="/#" aria-hidden='true'><b></b></a>
<button>Click me</button>
<a href="/#">Link text</a>

@ -0,0 +1,26 @@
[
{
"code": "a11y_consider_explicit_label",
"message": "Buttons and links should either contain text or have an `aria-label` or `aria-labelledby` attribute",
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 1,
"column": 17
}
},
{
"code": "a11y_consider_explicit_label",
"message": "Buttons and links should either contain text or have an `aria-label` or `aria-labelledby` attribute",
"start": {
"line": 2,
"column": 0
},
"end": {
"line": 2,
"column": 24
}
}
]

@ -1,7 +1,7 @@
[ [
{ {
"code": "a11y_missing_content", "code": "a11y_missing_content",
"message": "`<h1>` element should have child content", "message": "`<h1>` element should contain text",
"start": { "start": {
"line": 1, "line": 1,
"column": 0 "column": 0

@ -3,7 +3,7 @@
<div aria-disabled="true" role="button" on:keypress={() => {}}></div> <div aria-disabled="true" role="button" on:keypress={() => {}}></div>
<div disabled role="button" on:keypress={() => {}}></div> <div disabled role="button" on:keypress={() => {}}></div>
<div role="presentation" on:keypress={() => {}}></div> <div role="presentation" on:keypress={() => {}}></div>
<button on:click={() => {}}></button> <button on:click={() => {}}>click me</button>
<div role="menuitem" tabindex="0" on:click={() => {}} on:keypress={() => {}}></div> <div role="menuitem" tabindex="0" on:click={() => {}} on:keypress={() => {}}></div>
<div role="button" tabindex="-1" on:click={() => {}} on:keypress={() => {}}></div> <div role="button" tabindex="-1" on:click={() => {}} on:keypress={() => {}}></div>

@ -2,7 +2,7 @@
<div role="presentation" on:mouseup={() => {}}></div> <div role="presentation" on:mouseup={() => {}}></div>
<div role="button" tabindex="-1" on:click={() => {}} on:keypress={() => {}}></div> <div role="button" tabindex="-1" on:click={() => {}} on:keypress={() => {}}></div>
<div role="listitem" aria-hidden="true" on:click={() => {}} on:keypress={() => {}}></div> <div role="listitem" aria-hidden="true" on:click={() => {}} on:keypress={() => {}}></div>
<button on:click={() => {}}></button> <button on:click={() => {}}>click me</button>
<h1 contenteditable="true" on:keydown={() => {}}>Heading</h1> <h1 contenteditable="true" on:keydown={() => {}}>Heading</h1>
<h1>Heading</h1> <h1>Heading</h1>

@ -1,7 +1,7 @@
<!-- valid --> <!-- valid -->
<button></button> <button>click me</button>
<button tabindex='0'></button> <button tabindex='0'>click me</button>
<button tabindex='{0}'></button> <button tabindex='{0}'>click me</button>
<div></div> <div></div>
<div tabindex='-1'></div> <div tabindex='-1'></div>
<div role='button' tabindex='0'></div> <div role='button' tabindex='0'></div>

@ -2,7 +2,7 @@
<article role="article"></article> <article role="article"></article>
<aside role="complementary"></aside> <aside role="complementary"></aside>
<body role="document"></body> <body role="document"></body>
<button role="button"></button> <button role="button">click me</button>
<datalist role="listbox"></datalist> <datalist role="listbox"></datalist>
<dd role="definition"></dd> <dd role="definition"></dd>
<dfn role="term"></dfn> <dfn role="term"></dfn>

@ -3,7 +3,7 @@
</script> </script>
<!-- valid --> <!-- valid -->
<button on:click={() => {}}></button> <button on:click={() => {}}>click me</button>
<!-- svelte-ignore a11y_interactive_supports_focus --> <!-- svelte-ignore a11y_interactive_supports_focus -->
<div on:keydown={() => {}} role="button"></div> <div on:keydown={() => {}} role="button"></div>
<input type="text" on:click={() => {}} /> <input type="text" on:click={() => {}} />

@ -4,7 +4,7 @@
<article aria-autocomplete="inline"></article> <article aria-autocomplete="inline"></article>
<aside aria-modal="true"></aside> <aside aria-modal="true"></aside>
<body aria-invalid="true"></body> <body aria-invalid="true"></body>
<button aria-valuemax="0"></button> <button aria-valuemax="0">click me</button>
<datalist aria-valuenow="0"></datalist> <datalist aria-valuenow="0"></datalist>
<dd aria-rowindex="0"></dd> <dd aria-rowindex="0"></dd>
<dfn aria-colcount="0"></dfn> <dfn aria-colcount="0"></dfn>

@ -2,7 +2,7 @@
let foo; let foo;
</script> </script>
<button tabindex='-1'></button> <button tabindex='-1'>click me</button>
<button tabindex='0'></button> <button tabindex='0'>click me</button>
<button tabindex='1'></button> <button tabindex='1'>click me</button>
<button tabindex='{foo}'></button> <button tabindex='{foo}'>click me</button>

@ -2,8 +2,8 @@
let onclick; let onclick;
</script> </script>
<button {onclick}></button> <button {onclick}>click me</button>
<button onclick={onclick}></button> <button onclick={onclick}>click me</button>
<button {onkeydown}></button> <button {onkeydown}>click me</button>
<button onkeydown={onkeydown}></button> <button onkeydown={onkeydown}>click me</button>

@ -2,14 +2,14 @@
import C from './irrelevant'; import C from './irrelevant';
</script> </script>
<button on:click></button> <button on:click>click me</button>
<button xml:click></button> <button xml:click>click me</button>
<button xmlns:click></button> <button xmlns:click>click me</button>
<button xlink:click></button> <button xlink:click>click me</button>
<C on:click /> <C on:click />
<C xml:click /> <C xml:click />
<C xmlns:click /> <C xmlns:click />
<C xlink:click /> <C xlink:click />
<button foo:bar></button> <button foo:bar>click me</button>
<C foo:bar /> <C foo:bar />

@ -3,4 +3,4 @@
let { b }: { b: string } = $derived(a); let { b }: { b: string } = $derived(a);
</script> </script>
<button onclick={()=>a++}></button> <button onclick={()=>a++}>click me</button>

@ -2,4 +2,4 @@
export let height; export let height;
</script> </script>
<button style:height></button> <button style:height>click me</button>

Loading…
Cancel
Save