Skip to content

Commit

Permalink
feat(eslint-plugin-template): add rule accessibility-label-has-associ…
Browse files Browse the repository at this point in the history
…ated-control (#392)
  • Loading branch information
rafaelss95 committed May 12, 2021
1 parent 1539564 commit 0851f3e
Show file tree
Hide file tree
Showing 5 changed files with 232 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/eslint-plugin-template/src/configs/all.json
Expand Up @@ -4,6 +4,7 @@
"@angular-eslint/template/accessibility-alt-text": "error",
"@angular-eslint/template/accessibility-elements-content": "error",
"@angular-eslint/template/accessibility-label-for": "error",
"@angular-eslint/template/accessibility-label-has-associated-control": "error",
"@angular-eslint/template/accessibility-table-scope": "error",
"@angular-eslint/template/accessibility-valid-aria": "error",
"@angular-eslint/template/banana-in-box": "error",
Expand Down
4 changes: 4 additions & 0 deletions packages/eslint-plugin-template/src/index.ts
Expand Up @@ -12,6 +12,9 @@ import accessibilityElementsContent, {
import accessibilityLabelFor, {
RULE_NAME as accessibilityLabelForRuleName,
} from './rules/accessibility-label-for';
import accessibilityLabelHasAssociatedControl, {
RULE_NAME as accessibilityLabelHasAssociatedControlRuleName,
} from './rules/accessibility-label-has-associated-control';
import accessibilityTableScope, {
RULE_NAME as accessibilityTableScopeRuleName,
} from './rules/accessibility-table-scope';
Expand Down Expand Up @@ -69,6 +72,7 @@ export default {
[accessibilityAltTextRuleName]: accessibilityAltText,
[accessibilityElementsContentRuleName]: accessibilityElementsContent,
[accessibilityLabelForRuleName]: accessibilityLabelFor,
[accessibilityLabelHasAssociatedControlRuleName]: accessibilityLabelHasAssociatedControl,
[accessibilityTableScopeRuleName]: accessibilityTableScope,
[accessibilityValidAriaRuleName]: accessibilityValidAria,
[bananaInBoxRuleName]: bananaInBox,
Expand Down
Expand Up @@ -34,6 +34,8 @@ const DEFAULT_LABEL_COMPONENTS = ['label'] as const;
export default createESLintRule<Options, MessageIds>({
name: RULE_NAME,
meta: {
deprecated: true,
replacedBy: ['accessibility-label-has-associated-control'],
type: 'suggestion',
docs: {
description:
Expand Down
@@ -0,0 +1,126 @@
import type { TmplAstElement } from '@angular/compiler';
import {
createESLintRule,
getTemplateParserServices,
} from '../utils/create-eslint-rule';
import { isChildNodeOf } from '../utils/is-child-node-of';

type LabelComponent = {
readonly inputs?: readonly string[];
readonly selector: string;
};
type Options = [
{
readonly controlComponents?: readonly string[];
readonly labelComponents?: readonly LabelComponent[];
},
];
export type MessageIds = 'accessibilityLabelHasAssociatedControl';
export const RULE_NAME = 'accessibility-label-has-associated-control';
const DEFAULT_CONTROL_COMPONENTS = [
'input',
'meter',
'output',
'progress',
'select',
'textarea',
];
const DEFAULT_LABEL_COMPONENTS: readonly LabelComponent[] = [
{ inputs: ['for', 'htmlFor'], selector: 'label' },
];
const DEFAULT_OPTIONS: Options[0] = {
controlComponents: DEFAULT_CONTROL_COMPONENTS,
labelComponents: DEFAULT_LABEL_COMPONENTS,
};

export default createESLintRule<Options, MessageIds>({
name: RULE_NAME,
meta: {
type: 'suggestion',
docs: {
description:
'Ensures that a label element/component is associated with a form element',
category: 'Best Practices',
recommended: false,
},
schema: [
{
additionalProperties: false,
properties: {
controlComponents: {
items: { type: 'string' },
type: 'array',
uniqueItems: true,
},
labelComponents: {
items: { required: ['selector'], type: 'object' },
type: 'array',
uniqueItems: true,
},
},
type: 'object',
},
],
messages: {
accessibilityLabelHasAssociatedControl:
'A label component must be associated with a form element',
},
},
defaultOptions: [DEFAULT_OPTIONS],
create(context, [{ controlComponents, labelComponents }]) {
const parserServices = getTemplateParserServices(context);
const allControlComponents: ReadonlySet<string> = new Set([
...DEFAULT_CONTROL_COMPONENTS,
...(controlComponents ?? []),
]);
const allLabelComponents = [
...DEFAULT_LABEL_COMPONENTS,
...(labelComponents ?? []),
] as const;
const labelSelectors = allLabelComponents.map(({ selector }) => selector);
const labelComponentsPattern = toPattern(labelSelectors);

return {
[`Element[name=${labelComponentsPattern}]`](node: TmplAstElement) {
const element = allLabelComponents.find(
({ selector }) => selector === node.name,
);

if (!element) return;

const attributesInputs: ReadonlySet<string> = new Set(
[...node.attributes, ...node.inputs].map(({ name }) => name),
);
const hasInput = element.inputs?.some((input) =>
attributesInputs.has(input),
);

if (hasInput || hasControlComponentIn(allControlComponents, node)) {
return;
}

const loc = parserServices.convertNodeSourceSpanToLoc(node.sourceSpan);

context.report({
loc,
messageId: 'accessibilityLabelHasAssociatedControl',
});
},
};
},
});

function hasControlComponentIn(
controlComponents: ReadonlySet<string>,
element: TmplAstElement,
): boolean {
return Boolean(
[...controlComponents].some((controlComponent) =>
isChildNodeOf(element, controlComponent),
),
);
}

function toPattern(value: readonly unknown[]): RegExp {
return RegExp(`^(${value.join('|')})$`);
}
@@ -0,0 +1,99 @@
import {
convertAnnotatedSourceToFailureCase,
RuleTester,
} from '@angular-eslint/utils';
import type { MessageIds } from '../../src/rules/accessibility-label-has-associated-control';
import rule, {
RULE_NAME,
} from '../../src/rules/accessibility-label-has-associated-control';

//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------

const ruleTester = new RuleTester({
parser: '@angular-eslint/template-parser',
});

const messageId: MessageIds = 'accessibilityLabelHasAssociatedControl';

ruleTester.run(RULE_NAME, rule, {
valid: [
`
<ng-container *ngFor="let item of items; index as index">
<label for="item-{{index}}">Label #{{index}</label>
<input id="item-{{index}}" [(ngModel)]="item.name">
</ng-container>
<label for="id"></label>
<label for="{{id}}"></label>
<label [attr.for]="id"></label>
<label [htmlFor]="id"></label>
`,
{
code: `
<app-label id="name"></app-label>
<app-label id="{{name}}"></app-label>
<app-label [id]="name"></app-label>
<label [htmlFor]="id"></label>
`,
options: [
{
controlComponents: ['app-input'],
labelComponents: [{ inputs: ['id'], selector: 'app-label' }],
},
],
},
{
code: `
<label><input type="radio"></label>
<label><meter></meter></label>
<label><output></output></label>
<label><progress></progress></label>
<label><select><option>1</option></select></label>
<label><textarea></textarea></label>
<a-label><input></a-label>
<label>
Label
<input>
</label>
<label>
Label
<span><input></span>
</label>
<app-label>
<span>
<app-input></app-input>
</span>
</app-label>
`,
options: [
{
controlComponents: ['app-input'],
labelComponents: [{ inputs: ['id'], selector: 'app-label' }],
},
],
},
],
invalid: [
convertAnnotatedSourceToFailureCase({
messageId,
description: 'should fail if a label does not have a "for" attribute',
annotatedSource: `
<label>Label</label>
~~~~~~~~~~~~~~~~~~~~
`,
}),
convertAnnotatedSourceToFailureCase({
messageId,
description:
'should fail if a label component does not have a label attribute',
annotatedSource: `
<app-label anotherAttribute="id"></app-label>
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
`,
options: [
{ labelComponents: [{ inputs: ['id'], selector: 'app-label' }] },
],
}),
],
});

0 comments on commit 0851f3e

Please sign in to comment.