Skip to content

Commit 76c24fa

Browse files
mohammedzamakhanmgechev
authored andcommitted
feat(rule): label accessibility - should have associated control (#739)
1 parent 799382f commit 76c24fa

File tree

4 files changed

+250
-0
lines changed

4 files changed

+250
-0
lines changed

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export { Rule as PreferOutputReadonlyRule } from './preferOutputReadonlyRule';
2929
export { Rule as TemplateConditionalComplexityRule } from './templateConditionalComplexityRule';
3030
export { Rule as TemplateCyclomaticComplexityRule } from './templateCyclomaticComplexityRule';
3131
export { Rule as TemplateAccessibilityTabindexNoPositiveRule } from './templateAccessibilityTabindexNoPositiveRule';
32+
export { Rule as TemplateAccessibilityLabelForVisitor } from './templateAccessibilityLabelForRule';
3233
export { Rule as TemplatesNoNegatedAsync } from './templatesNoNegatedAsyncRule';
3334
export { Rule as TemplateNoAutofocusRule } from './templateNoAutofocusRule';
3435
export { Rule as TrackByFunctionRule } from './trackByFunctionRule';
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { BoundDirectivePropertyAst, ElementAst } from '@angular/compiler';
2+
import { sprintf } from 'sprintf-js';
3+
import { IRuleMetadata, RuleFailure, Rules, Utils } from 'tslint/lib';
4+
import { SourceFile } from 'typescript/lib/typescript';
5+
import { NgWalker } from './angular/ngWalker';
6+
import { BasicTemplateAstVisitor } from './angular/templates/basicTemplateAstVisitor';
7+
import { mayContainChildComponent } from './util/mayContainChildComponent';
8+
9+
interface ILabelForOptions {
10+
labelComponents: string[];
11+
labelAttributes: string[];
12+
controlComponents: string[];
13+
}
14+
export class Rule extends Rules.AbstractRule {
15+
static readonly metadata: IRuleMetadata = {
16+
description: 'Checks if the label has associated for attribute or a form element',
17+
optionExamples: [[true, { labelComponents: ['app-label'], labelAttributes: ['id'], controlComponents: ['app-input', 'app-select'] }]],
18+
options: {
19+
items: {
20+
type: 'object',
21+
properties: {
22+
labelComponents: {
23+
type: 'array',
24+
items: {
25+
type: 'string'
26+
}
27+
},
28+
labelAttributes: {
29+
type: 'array',
30+
items: {
31+
type: 'string'
32+
}
33+
},
34+
controlComponents: {
35+
type: 'array',
36+
items: {
37+
type: 'string'
38+
}
39+
}
40+
}
41+
},
42+
type: 'array'
43+
},
44+
optionsDescription: 'Add custom label, label attribute and controls',
45+
rationale: Utils.dedent`
46+
The label tag should either have a for attribute or should have associated control.
47+
This rule supports two ways, either the label component should explicitly have a for attribute or a control nested inside the label component
48+
It also supports adding custom control component and custom label component support.`,
49+
ruleName: 'template-accessibility-label-for',
50+
type: 'functionality',
51+
typescriptOnly: true
52+
};
53+
54+
static readonly FAILURE_STRING = 'A form label must be associated with a control';
55+
static readonly FORM_ELEMENTS = ['input', 'select', 'textarea'];
56+
57+
apply(sourceFile: SourceFile): RuleFailure[] {
58+
return this.applyWithWalker(
59+
new NgWalker(sourceFile, this.getOptions(), {
60+
templateVisitorCtrl: TemplateAccessibilityLabelForVisitor
61+
})
62+
);
63+
}
64+
}
65+
66+
class TemplateAccessibilityLabelForVisitor extends BasicTemplateAstVisitor {
67+
visitElement(element: ElementAst, context: any) {
68+
this.validateElement(element);
69+
super.visitElement(element, context);
70+
}
71+
72+
private validateElement(element: ElementAst) {
73+
let { labelAttributes, labelComponents, controlComponents }: ILabelForOptions = this.getOptions() || {};
74+
controlComponents = Rule.FORM_ELEMENTS.concat(controlComponents || []);
75+
labelComponents = ['label'].concat(labelComponents || []);
76+
labelAttributes = ['for'].concat(labelAttributes || []);
77+
78+
if (labelComponents.indexOf(element.name) === -1) {
79+
return;
80+
}
81+
const hasForAttr = element.attrs.some(attr => labelAttributes.indexOf(attr.name) !== -1);
82+
const hasForInput = element.inputs.some(input => {
83+
return labelAttributes.indexOf(input.name) !== -1;
84+
});
85+
86+
const hasImplicitFormElement = controlComponents.some(component => mayContainChildComponent(element, component));
87+
88+
if (hasForAttr || hasForInput || hasImplicitFormElement) {
89+
return;
90+
}
91+
const {
92+
sourceSpan: {
93+
end: { offset: endOffset },
94+
start: { offset: startOffset }
95+
}
96+
} = element;
97+
98+
this.addFailureFromStartToEnd(startOffset, endOffset, Rule.FAILURE_STRING);
99+
}
100+
}

src/util/mayContainChildComponent.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { ElementAst } from '@angular/compiler';
2+
3+
export function mayContainChildComponent(root: ElementAst, componentName: string): boolean {
4+
function traverseChildren(node: ElementAst): boolean {
5+
if (!node.children) {
6+
return false;
7+
}
8+
if (node.children) {
9+
for (let i = 0; i < node.children.length; i += 1) {
10+
const childNode: ElementAst = <ElementAst>node.children[i];
11+
if (childNode.name === componentName) {
12+
return true;
13+
}
14+
if (traverseChildren(childNode)) {
15+
return true;
16+
}
17+
}
18+
}
19+
return false;
20+
}
21+
return traverseChildren(root);
22+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { Rule } from '../src/templateAccessibilityLabelForRule';
2+
import { assertAnnotated, assertSuccess } from './testHelper';
3+
4+
const {
5+
FAILURE_STRING,
6+
metadata: { ruleName }
7+
} = Rule;
8+
9+
describe(ruleName, () => {
10+
describe('failure', () => {
11+
it("should fail when label doesn't have for attribute", () => {
12+
const source = `
13+
@Component({
14+
template: \`
15+
<label>Label</label>
16+
~~~~~~~
17+
\`
18+
})
19+
class Bar {}
20+
`;
21+
assertAnnotated({
22+
message: FAILURE_STRING,
23+
ruleName,
24+
source
25+
});
26+
});
27+
28+
it("should fail when custom label doesn't have label attribute", () => {
29+
const source = `
30+
@Component({
31+
template: \`
32+
<app-label></app-label>
33+
~~~~~~~~~~~
34+
\`
35+
})
36+
class Bar {}
37+
`;
38+
assertAnnotated({
39+
message: FAILURE_STRING,
40+
ruleName,
41+
source,
42+
options: {
43+
labelComponents: ['app-label'],
44+
labelAttributes: ['id']
45+
}
46+
});
47+
});
48+
});
49+
50+
describe('success', () => {
51+
it('should work when label has for attribute', () => {
52+
const source = `
53+
@Component({
54+
template: \`
55+
<label for="id"></label>
56+
<label [attr.for]="id"></label>
57+
\`
58+
})
59+
class Bar {}
60+
`;
61+
assertSuccess(ruleName, source);
62+
});
63+
64+
it('should work when label are associated implicitly', () => {
65+
const source = `
66+
@Component({
67+
template: \`
68+
<label>
69+
Label
70+
<input />
71+
</label>
72+
73+
<label>
74+
Label
75+
<span><input /></span>
76+
</label>
77+
78+
<app-label>
79+
<span>
80+
<app-input></app-input>
81+
</span>
82+
</app-label>
83+
\`
84+
})
85+
class Bar {}
86+
`;
87+
assertSuccess(ruleName, source, {
88+
labelComponents: ['app-label'],
89+
controlComponents: ['app-input']
90+
});
91+
});
92+
93+
it("should fail when label doesn't have for attribute", () => {
94+
const source = `
95+
@Component({
96+
template: \`
97+
<label>
98+
<span>
99+
<span>
100+
<input>
101+
</span>
102+
</span>
103+
</label>
104+
\`
105+
})
106+
class Bar {}
107+
`;
108+
assertSuccess(ruleName, source);
109+
});
110+
111+
it('should work when custom label has label attribute', () => {
112+
const source = `
113+
@Component({
114+
template: \`
115+
<app-label id="name"></app-label>
116+
<app-label [id]="name"></app-label>
117+
\`
118+
})
119+
class Bar {}
120+
`;
121+
assertSuccess(ruleName, source, {
122+
labelComponents: ['app-label'],
123+
labelAttributes: ['id']
124+
});
125+
});
126+
});
127+
});

0 commit comments

Comments
 (0)