Skip to content

Commit

Permalink
feat(eslint-plugin-template): accessibility-label-for (#268)
Browse files Browse the repository at this point in the history
  • Loading branch information
rafaelss95 committed Jan 11, 2021
1 parent 5010b3f commit 49ab76a
Show file tree
Hide file tree
Showing 6 changed files with 248 additions and 1 deletion.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ If you are still having problems after you have done some digging into these, fe
| [`prefer-on-push-component-change-detection`] | :white_check_mark: |
| [`template-accessibility-alt-text`] | :white_check_mark: |
| [`template-accessibility-elements-content`] | :white_check_mark: |
| [`template-accessibility-label-for`] | |
| [`template-accessibility-label-for`] | :white_check_mark: |
| [`template-accessibility-tabindex-no-positive`] | :white_check_mark: |
| [`template-accessibility-table-scope`] | :white_check_mark: |
| [`template-accessibility-valid-aria`] | :white_check_mark: |
Expand Down
1 change: 1 addition & 0 deletions packages/eslint-plugin-template/src/configs/all.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"rules": {
"@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-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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import accessibilityAltText, {
import accessibilityElementsContent, {
RULE_NAME as accessibilityElementsContentRuleName,
} from './rules/accessibility-elements-content';
import accessibilityLabelFor, {
RULE_NAME as accessibilityLabelForRuleName,
} from './rules/accessibility-label-for';
import accessibilityTableScope, {
RULE_NAME as accessibilityTableScopeRuleName,
} from './rules/accessibility-table-scope';
Expand Down Expand Up @@ -62,6 +65,7 @@ export default {
rules: {
[accessibilityAltTextRuleName]: accessibilityAltText,
[accessibilityElementsContentRuleName]: accessibilityElementsContent,
[accessibilityLabelForRuleName]: accessibilityLabelFor,
[accessibilityTableScopeRuleName]: accessibilityTableScope,
[accessibilityValidAriaRuleName]: accessibilityValidAria,
[bananaInBoxRuleName]: bananaInBox,
Expand Down
137 changes: 137 additions & 0 deletions packages/eslint-plugin-template/src/rules/accessibility-label-for.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { TmplAstElement } from '@angular/compiler';
import {
createESLintRule,
getTemplateParserServices,
} from '../utils/create-eslint-rule';
import { isChildNodeOf } from '../utils/is-child-node-of';

type Options = [
{
readonly controlComponents?: readonly string[];
readonly labelAttributes?: readonly string[];
readonly labelComponents?: readonly string[];
},
];
export type MessageIds = 'accessibilityLabelFor';
export const RULE_NAME = 'accessibility-label-for';
const OPTION_SCHEMA_VALUE = {
items: { type: 'string' },
type: 'array',
uniqueItems: true,
} as const;
const DEFAULT_ELEMENTS = [
'button',
'input',
'meter',
'output',
'progress',
'select',
'textarea',
] as const;
const DEFAULT_LABEL_ATTRIBUTES = ['for', 'htmlFor'] as const;
const DEFAULT_LABEL_COMPONENTS = ['label'] as const;

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: OPTION_SCHEMA_VALUE,
labelAttributes: OPTION_SCHEMA_VALUE,
labelComponents: OPTION_SCHEMA_VALUE,
},
type: 'object',
},
],
messages: {
accessibilityLabelFor:
'A label element/component must be associated with a form element',
},
},
defaultOptions: [
{
controlComponents: DEFAULT_ELEMENTS,
labelAttributes: DEFAULT_LABEL_ATTRIBUTES,
labelComponents: DEFAULT_LABEL_COMPONENTS,
},
],
create(context, [options]) {
const parserServices = getTemplateParserServices(context);
const {
controlComponents,
labelAttributes,
labelComponents,
} = getParsedOptions(options);
const labelComponentsPattern = toPattern([...labelComponents]);

return {
[`Element[name=${labelComponentsPattern}]`](node: TmplAstElement) {
const attributesInputs: ReadonlySet<string> = new Set(
[...node.attributes, ...node.inputs].map(({ name }) => name),
);
const hasLabelAttribute = [...labelAttributes].some((labelAttribute) =>
attributesInputs.has(labelAttribute),
);

if (
hasLabelAttribute ||
hasControlComponentIn(controlComponents, node)
) {
return;
}

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

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

function getParsedOptions({
controlComponents,
labelAttributes,
labelComponents,
}: Options[0]) {
return {
controlComponents: new Set([
...DEFAULT_ELEMENTS,
...(controlComponents ?? []),
]) as ReadonlySet<string>,
labelAttributes: new Set([
...DEFAULT_LABEL_ATTRIBUTES,
...(labelAttributes ?? []),
]) as ReadonlySet<string>,
labelComponents: new Set([
...DEFAULT_LABEL_COMPONENTS,
...(labelComponents ?? []),
]) as ReadonlySet<string>,
} as const;
}

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('|')})$`);
}
16 changes: 16 additions & 0 deletions packages/eslint-plugin-template/src/utils/is-child-node-of.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { TmplAstElement } from '@angular/compiler';

export function isChildNodeOf(
ast: TmplAstElement,
childNodeName: string,
): boolean {
function traverseChildNodes({ children }: TmplAstElement): boolean {
return children.some(
(child) =>
child instanceof TmplAstElement &&
(child.name === childNodeName || traverseChildNodes(child)),
);
}

return traverseChildNodes(ast);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {
convertAnnotatedSourceToFailureCase,
RuleTester,
} from '@angular-eslint/utils';
import rule, {
MessageIds,
RULE_NAME,
} from '../../src/rules/accessibility-label-for';

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

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

const messageId: MessageIds = 'accessibilityLabelFor';

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>
`,
options: [{ labelAttributes: ['id'], labelComponents: ['app-label'] }],
},
{
code: `
<label><button>Button</button></label>
<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: ['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: [{ labelAttributes: ['id'], labelComponents: ['app-label'] }],
}),
],
});

0 comments on commit 49ab76a

Please sign in to comment.