From 4cf49dcf47fd2654387dbc95931dff7e07062a63 Mon Sep 17 00:00:00 2001 From: Paritosh Maurya <47924620+mauryapari@users.noreply.github.com> Date: Thu, 18 Jul 2024 00:32:09 +0530 Subject: [PATCH] feat: add rules for prohibiting use of aria-hidden and role='presentation' 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 --- docs/rules/no-aria-hidden-on-focusable.md | 79 +++++++++++++++++++ .../no-role-presentation-on-focusable.md | 79 +++++++++++++++++++ .../no-aria-hidden-on-focusable.test.ts | 31 ++++++++ .../no-role-presentation-on-focusable.test.ts | 31 ++++++++ src/rules/no-aria-hidden-on-focusable.ts | 37 +++++++++ .../no-role-presentation-on-focusable.ts | 38 +++++++++ src/utils.ts | 1 + src/utils/hasFocusableElement.ts | 21 +++++ 8 files changed, 317 insertions(+) create mode 100644 docs/rules/no-aria-hidden-on-focusable.md create mode 100644 docs/rules/no-role-presentation-on-focusable.md create mode 100644 src/rules/__tests__/no-aria-hidden-on-focusable.test.ts create mode 100644 src/rules/__tests__/no-role-presentation-on-focusable.test.ts create mode 100644 src/rules/no-aria-hidden-on-focusable.ts create mode 100644 src/rules/no-role-presentation-on-focusable.ts create mode 100644 src/utils/hasFocusableElement.ts diff --git a/docs/rules/no-aria-hidden-on-focusable.md b/docs/rules/no-aria-hidden-on-focusable.md new file mode 100644 index 00000000..bf2701a3 --- /dev/null +++ b/docs/rules/no-aria-hidden-on-focusable.md @@ -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 + +``` + +```vue + +``` + +```vue + +``` + +```vue + +``` + +```vue + +``` + +```vue + +``` + +### ❌ Fail + +```vue + +``` + +```vue + +``` + +```vue + +``` + +```vue + +``` + +```vue + +``` diff --git a/docs/rules/no-role-presentation-on-focusable.md b/docs/rules/no-role-presentation-on-focusable.md new file mode 100644 index 00000000..2d5f0f3b --- /dev/null +++ b/docs/rules/no-role-presentation-on-focusable.md @@ -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 + +``` + +```vue + +``` + +```vue + +``` + +```vue + +``` + +```vue + +``` + +```vue + +``` + +### ❌ Fail + +```vue + +``` + +```vue + +``` + +```vue + +``` + +```vue + +``` + +```vue + +``` diff --git a/src/rules/__tests__/no-aria-hidden-on-focusable.test.ts b/src/rules/__tests__/no-aria-hidden-on-focusable.test.ts new file mode 100644 index 00000000..b22a9f9f --- /dev/null +++ b/src/rules/__tests__/no-aria-hidden-on-focusable.test.ts @@ -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: [ + "", + "", + "
", + "link", + "", + "" + ], + invalid: [ + { + code: "", + errors: [{ messageId: "default" }] + }, + { + code: "", + errors: [{ messageId: "default" }] + }, + { + code: "", + errors: [{ messageId: "default" }] + }, + { + code: "", + errors: [{ messageId: "default" }] + } + ] +}); diff --git a/src/rules/__tests__/no-role-presentation-on-focusable.test.ts b/src/rules/__tests__/no-role-presentation-on-focusable.test.ts new file mode 100644 index 00000000..ddf9c548 --- /dev/null +++ b/src/rules/__tests__/no-role-presentation-on-focusable.test.ts @@ -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: [ + "", + "
", + "
", + "link", + "", + "
Link
" + ], + invalid: [ + { + code: "
", + errors: [{ messageId: "default" }] + }, + { + code: "", + errors: [{ messageId: "default" }] + }, + { + code: "Link", + errors: [{ messageId: "default" }] + }, + { + code: "Icon", + errors: [{ messageId: "default" }] + } + ] +}); diff --git a/src/rules/no-aria-hidden-on-focusable.ts b/src/rules/no-aria-hidden-on-focusable.ts new file mode 100644 index 00000000..bb05696f --- /dev/null +++ b/src/rules/no-aria-hidden-on-focusable.ts @@ -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; diff --git a/src/rules/no-role-presentation-on-focusable.ts b/src/rules/no-role-presentation-on-focusable.ts new file mode 100644 index 00000000..06738847 --- /dev/null +++ b/src/rules/no-role-presentation-on-focusable.ts @@ -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; diff --git a/src/utils.ts b/src/utils.ts index 14fd542a..290b5870 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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"; diff --git a/src/utils/hasFocusableElement.ts b/src/utils/hasFocusableElement.ts new file mode 100644 index 00000000..37d3ab3b --- /dev/null +++ b/src/utils/hasFocusableElement.ts @@ -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;