diff --git a/docs/form-control-has-label.md b/docs/form-control-has-label.md index 82a03f89..443b5d8c 100644 --- a/docs/form-control-has-label.md +++ b/docs/form-control-has-label.md @@ -16,7 +16,12 @@ This rule takes one optional object argument of type object: "vuejs-accessibility/form-control-has-label": [ "error", { - "labelComponents": ["CustomLabel"], + "labelComponents": [ + "CustomLabel", + ], + "labelComponentsWithRequiredAttributes": [ + { "name": "CustomComponentWithRequiredAttribute", "requiredAttributes": ["label"] }, + ] "controlComponents": ["CustomInput"] } ] @@ -26,6 +31,8 @@ This rule takes one optional object argument of type object: 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. +For the `labelComponentsWithRequiredAttributes` option, these objects determine which elements should be checked for having a value for any of the `requiredAttributes` as an attribute. This is a good use case when you have a wrapper component that renders a `label` element when a specific attribute is provided. + For the `controlComponents` option, these strings determine which elements (**always including** `<input>`, `<textarea>` and `<select>`) should be checked for having an associated label. This is a good use case when you have a wrapper component that simply renders an input element. ### Succeed diff --git a/src/rules/__tests__/form-control-has-label.test.ts b/src/rules/__tests__/form-control-has-label.test.ts index 2442afd3..437d49d5 100644 --- a/src/rules/__tests__/form-control-has-label.test.ts +++ b/src/rules/__tests__/form-control-has-label.test.ts @@ -26,16 +26,73 @@ makeRuleTester("form-control-has-label", rule, { code: "<custom-label for='input'>text</custom-label><input type='text' id='input' />", options: [{ labelComponents: ["CustomLabel"] }] }, + { + code: "<custom-label label='text'><input type='text' id='input' /></custom-label>", + options: [{ + labelComponentsWithRequiredAttributes: [ + { name: "CustomLabel", requiredAttributes: ["label"] }, + ], + }] + }, + { + code: ` + <custom-label label='text'><input type='text' id='input' /></custom-label> + <custom-label-other><input type='text' id='input' /></custom-label-other> + `, + options: [{ + labelComponents: [ + "CustomLabelOther", + ], + labelComponentsWithRequiredAttributes: [ + { name: "CustomLabel", requiredAttributes: ["label"] }, + ], + }] + }, + { + code: "<custom-label label='text' id='id' for='bla'><input type='text' id='input' /></custom-label>", + options: [{ + labelComponentsWithRequiredAttributes: [ + { name: "CustomLabel", requiredAttributes: ["label", "id", "for"] }, + ], + }] + }, + { + code: "<custom-label><input type='text' id='input' /></custom-label>", + options: [{ labelComponents: ["CustomLabel"] }] + }, "<b-form-input />" ], invalid: [ "<input type='text' />", "<textarea type='text'></textarea>", "<custom-label for='input'>text</custom-label><input type='text' id='input' />", + { + code: "<custom-label><input type='text' id='input' /></custom-label>", + options: [{ + labelComponentsWithRequiredAttributes: [ + { name: "CustomLabel", requiredAttributes: ["label"] }, + ], + }], + errors: [{ messageId: "default" }] + }, { code: "<div><b-form-input /></div>", options: [{ controlComponents: ["b-form-input"] }], errors: [{ messageId: "default" }] - } + }, + { + code: "<div label='text'><b-form-input /></div>", + options: [{ controlComponents: ["b-form-input"] }], + errors: [{ messageId: "default" }] + }, + { + code: "<custom-label label='text'>label next to input</custom-label><input type='text' id='input' />", + options: [{ + labelComponentsWithRequiredAttributes: [ + { name: "CustomLabel", requiredAttributes: ["label"] }, + ], + }], + errors: [{ messageId: "default" }] + }, ] }); diff --git a/src/rules/form-control-has-label.ts b/src/rules/form-control-has-label.ts index 9b147400..b0799ecf 100644 --- a/src/rules/form-control-has-label.ts +++ b/src/rules/form-control-has-label.ts @@ -1,8 +1,14 @@ import type { Rule } from "eslint"; import type { AST } from "vue-eslint-parser"; +interface LabelComponentsRequiredAttributes { + name: string; + requiredAttributes: string[]; +} + interface FormControlHasLabelOptions { labelComponents: string[]; + labelComponentsWithRequiredAttributes: LabelComponentsRequiredAttributes[]; } import { @@ -23,10 +29,22 @@ function isLabelElement( | AST.VExpressionContainer, { labelComponents = [] }: FormControlHasLabelOptions ) { - const allLabelComponents = labelComponents.concat("label"); + const allLabelComponents: string[] = labelComponents.concat("label"); return isMatchingElement(node, allLabelComponents); } +function isLabelElementWithRequiredAttributes( + node: | AST.VElement, + { labelComponentsWithRequiredAttributes = [] }: FormControlHasLabelOptions +) { + return labelComponentsWithRequiredAttributes.some((component) => ( + isMatchingElement(node, [component.name]) && + component.requiredAttributes.some( + (attr) => getElementAttributeValue(node, attr) + ) + )); +} + function hasLabelElement( node: AST.VElement, options: FormControlHasLabelOptions @@ -37,7 +55,13 @@ function hasLabelElement( [parent, ...parent.children].some((node) => isLabelElement(node, options) ) || - (parent && parent.type === "VElement" && hasLabelElement(parent, options)) + ( + parent && parent.type === "VElement" && + ( + isLabelElementWithRequiredAttributes(parent, options) || + hasLabelElement(parent, options) + ) + ) ); } @@ -62,6 +86,22 @@ const rule: Rule.RuleModule = { }, uniqueItems: true }, + labelComponentsWithRequiredAttributes: { + type: "array", + items: { + type: "object", + properties: { + name: { + type: "string", + }, + requiredAttributes: { + type: "array", + items: { type: "string" } + }, + } + }, + uniqueItems: true + }, controlComponents: { type: "array", items: {