-
-
Notifications
You must be signed in to change notification settings - Fork 208
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): [accessibility-role-has-required-aria] …
…add rule (#1100)
- Loading branch information
1 parent
b01b413
commit f684df0
Showing
7 changed files
with
407 additions
and
0 deletions.
There are no files selected for viewing
203 changes: 203 additions & 0 deletions
203
packages/eslint-plugin-template/docs/rules/accessibility-role-has-required-aria.md
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,203 @@ | ||
<!-- | ||
DO NOT EDIT. | ||
This markdown file was autogenerated using a mixture of the following files as the source of truth for its data: | ||
- ../../src/rules/accessibility-role-has-required-aria.ts | ||
- ../../tests/rules/accessibility-role-has-required-aria/cases.ts | ||
In order to update this file, it is therefore those files which need to be updated, as well as potentially the generator script: | ||
- ../../../../tools/scripts/generate-rule-docs.ts | ||
--> | ||
|
||
<br> | ||
|
||
# `@angular-eslint/template/accessibility-role-has-required-aria` | ||
|
||
Ensures elements with ARIA roles have all required properties for that role. | ||
|
||
- Type: suggestion | ||
|
||
- 💡 Provides suggestions on how to fix issues (https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions) | ||
|
||
<br> | ||
|
||
## Rule Options | ||
|
||
The rule does not have any configuration options. | ||
|
||
<br> | ||
|
||
## Usage Examples | ||
|
||
> The following examples are generated automatically from the actual unit tests within the plugin, so you can be assured that their behavior is accurate based on the current commit. | ||
<br> | ||
|
||
<details> | ||
<summary>❌ - Toggle examples of <strong>incorrect</strong> code for this rule</summary> | ||
|
||
<br> | ||
|
||
#### Default Config | ||
|
||
```json | ||
{ | ||
"rules": { | ||
"@angular-eslint/template/accessibility-role-has-required-aria": [ | ||
"error" | ||
] | ||
} | ||
} | ||
``` | ||
|
||
<br> | ||
|
||
#### ❌ Invalid Code | ||
|
||
```html | ||
<div role="combobox"></div> | ||
~~~~~~~~~~~~~~~ | ||
``` | ||
|
||
</details> | ||
|
||
<br> | ||
|
||
--- | ||
|
||
<br> | ||
|
||
<details> | ||
<summary>✅ - Toggle examples of <strong>correct</strong> code for this rule</summary> | ||
|
||
<br> | ||
|
||
#### Default Config | ||
|
||
```json | ||
{ | ||
"rules": { | ||
"@angular-eslint/template/accessibility-role-has-required-aria": [ | ||
"error" | ||
] | ||
} | ||
} | ||
``` | ||
|
||
<br> | ||
|
||
#### ✅ Valid Code | ||
|
||
```html | ||
<span role="checkbox" aria-checked="false"></span> | ||
``` | ||
|
||
<br> | ||
|
||
--- | ||
|
||
<br> | ||
|
||
#### Default Config | ||
|
||
```json | ||
{ | ||
"rules": { | ||
"@angular-eslint/template/accessibility-role-has-required-aria": [ | ||
"error" | ||
] | ||
} | ||
} | ||
``` | ||
|
||
<br> | ||
|
||
#### ✅ Valid Code | ||
|
||
```html | ||
<input type="checkbox" role="switch"> | ||
``` | ||
|
||
<br> | ||
|
||
--- | ||
|
||
<br> | ||
|
||
#### Default Config | ||
|
||
```json | ||
{ | ||
"rules": { | ||
"@angular-eslint/template/accessibility-role-has-required-aria": [ | ||
"error" | ||
] | ||
} | ||
} | ||
``` | ||
|
||
<br> | ||
|
||
#### ✅ Valid Code | ||
|
||
```html | ||
<span role="heading" aria-level="5"></span> | ||
``` | ||
|
||
<br> | ||
|
||
--- | ||
|
||
<br> | ||
|
||
#### Default Config | ||
|
||
```json | ||
{ | ||
"rules": { | ||
"@angular-eslint/template/accessibility-role-has-required-aria": [ | ||
"error" | ||
] | ||
} | ||
} | ||
``` | ||
|
||
<br> | ||
|
||
#### ✅ Valid Code | ||
|
||
```html | ||
<span role="button"></span> | ||
``` | ||
|
||
<br> | ||
|
||
--- | ||
|
||
<br> | ||
|
||
#### Default Config | ||
|
||
```json | ||
{ | ||
"rules": { | ||
"@angular-eslint/template/accessibility-role-has-required-aria": [ | ||
"error" | ||
] | ||
} | ||
} | ||
``` | ||
|
||
<br> | ||
|
||
#### ✅ Valid Code | ||
|
||
```html | ||
<app-component [role]="ADMIN"></app-component> | ||
``` | ||
|
||
</details> | ||
|
||
<br> |
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
95 changes: 95 additions & 0 deletions
95
packages/eslint-plugin-template/src/rules/accessibility-role-has-required-aria.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,95 @@ | ||
import type { | ||
TmplAstTextAttribute, | ||
TmplAstElement, | ||
} from '@angular-eslint/bundled-angular-compiler'; | ||
import type { ARIARoleDefintionKey } from 'aria-query'; | ||
import { roles } from 'aria-query'; | ||
import { | ||
createESLintRule, | ||
getTemplateParserServices, | ||
} from '../utils/create-eslint-rule'; | ||
import { getDomElements } from '../utils/get-dom-elements'; | ||
import { toPattern } from '../utils/to-pattern'; | ||
import { isSemanticRoleElement } from '../utils/is-semantic-role-element'; | ||
|
||
type Options = []; | ||
export type MessageIds = 'roleHasRequiredAria' | 'suggestRemoveRole'; | ||
export const RULE_NAME = 'accessibility-role-has-required-aria'; | ||
|
||
export default createESLintRule<Options, MessageIds>({ | ||
name: RULE_NAME, | ||
meta: { | ||
type: 'suggestion', | ||
docs: { | ||
description: | ||
'Ensures elements with ARIA roles have all required properties for that role.', | ||
recommended: false, | ||
}, | ||
hasSuggestions: true, | ||
schema: [], | ||
messages: { | ||
roleHasRequiredAria: | ||
'The {{element}} with role="{{role}}" does not have required ARIA properties: {{missingProps}}', | ||
suggestRemoveRole: 'Remove role `{{role}}`', | ||
}, | ||
}, | ||
defaultOptions: [], | ||
create(context) { | ||
const parserServices = getTemplateParserServices(context); | ||
const elementNamePattern = toPattern([...getDomElements()]); | ||
|
||
return { | ||
[`Element$1[name=${elementNamePattern}] > TextAttribute[name='role']`]( | ||
node: TmplAstTextAttribute & { | ||
parent: TmplAstElement; | ||
}, | ||
) { | ||
const { value: role, sourceSpan } = node; | ||
const { attributes, inputs, name: element } = node.parent; | ||
const props = [...attributes, ...inputs]; | ||
|
||
const roleDef = roles.get(role as ARIARoleDefintionKey); | ||
|
||
const requiredProps = Object.keys(roleDef?.requiredProps || {}); | ||
if (!requiredProps.length) return; | ||
|
||
if (isSemanticRoleElement(element, role, props)) return; | ||
|
||
const missingProps = requiredProps | ||
.filter( | ||
(requiredProp) => !props.find((prop) => prop.name === requiredProp), | ||
) | ||
.join(', '); | ||
|
||
if (missingProps) { | ||
const loc = parserServices.convertNodeSourceSpanToLoc(sourceSpan); | ||
|
||
context.report({ | ||
loc, | ||
messageId: 'roleHasRequiredAria', | ||
data: { | ||
element, | ||
role, | ||
missingProps, | ||
}, | ||
suggest: [ | ||
{ | ||
messageId: 'suggestRemoveRole', | ||
data: { | ||
element, | ||
role, | ||
missingProps, | ||
}, | ||
fix: (fixer) => | ||
fixer.removeRange([ | ||
sourceSpan?.start.offset - 1, | ||
sourceSpan?.end.offset, | ||
]), | ||
}, | ||
], | ||
}); | ||
} | ||
}, | ||
}; | ||
}, | ||
}); |
51 changes: 51 additions & 0 deletions
51
packages/eslint-plugin-template/src/utils/is-semantic-role-element.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,51 @@ | ||
import type { | ||
TmplAstBoundAttribute, | ||
TmplAstTextAttribute, | ||
} from '@angular-eslint/bundled-angular-compiler'; | ||
|
||
// The axobject-query package doesn't have type definitions, but this is what we're using from it here | ||
let axElements: Map< | ||
{ name: string; attributes?: { name: string; value: string }[] }, | ||
string[] | ||
> | null = null; | ||
let axRoles: Map<string, { name: string }[]> | null = null; | ||
|
||
// This function follows the lazy initialization pattern. | ||
// Since this is a top-level module (it will be included via `require`), we do not need to | ||
// initialize the `nonInteractiveElementRoleSchemas` until the function is called | ||
// for the first time, so we will not take up the memory. | ||
export function isSemanticRoleElement( | ||
element: string, | ||
role: string, | ||
elementAttributes: (TmplAstTextAttribute | TmplAstBoundAttribute)[], | ||
): boolean { | ||
if (axElements === null || axRoles === null) { | ||
// eslint-disable-next-line @typescript-eslint/no-var-requires | ||
const { AXObjectRoles, elementAXObjects } = require('axobject-query'); | ||
axElements = elementAXObjects; | ||
axRoles = AXObjectRoles; | ||
} | ||
|
||
// elementAXObjects: HTML elements are mapped to their related AXConcepts concepts | ||
return Array.from(axElements?.keys() ?? []).some( | ||
(htmlElement) => | ||
htmlElement.name === element && | ||
(htmlElement.attributes ?? []).every((htmlElemAttr) => | ||
// match every axElement html attributes to given elementAttributes | ||
elementAttributes.find( | ||
(elemAttr) => | ||
htmlElemAttr.name === elemAttr.name && | ||
htmlElemAttr.value === elemAttr.value, | ||
), | ||
) && | ||
// aria- properties are covered by the element's semantic role | ||
axElements?.get(htmlElement)?.find((roleName: string) => | ||
// AXObjectRoles: AXObjects are mapped to their related ARIA concepts | ||
axRoles | ||
?.get(roleName) | ||
?.find( | ||
(semanticRole: { name: string }) => semanticRole.name === role, | ||
), | ||
), | ||
); | ||
} |
Oops, something went wrong.