Repository: vue-a11y/eslint-plugin-vuejs-accessibility (ts)

Issue:
[heading-has-content] fails when Vue component is being used inside
In our project we translate website into multiple languages and we use a special Vue component instead of a text node inside heading tags. Example:

```html
<h1>
  <Trans tag="hello_world">
</h1>
```

which in English renders as:

```html
<h1>
  Hello world!
</h1>
```

The plugin complains about it:
> error  Headings must have content and the content must be accessible by a screen reader  vuejs-accessibility/heading-has-content

Is there a way to implement some exception for specifying which components are allowed to be used inside headings?

Pull request:
hasContent checks include accessibleChildren option
For the `heading-has-content` and the `anchor-has-content` rules, add an `accessibleChildren` option that always marks certain children as being accessible.

Closes #66.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b042c14..d8935e1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a
 
 ## [Unreleased]
 
+### Added
+
+- For the `heading-has-content` and the `anchor-has-content` rules, add an `accessibleChildren` option that always marks certain children as being accessible.
+
 ### Changed
 
 - Handle non string literal role values and `role-has-required-aria-props` and non string literal value for `kind` attribute in `media-has-caption`.
diff --git a/docs/anchor-has-content.md b/docs/anchor-has-content.md
index 3186fbe..ed3f105 100644
--- a/docs/anchor-has-content.md
+++ b/docs/anchor-has-content.md
@@ -16,7 +16,8 @@ This rule takes one optional object argument of type object:
     "vuejs-accessibility/anchor-has-content": [
       "error",
       {
-        "components": ["Anchor"]
+        "components": ["Anchor"],
+        "accessibleChildren": ["MyAccessibleText"]
       }
     ]
   }
@@ -25,6 +26,8 @@ This rule takes one optional object argument of type object:
 
 For the `components` option, these strings determine which elements (**always including** `<a>`) should be checked for having content. This is a good use case when you have a wrapper component that simply renders an `a` element.
 
+For the `accessibleChildren` option, these strings determine which elements should be marked as acceptably accessible child elements. For example if you have something like a `<trans tag="hello-world" />` child that you know will translate into accessible text, then you should put the `Trans` component into this array.
+
 ### Succeed
 
 <!-- prettier-ignore -->
@@ -34,6 +37,8 @@ For the `components` option, these strings determine which elements (**always in
 <a is="TextWrapper" />
 <a v-text="msg" />
 <a v-html="msg" />
+<Anchor>Anchor content</!Anchor>
+<a><my-accessible-text /></a>
 ```
 
 ### Fail
@@ -41,4 +46,5 @@ For the `components` option, these strings determine which elements (**always in
 ```vue
 <a />
 <a><TextWrapper aria-hidden /></a>
+<Anchor></Anchor>
 ```
diff --git a/docs/heading-has-content.md b/docs/heading-has-content.md
index bc97afa..59e730f 100644
--- a/docs/heading-has-content.md
+++ b/docs/heading-has-content.md
@@ -16,7 +16,8 @@ This rule takes one optional object argument of type object:
     "vuejs-accessibility/heading-has-content": [
       "error",
       {
-        "components": ["MyHeading"]
+        "components": ["MyHeading"],
+        "accessibleChildren": ["MyAccessibleText"]
       }
     ]
   }
@@ -25,15 +26,22 @@ This rule takes one optional object argument of type object:
 
 For the `components` option, these strings determine which elements (**always including** `<h1>` thru `<h6>`) should be checked for having content. This is a good use case when you have a wrapper component that simply renders an `h1` element.
 
+For the `accessibleChildren` option, these strings determine which elements should be marked as acceptably accessible child elements. For example if you have something like a `<trans tag="hello-world" />` child that you know will translate into accessible text, then you should put the `Trans` component into this array.
+
 ### Succeed
 
 ```vue
 <h1>Heading Content!</h1>
 <h1 v-html="msg"></h1>
+<MyHeading>Heading Content!</MyHeading>
+<h1>
+  <MyAccessibleText />
+</h1>
 ```
 
 ### Fail
 
 ```vue
 <h1></h1>
+<MyHeading></MyHeading>
 ```
diff --git a/src/rules/anchor-has-content.js b/src/rules/anchor-has-content.js
index 6660fd1..53feff0 100644
--- a/src/rules/anchor-has-content.js
+++ b/src/rules/anchor-has-content.js
@@ -23,6 +23,10 @@ module.exports = {
           components: {
             type: "array",
             items: { type: "string" }
+          },
+          accessibleChildren: {
+            type: "array",
+            items: { type: "string" }
           }
         }
       }
@@ -31,14 +35,17 @@ module.exports = {
   create(context) {
     return defineTemplateBodyVisitor(context, {
       VElement(node) {
-        const { components = [] } = context.options[0] || {};
+        const { components = [], accessibleChildren = [] } =
+          context.options[0] || {};
 
         const elementTypes = ["a"].concat(components.map(makeKebabCase));
+        const accessibleChildTypes = accessibleChildren.map(makeKebabCase);
+
         const elementType = getElementType(node);
 
         if (
           elementTypes.includes(elementType) &&
-          !hasContent(node) &&
+          !hasContent(node, accessibleChildTypes) &&
           !hasAriaLabel(node)
         ) {
           context.report({ node, messageId: "default" });
diff --git a/src/rules/heading-has-content.js b/src/rules/heading-has-content.js
index 0b6fae1..f3d7428 100644
--- a/src/rules/heading-has-content.js
+++ b/src/rules/heading-has-content.js
@@ -24,6 +24,10 @@ module.exports = {
           components: {
             type: "array",
             items: { type: "string" }
+          },
+          accessibleChildren: {
+            type: "array",
+            items: { type: "string" }
           }
         }
       }
@@ -32,12 +36,18 @@ module.exports = {
   create(context) {
     return defineTemplateBodyVisitor(context, {
       VElement(node) {
-        const { components = [] } = context.options[0] || {};
+        const { components = [], accessibleChildren = [] } =
+          context.options[0] || {};
 
         const elementTypes = headings.concat(components.map(makeKebabCase));
+        const accessibleChildTypes = accessibleChildren.map(makeKebabCase);
+
         const elementType = getElementType(node);
 
-        if (elementTypes.includes(elementType) && !hasContent(node)) {
+        if (
+          elementTypes.includes(elementType) &&
+          !hasContent(node, accessibleChildTypes)
+        ) {
           context.report({ node, messageId: "default" });
         }
       }
diff --git a/src/utils/hasAccessibleChild.js b/src/utils/hasAccessibleChild.js
index d2e3ca4..9c03e1d 100644
--- a/src/utils/hasAccessibleChild.js
+++ b/src/utils/hasAccessibleChild.js
@@ -1,15 +1,21 @@
+const getElementType = require("./getElementType");
 const isHiddenFromScreenReader = require("./isHiddenFromScreenReader");
 
-const hasAccessibleChild = (node) =>
+const hasAccessibleChild = (node, accessibleChildTypes = []) =>
   node.children.some((child) => {
     switch (child.type) {
       case "VText":
         return child.value.trim().length > 0;
-      case "VElement":
+      case "VElement": {
+        const elementType = getElementType(child);
+
         return (
+          accessibleChildTypes.includes(elementType) ||
           child.rawName === "slot" ||
-          (!isHiddenFromScreenReader(child) && hasAccessibleChild(child))
+          (!isHiddenFromScreenReader(child) &&
+            hasAccessibleChild(child, accessibleChildTypes))
         );
+      }
       case "VExpressionContainer":
         if (child.expression && child.expression.type === "Identifier") {
           return child.expression.name !== "undefined";
diff --git a/src/utils/hasContent.js b/src/utils/hasContent.js
index 479cf70..91b28a4 100644
--- a/src/utils/hasContent.js
+++ b/src/utils/hasContent.js
@@ -22,8 +22,8 @@ const hasChildImageWithAlt = (node) =>
     }
   });
 
-const hasContent = (node) =>
-  hasAccessibleChild(node) ||
+const hasContent = (node, accessibleChildTypes) =>
+  hasAccessibleChild(node, accessibleChildTypes) ||
   hasDirective(node, "text") ||
   hasDirective(node, "html") ||
   hasChildImageWithAlt(node);


Test patch:
diff --git a/src/rules/__tests__/anchor-has-content.test.js b/src/rules/__tests__/anchor-has-content.test.js
index 756c7a7..180a0cb 100644
--- a/src/rules/__tests__/anchor-has-content.test.js
+++ b/src/rules/__tests__/anchor-has-content.test.js
@@ -10,7 +10,11 @@ makeRuleTester("anchor-has-content", rule, {
     "<a><slot /></a>",
     "<VAnchor  />",
     "<a aria-label='This is my label' />",
-    "<a><img alt='foo' /></a>"
+    "<a><img alt='foo' /></a>",
+    {
+      code: "<a><accessible-child /></a>",
+      options: [{ accessibleChildren: ["AccessibleChild"] }]
+    }
   ],
   invalid: [
     "<a />",
diff --git a/src/rules/__tests__/heading-has-content.test.js b/src/rules/__tests__/heading-has-content.test.js
index d9ca15c..4ef23f3 100644
--- a/src/rules/__tests__/heading-has-content.test.js
+++ b/src/rules/__tests__/heading-has-content.test.js
@@ -8,7 +8,11 @@ makeRuleTester("heading-has-content", rule, {
     "<h1 v-text='msg'></h1>",
     "<h1 v-html='msg'></h1>",
     "<h1>{{ test }}</h1>",
-    "<h1><slot /></h1>"
+    "<h1><slot /></h1>",
+    {
+      code: "<h1><accessible-child /></h1>",
+      options: [{ accessibleChildren: ["AccessibleChild"] }]
+    }
   ],
   invalid: [
     "<h1 />",