Skip to content

Commit

Permalink
feat: add rules for prohibiting use of aria-hidden and role='presenta…
Browse files Browse the repository at this point in the history
…tion' on focusable elements. (#1169)

* Added rule for prohibiting use of aria-hidden and role=presentaion on focusable elements

* Refactored code into seprate files and utility file

* Added EOL for files with prettier errors

* Removed nested ifs

* Fixed EOL errors

* Added more lines

* Fixed prettier issues

* Removed commented code
  • Loading branch information
mauryapari authored and vhoyer committed Jul 17, 2024
1 parent 18841d3 commit 4cf49dc
Show file tree
Hide file tree
Showing 8 changed files with 317 additions and 0 deletions.
79 changes: 79 additions & 0 deletions docs/rules/no-aria-hidden-on-focusable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# 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</span></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>
```
79 changes: 79 additions & 0 deletions docs/rules/no-role-presentation-on-focusable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# 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</span></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" }]
}
]
});
37 changes: 37 additions & 0 deletions src/rules/no-aria-hidden-on-focusable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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 && hasFocusableElements(node)) {
context.report({
node: node as any,
messageId: "default"
});
}
}
});
}
};

export default rule;
38 changes: 38 additions & 0 deletions src/rules/no-role-presentation-on-focusable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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-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 && 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
21 changes: 21 additions & 0 deletions src/utils/hasFocusableElement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
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;

0 comments on commit 4cf49dc

Please sign in to comment.