-
-
Notifications
You must be signed in to change notification settings - Fork 207
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(eslint-plugin-template): add rule accessibility-label-has-associ…
…ated-control (#392)
- Loading branch information
1 parent
1539564
commit 0851f3e
Showing
5 changed files
with
232 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
126 changes: 126 additions & 0 deletions
126
packages/eslint-plugin-template/src/rules/accessibility-label-has-associated-control.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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('|')})$`); | ||
} |
99 changes: 99 additions & 0 deletions
99
...ges/eslint-plugin-template/tests/rules/accessibility-label-has-associated-control.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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' }] }, | ||
], | ||
}), | ||
], | ||
}); |