Skip to content

Commit

Permalink
[New] add attributes setting
Browse files Browse the repository at this point in the history
  • Loading branch information
edoardocavazza committed Apr 15, 2024
1 parent 0d5321a commit 2031c01
Show file tree
Hide file tree
Showing 8 changed files with 61 additions and 20 deletions.
7 changes: 7 additions & 0 deletions README.md
Expand Up @@ -103,6 +103,9 @@ Add `plugin:jsx-a11y/recommended` or `plugin:jsx-a11y/strict` in `extends`:
"CustomButton": "button",
"MyButton": "button",
"RoundButton": "button"
},
"attributes": {
"for": ["htmlFor", "for"]
}
}
}
Expand All @@ -113,6 +116,10 @@ Add `plugin:jsx-a11y/recommended` or `plugin:jsx-a11y/strict` in `extends`:

To enable your custom components to be checked as DOM elements, you can set global settings in your configuration file by mapping each custom component name to a DOM element type.

#### Attribute Mapping

To configure the JSX property to use for attribute checking, you can set global settings in your configuration file by mapping each DOM attribute to the JSX property you want to check. For example, you may want to allow the `for` attribute in addition to the `htmlFor` attribute for checking label associations.

#### Polymorphic Components

You can optionally use the `polymorphicPropName` setting to define the prop your code uses to create polymorphic components.
Expand Down
4 changes: 4 additions & 0 deletions __tests__/src/rules/label-has-associated-control-test.js
Expand Up @@ -40,6 +40,10 @@ const htmlForValid = [
{ code: '<label htmlFor="js_id" aria-label="A label" />' },
{ code: '<label htmlFor="js_id" aria-labelledby="A label" />' },
{ code: '<div><label htmlFor="js_id">A label</label><input id="js_id" /></div>' },
{ code: '<label for="js_id"><span><span><span>A label</span></span></span></label>', options: [{ depth: 4, htmlForAttributes: ['htmlFor', 'for'] }] },
{ code: '<label for="js_id" aria-label="A label" />', options: [{ htmlForAttributes: ['htmlFor', 'for'] }] },
{ code: '<label for="js_id" aria-labelledby="A label" />', options: [{ htmlForAttributes: ['htmlFor', 'for'] }] },
{ code: '<div><label for="js_id">A label</label><input id="js_id" /></div>', options: [{ htmlForAttributes: ['htmlFor', 'for'] }] },
// Custom label component.
{ code: '<CustomLabel htmlFor="js_id" aria-label="A label" />', options: [{ labelComponents: ['CustomLabel'] }] },
{ code: '<CustomLabel htmlFor="js_id" label="A label" />', options: [{ labelAttributes: ['label'], labelComponents: ['CustomLabel'] }] },
Expand Down
4 changes: 4 additions & 0 deletions __tests__/src/rules/label-has-for-test.js
Expand Up @@ -56,10 +56,14 @@ ruleTester.run('label-has-for', rule, {
{ code: '<div />' },
{ code: '<label htmlFor="foo"><input /></label>' },
{ code: '<label htmlFor="foo"><textarea /></label>' },
{ code: '<label for="foo"><input /></label>', options: [{ htmlForAttributes: ['htmlFor', 'for'] }] },
{ code: '<label for="foo"><textarea /></label>', options: [{ htmlForAttributes: ['htmlFor', 'for'] }] },
{ code: '<Label />' }, // lower-case convention refers to real HTML elements.
{ code: '<Label htmlFor="foo" />' },
{ code: '<Label for="foo" />', options: [{ htmlForAttributes: ['htmlFor', 'for'] }] },
{ code: '<Descriptor />' },
{ code: '<Descriptor htmlFor="foo">Test!</Descriptor>' },
{ code: '<Descriptor for="foo">Test!</Descriptor>', options: [{ htmlForAttributes: ['htmlFor', 'for'] }] },
{ code: '<UX.Layout>test</UX.Layout>' },

// CUSTOM ELEMENT ARRAY OPTION TESTS
Expand Down
2 changes: 2 additions & 0 deletions docs/rules/label-has-associated-control.md
Expand Up @@ -53,6 +53,7 @@ And the configuration:
{
"rules": {
"jsx-a11y/label-has-associated-control": [ 2, {
"htmlForAttributes": ["htmlFor", "for"],
"labelComponents": ["CustomInputLabel"],
"labelAttributes": ["label"],
"controlComponents": ["CustomInput"],
Expand Down Expand Up @@ -103,6 +104,7 @@ This rule takes one optional object argument of type object:
}
```

`htmlForAttributes`: is an array of strings that specify the attribute to check for an associated control. Default is `["htmlFor"]`.
`labelComponents` is a list of custom React Component names that should be checked for an associated control.
`labelAttributes` is a list of attributes to check on the label component and its children for a label. Use this if you have a custom component that uses a string passed on a prop to render an HTML `label`, for example.
`controlComponents` is a list of custom React Components names that will output an input element. [Glob format](https://linuxhint.com/bash_globbing_tutorial/) is also supported for specifying names (e.g., `Label*` matches `LabelComponent` but not `CustomLabel`, `????Label` matches `LinkLabel` but not `CustomLabel`).
Expand Down
5 changes: 4 additions & 1 deletion docs/rules/label-has-for.md
Expand Up @@ -13,7 +13,7 @@ Enforce label tags have associated control.
There are two supported ways to associate a label with a control:

- nesting: by wrapping a control in a label tag
- id: by using the prop `htmlFor` as in `htmlFor=[ID of control]`
- id: by using the prop `htmlFor` (or any configured attribute) as in `htmlFor=[ID of control]`

To fully cover 100% of assistive devices, you're encouraged to validate for both nesting and id.

Expand All @@ -25,6 +25,7 @@ This rule takes one optional object argument of type object:
{
"rules": {
"jsx-a11y/label-has-for": [ 2, {
"htmlForAttributes": ["htmlFor", "for"],
"components": [ "Label" ],
"required": {
"every": [ "nesting", "id" ]
Expand All @@ -35,6 +36,8 @@ This rule takes one optional object argument of type object:
}
```

The `htmlForAttributes` allows you to specify which prop to check for. This is useful when you want to use a different property instead of `htmlFor`, for example `for`.

For the `components` option, these strings determine which JSX elements (**always including** `<label>`) should be checked for having `htmlFor` prop. This is a good use case when you have a wrapper component that simply renders a `label` element (like in React):

```js
Expand Down
3 changes: 2 additions & 1 deletion flow/eslint.js
Expand Up @@ -10,7 +10,8 @@ export type ESLintSettings = {
[string]: mixed,
'jsx-a11y'?: {
polymorphicPropName?: string,
components?: {[string]: string},
components?: { [string]: string },
attributes?: { for?: string[] },
},
}

Expand Down
22 changes: 16 additions & 6 deletions src/rules/label-has-associated-control.js
Expand Up @@ -9,7 +9,7 @@
// Rule Definition
// ----------------------------------------------------------------------------

import { getProp, getPropValue } from 'jsx-ast-utils';
import { hasProp, getProp, getPropValue } from 'jsx-ast-utils';
import type { JSXElement } from 'ast-types-flow';
import { generateObjSchema, arraySchema } from '../util/schemas';
import type { ESLintConfig, ESLintContext, ESLintVisitorSelectorConfig } from '../../flow/eslint';
Expand All @@ -20,6 +20,7 @@ import mayHaveAccessibleLabel from '../util/mayHaveAccessibleLabel';
const errorMessage = 'A form label must be associated with a control.';

const schema = generateObjSchema({
htmlForAttributes: arraySchema,
labelComponents: arraySchema,
labelAttributes: arraySchema,
controlComponents: arraySchema,
Expand All @@ -35,11 +36,18 @@ const schema = generateObjSchema({
},
});

const validateId = (node) => {
const htmlForAttr = getProp(node.attributes, 'htmlFor');
const htmlForValue = getPropValue(htmlForAttr);
const validateId = (node, htmlForAttributes) => {
for (let i = 0; i < htmlForAttributes.length; i += 1) {
const attribute = htmlForAttributes[i];
if (hasProp(node.attributes, attribute)) {
const htmlForAttr = getProp(node.attributes, attribute);
const htmlForValue = getPropValue(htmlForAttr);

return htmlForAttr !== false && !!htmlForValue;
return htmlForAttr !== false && !!htmlForValue;
}
}

return false;
};

export default ({
Expand All @@ -52,7 +60,9 @@ export default ({
},

create: (context: ESLintContext): ESLintVisitorSelectorConfig => {
const { settings } = context;
const options = context.options[0] || {};
const htmlForAttributes = options.htmlForAttributes ?? settings['jsx-a11y']?.attributes?.for ?? ['htmlFor'];
const labelComponents = options.labelComponents || [];
const assertType = options.assert || 'either';
const componentNames = ['label'].concat(labelComponents);
Expand All @@ -75,7 +85,7 @@ export default ({
options.depth === undefined ? 2 : options.depth,
25,
);
const hasLabelId = validateId(node.openingElement);
const hasLabelId = validateId(node.openingElement, htmlForAttributes);
// Check for multiple control components.
const hasNestedControl = controlComponents.some((name) => mayContainChildComponent(
node,
Expand Down
34 changes: 22 additions & 12 deletions src/rules/label-has-for.js
Expand Up @@ -7,7 +7,7 @@
// Rule Definition
// ----------------------------------------------------------------------------

import { getProp, getPropValue } from 'jsx-ast-utils';
import { hasProp, getProp, getPropValue } from 'jsx-ast-utils';
import { generateObjSchema, arraySchema, enumArraySchema } from '../util/schemas';
import getElementType from '../util/getElementType';
import hasAccessibleChild from '../util/hasAccessibleChild';
Expand All @@ -16,6 +16,7 @@ const enumValues = ['nesting', 'id'];
const schema = {
type: 'object',
properties: {
htmlForAttributes: arraySchema,
components: arraySchema,
required: {
oneOf: [
Expand Down Expand Up @@ -45,40 +46,47 @@ function validateNesting(node) {
return false;
}

const validateId = (node) => {
const htmlForAttr = getProp(node.attributes, 'htmlFor');
const htmlForValue = getPropValue(htmlForAttr);
const validateId = (node, htmlForAttributes) => {
for (let i = 0; i < htmlForAttributes.length; i += 1) {
const attribute = htmlForAttributes[i];
if (hasProp(node.attributes, attribute)) {
const htmlForAttr = getProp(node.attributes, attribute);
const htmlForValue = getPropValue(htmlForAttr);

return htmlForAttr !== false && !!htmlForValue;
return htmlForAttr !== false && !!htmlForValue;
}
}

return false;
};

const validate = (node, required, allowChildren, elementType) => {
const validate = (node, required, allowChildren, elementType, htmlForAttributes) => {
if (allowChildren === true) {
return hasAccessibleChild(node.parent, elementType);
}
if (required === 'nesting') {
return validateNesting(node);
}
return validateId(node);
return validateId(node, htmlForAttributes);
};

const getValidityStatus = (node, required, allowChildren, elementType) => {
const getValidityStatus = (node, required, allowChildren, elementType, htmlForAttributes) => {
if (Array.isArray(required.some)) {
const isValid = required.some.some((rule) => validate(node, rule, allowChildren, elementType));
const isValid = required.some.some((rule) => validate(node, rule, allowChildren, elementType, htmlForAttributes));
const message = !isValid
? `Form label must have ANY of the following types of associated control: ${required.some.join(', ')}`
: null;
return { isValid, message };
}
if (Array.isArray(required.every)) {
const isValid = required.every.every((rule) => validate(node, rule, allowChildren, elementType));
const isValid = required.every.every((rule) => validate(node, rule, allowChildren, elementType, htmlForAttributes));
const message = !isValid
? `Form label must have ALL of the following types of associated control: ${required.every.join(', ')}`
: null;
return { isValid, message };
}

const isValid = validate(node, required, allowChildren, elementType);
const isValid = validate(node, required, allowChildren, elementType, htmlForAttributes);
const message = !isValid
? `Form label must have the following type of associated control: ${required}`
: null;
Expand All @@ -100,7 +108,9 @@ export default {
const elementType = getElementType(context);
return {
JSXOpeningElement: (node) => {
const { settings } = context;
const options = context.options[0] || {};
const htmlForAttributes = options.htmlForAttributes ?? settings['jsx-a11y']?.attributes?.for ?? ['htmlFor'];
const componentOptions = options.components || [];
const typesToValidate = ['label'].concat(componentOptions);
const nodeType = elementType(node);
Expand All @@ -113,7 +123,7 @@ export default {
const required = options.required || { every: ['nesting', 'id'] };
const allowChildren = options.allowChildren || false;

const { isValid, message } = getValidityStatus(node, required, allowChildren, elementType);
const { isValid, message } = getValidityStatus(node, required, allowChildren, elementType, htmlForAttributes);
if (!isValid) {
context.report({
node,
Expand Down

0 comments on commit 2031c01

Please sign in to comment.