Repository: vue-a11y/eslint-plugin-vuejs-accessibility
Pull request #334 (2021-11-12 11:50:50)

Add labelComponents option to form control has label
We have custom label components in our app which means the `form-control-has-label` rule is always triggered. 

I've leant on the options setup for `label-has-for` and implemented a similar setup where you can pass an array of custom components to be considered as `label` elements by the rule for validation.

In the app template:

```
<CustomLabel for="myInput">Label</CustomLabel>
<input type="text" id="myInput" />
```

Pass the label components as part of the ruleset:

```
"vuejs-accessibility/form-control-has-label": [
      "error",
      {
        "labelComponents": ["CustomLabel"],
      }
    ]
```


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ec6856..e0d4816 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
+
+- `labelComponents` options to add custom label components in `form-control-has-label`
+
 ## [1.1.1] - 2021-12-23
 
 ### Changed
diff --git a/docs/form-control-has-label.md b/docs/form-control-has-label.md
index 2865efa..11cc8f5 100644
--- a/docs/form-control-has-label.md
+++ b/docs/form-control-has-label.md
@@ -8,6 +8,23 @@ _References:_
 
 ## Rule details
 
+This rule takes one optional object argument of type object:
+
+```json
+{
+  "rules": {
+    "vuejs-accessibility/form-control-has-label": [
+      "error",
+      {
+        "labelComponents": ["CustomLabel"],
+      }
+    ]
+  }
+}
+```
+
+For the `labelComponents` option, these strings determine which elements (**always including** `<label>`) should be checked for having the `for` prop. This is a good use case when you have a wrapper component that simply renders a `label` element.
+
 ### Succeed
 
 ```
diff --git a/src/rules/form-control-has-label.ts b/src/rules/form-control-has-label.ts
index 037846c..9ad344f 100644
--- a/src/rules/form-control-has-label.ts
+++ b/src/rules/form-control-has-label.ts
@@ -1,13 +1,18 @@
 import type { Rule } from "eslint";
 import type { AST } from "vue-eslint-parser";
 
+interface FormControlHasLabelOptions {
+  labelComponents: string[];
+}
+
 import {
   defineTemplateBodyVisitor,
   getElementAttributeValue,
   getElementType,
   hasAriaLabel,
   isAriaHidden,
-  makeDocsURL
+  isMatchingElement,
+  makeDocsURL,
 } from "../utils";
 
 function isLabelElement(
@@ -15,17 +20,19 @@ function isLabelElement(
     | AST.VElement
     | AST.VDocumentFragment
     | AST.VText
-    | AST.VExpressionContainer
+    | AST.VExpressionContainer,
+  { labelComponents = [] }: FormControlHasLabelOptions
 ) {
-  return node.type === "VElement" && getElementType(node) === "label";
+  const allLabelComponents = labelComponents.concat("label");
+  return isMatchingElement(node, allLabelComponents);
 }
 
-function hasLabelElement(node: AST.VElement): boolean {
+function hasLabelElement(node: AST.VElement, options: FormControlHasLabelOptions): boolean {
   const { parent } = node;
 
   return (
-    [parent, ...parent.children].some(isLabelElement) ||
-    (parent && parent.type === "VElement" && hasLabelElement(parent))
+    [parent, ...parent.children].some((node) => isLabelElement(node, options)) ||
+    (parent && parent.type === "VElement" && hasLabelElement(parent, options))
   );
 }
 
@@ -39,13 +46,28 @@ const rule: Rule.RuleModule = {
       default:
         "Each form element must have a programmatically associated label element."
     },
-    schema: []
+    schema: [
+      {
+        type: "object",
+        properties: {
+          labelComponents: {
+            type: "array",
+            items: {
+              type: "string"
+            },
+            uniqueItems: true
+          }
+        }
+      }
+    ]
   },
   create(context) {
     return defineTemplateBodyVisitor(context, {
       VElement(node) {
+        const options = context.options[0] || {};
         const elementType = getElementType(node);
-        if (!["input", "textarea"].includes(elementType)) {
+
+        if (!["input", "textarea", "select"].includes(elementType)) {
           return;
         }
 
@@ -65,7 +87,7 @@ const rule: Rule.RuleModule = {
         if (
           !isAriaHidden(node) &&
           !hasAriaLabel(node) &&
-          !hasLabelElement(node)
+          !hasLabelElement(node, options)
         ) {
           context.report({ node: node as any, messageId: "default" });
         }
diff --git a/src/utils.ts b/src/utils.ts
index 55bf292..7e2a25d 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -14,6 +14,7 @@ export { default as isAriaHidden } from "./utils/isAriaHidden";
 export { default as isAttribute } from "./utils/isAttribute";
 export { default as isHiddenFromScreenReader } from "./utils/isHiddenFromScreenReader";
 export { default as isInteractiveElement } from "./utils/isInteractiveElement";
+export { default as isMatchingElement } from "./utils/isMatchingElement";
 export { default as isPresentationRole } from "./utils/isPresentationRole";
 export { default as makeDocsURL } from "./utils/makeDocsURL";
 export { default as makeKebabCase } from "./utils/makeKebabCase";
diff --git a/src/utils/isMatchingElement.ts b/src/utils/isMatchingElement.ts
new file mode 100644
index 0000000..c10ecd7
--- /dev/null
+++ b/src/utils/isMatchingElement.ts
@@ -0,0 +1,23 @@
+import type { AST } from "vue-eslint-parser";
+
+import getElementType from "./getElementType";
+import makeKebabCase from "./makeKebabCase";
+
+function isMatchingElement(
+  node:
+  | AST.VElement
+  | AST.VDocumentFragment
+  | AST.VText
+  | AST.VExpressionContainer, 
+  searchArray: string[]
+) {
+  if (!(node.type === "VElement")) return false;
+
+  const elementType = getElementType(node);
+  
+  return searchArray.some((item: string) => {
+    return makeKebabCase(item) === elementType;
+  });
+}
+
+export default isMatchingElement;
\ No newline at end of file


Test patch:
diff --git a/src/rules/__tests__/form-control-has-label.test.ts b/src/rules/__tests__/form-control-has-label.test.ts
index 25c1d0f..66e06c2 100644
--- a/src/rules/__tests__/form-control-has-label.test.ts
+++ b/src/rules/__tests__/form-control-has-label.test.ts
@@ -21,7 +21,15 @@ makeRuleTester("form-control-has-label", rule, {
       <div aria-hidden="true">
         <input value="1" type="text" />
       </div>
-    `
+    `,
+    {
+      code: "<custom-label for='input'>text</custom-label><input type='text' id='input' />",
+      options: [{ labelComponents: ["CustomLabel"] }]
+    },
   ],
-  invalid: ["<input type='text' />", "<textarea type='text'></textarea>"]
+  invalid: [
+    "<input type='text' />", 
+    "<textarea type='text'></textarea>",
+    "<custom-label for='input'>text</custom-label><input type='text' id='input' />"
+  ]
 });