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;