Skip to content

[Rule Request]: Prohibiting use of aria-hidden and role='presentation' on focusable elements. #1169

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions docs/rules/no-aria-hidden-on-focusable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# no-aria-hidden-on-focusbable

Enforce that `aria-hidden="true"` is not set on focusable elements or parent of focusable elements.

`aria-hidden="true"` can be used to hide purely decorative content from screen reader users. An element with `aria-hidden="true"` that can also be reached by keyboard can lead to confusion or unexpected behavior for screen reader users. Avoid using `aria-hidden="true"` on focusable elements.

See more in [WAI-ARIA Use in HTML](https://www.w3.org/TR/using-aria/#fourth).


### ✔ Succeed
```vue
<template>
<button>Press Me</button>
</template>
```

```vue
<template>
<div aria-hidden='true'><button tabindex='-1'>Submit</button></div>
</template>
```


```vue
<template>
<div aria-hidden='true'><span>Some text</div></div>
</template>
```

```vue
<template>
<button tabindex='-1' aria-hidden='true'>Press</button>
</template>
```

```vue
<template>
<div aria-hidden='true'><a href='#' tabindex='-1'>Link</a></div>
</template>
```

```vue
<template>
<div aria-hidden='true'><span>Some text</div></div>
</template>
```

### ❌ Fail

```vue
<template>
<button aria-hidden='true'>press me</button>
</template>
```

```vue
<template>
<button aria-hidden="true">press me</button>
</template>
```
```vue
<template>
<a href="#" aria-hidden='true'>press me</a>
</template>
```
```vue
<template>
<div aria-hidden="true">
<button>press me</button>
</div>
</template>
```
```vue
<template>
<span tabindex='0' aria-hidden='true'><em>Icon</em></span>
</template>
```
77 changes: 77 additions & 0 deletions docs/rules/no-role-presentation-on-focusable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# no-role-presentaion-on-focusbable

Enforce that `role="presentation"` is not set on focusable elements or parent of focusbale elements.

`role="presentation` can be used to hide purely decorative content from screen reader users. An element with `role="presentation"` that can also be reached by keyboard can lead to confusion or unexpected behavior for screen reader users. Avoid using `role="presentation"` on focusable elements.

See more in [WAI-ARIA Use in HTML](https://www.w3.org/TR/using-aria/#fourth).


### ✔ Succeed
```vue
<template>
<button>Press Me</button>
</template>
```

```vue
<template>
<div role="presentation"><button tabindex='-1'>Submit</button></div>
</template>
```


```vue
<template>
<div role="presentation"><span>Some text</div></div>
</template>
```

```vue
<template>
<button tabindex='-1' role="presentation">Press</button>
</template>
```

```vue
<template>
<div role="presentation"><a href='#' tabindex='-1'>Link</a></div>
</template>
```

```vue
<template>
<div role="presentation"><span>Some text</div></div>
</template>
```

### ❌ Fail

```vue
<template>
<button role="presentation">press me</button>
</template>
```

```vue
<template>
<button role="presentation">press me</button>
</template>
```
```vue
<template>
<a href="#" role="presentation">press me</a>
</template>
```
```vue
<template>
<div role="presentation">
<button>press me</button>
</div>
</template>
```
```vue
<template>
<span tabindex='0' role="presentation"><em>Icon</em></span>
</template>
```
31 changes: 31 additions & 0 deletions src/rules/__tests__/no-aria-hidden-on-focusable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import rule from '../no-aria-hidden-on-focusable';
import makeRuleTester from "./makeRuleTester";

makeRuleTester('no-presentation-role-or-aria-hidden-on-focusable', rule, {
valid: [
"<button>Submit</button>",
"<div aria-hidden='true'><button tabindex='-1'>Some text</button></div>",
"<div><button>Submit</button></div>",
"<a href='#' tabindex='-1'>link</a>",
"<button tabindex='-1' aria-hidden='true'>Press</button>",
"<div aria-hidden='true'><a href='#' tabindex='-1'>Link</a></div>"
],
invalid: [
{
code: "<div aria-hidden='true'><button>Submit</button></div>",
errors: [{messageId: "default"}]
},
{
code: "<button type='button' aria-hidden='true'>Submit</button>",
errors: [{messageId: "default"}]
},
{
code: "<a href='#' aria-hidden='true'>Link</a>",
errors: [{messageId: "default"}]
},
{
code: "<span tabindex='0' aria-hidden='true'><em>Icon</em></span>",
errors: [{messageId: "default"}]
}
]
})
31 changes: 31 additions & 0 deletions src/rules/__tests__/no-role-presentation-on-focusable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import rule from '../no-role-presentation-on-focusable';
import makeRuleTester from "./makeRuleTester";

makeRuleTester('no-role-presentation-role-on-focusable', rule, {
valid: [
"<button>Submit</button>",
"<div role='presentation'><button tabindex='-1'>Some text</button></div>",
"<div><button>Submit</button></div>",
"<a href='#' tabindex='-1'>link</a>",
"<button tabindex='-1' role='presentation'>Press</button>",
"<div role='presentation'><a href='#' tabindex='-1'>Link</a></div>"
],
invalid: [
{
code: "<div role='presentation'><button>Submit</button></div>",
errors: [{messageId: "default"}]
},
{
code: "<button type='button' role='presentation'>Submit</button>",
errors: [{messageId: "default"}]
},
{
code: "<a href='#' role='presentation'>Link</a>",
errors: [{messageId: "default"}]
},
{
code: "<span tabindex='0' role='presentation'><em>Icon</em></span>",
errors: [{messageId: "default"}]
}
]
})
34 changes: 34 additions & 0 deletions src/rules/no-aria-hidden-on-focusable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Rule } from "eslint";

import { defineTemplateBodyVisitor, getElementAttributeValue, makeDocsURL } from "../utils";
import hasFocusableElements from "../utils/hasFocusableElement";

const rule: Rule.RuleModule = {
meta: {
type: "problem",
docs: {
url: makeDocsURL("no-aria-hidden-on-focusable")
},
messages: {
default: "Focusable/Interactive elements must not have an aria-hidden attribute."
},
schema: []
},
create(context) {
return defineTemplateBodyVisitor(context, {
VElement(node) {
const hasAriaHidden = getElementAttributeValue(node, 'aria-hidden');
if (hasAriaHidden) {
if (hasFocusableElements(node)) {
context.report({
node: node as any,
messageId: 'default',
});
}
}
},
});
}
}

export default rule;
35 changes: 35 additions & 0 deletions src/rules/no-role-presentation-on-focusable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Rule } from "eslint";
// import type { AST } from "vue-eslint-parser";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be better if we removed the comments, no?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


import { defineTemplateBodyVisitor, getElementAttributeValue, makeDocsURL } from "../utils";
import hasFocusableElements from "../utils/hasFocusableElement";

const rule: Rule.RuleModule = {
meta: {
type: "problem",
docs: {
url: makeDocsURL("no-role-presentation-on-focusable")
},
messages: {
default: "Focusable/Interactive elements must not have a presentation role attribute."
},
schema: []
},
create(context) {
return defineTemplateBodyVisitor(context, {
VElement(node) {
const hasRolePresentation = getElementAttributeValue(node, 'role') === 'presentation';
if (hasRolePresentation) {
if (hasFocusableElements(node)) {
context.report({
node: node as any,
messageId: 'default',
});
}
}
},
});
}
}

export default rule;
1 change: 1 addition & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export { default as getInteractiveRoles } from "./utils/getInteractiveRoles";
export { default as hasAccessibleChild } from "./utils/hasAccessibleChild";
export { default as hasAriaLabel } from "./utils/hasAriaLabel";
export { default as hasContent } from "./utils/hasContent";
export { default as hasFocusableElement } from "./utils/hasFocusableElement";
export { default as hasOnDirective } from "./utils/hasOnDirective";
export { default as hasOnDirectives } from "./utils/hasOnDirectives";
export { default as interactiveHandlers } from "./utils/interactiveHandlers.json";
Expand Down
19 changes: 19 additions & 0 deletions src/utils/hasFocusableElement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { AST } from 'vue-eslint-parser';
import getElementAttributeValue from './getElementAttributeValue';
import isInteractiveElement from './isInteractiveElement';

function hasFocusableElements(node: AST.VElement):boolean {
const tabindex = getElementAttributeValue(node, 'tabindex');

if(isInteractiveElement(node)) {
return tabindex !== '-1';
}

if(tabindex !== null && tabindex !== '-1') {
return true;
}

return node.children.some(child => child.type === 'VElement' && hasFocusableElements(child))
}

export default hasFocusableElements;
Loading