Skip to content

Commit

Permalink
feat(eslint-plugin-template): [accessibility-role-has-required-aria] …
Browse files Browse the repository at this point in the history
…add rule (#1100)
  • Loading branch information
sandikbarr committed Sep 26, 2022
1 parent b01b413 commit f684df0
Show file tree
Hide file tree
Showing 7 changed files with 407 additions and 0 deletions.
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>
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 @@ -5,6 +5,7 @@
"@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-role-has-required-aria": "error",
"@angular-eslint/template/accessibility-table-scope": "error",
"@angular-eslint/template/accessibility-valid-aria": "error",
"@angular-eslint/template/banana-in-box": "error",
Expand Down
5 changes: 5 additions & 0 deletions packages/eslint-plugin-template/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import accessibilityLabelFor, {
import accessibilityLabelHasAssociatedControl, {
RULE_NAME as accessibilityLabelHasAssociatedControlRuleName,
} from './rules/accessibility-label-has-associated-control';
import accessibilityRoleHasRequiredAria, {
RULE_NAME as accessibilityRoleHasRequiredAriaRuleName,
} from './rules/accessibility-role-has-required-aria';
import accessibilityTableScope, {
RULE_NAME as accessibilityTableScopeRuleName,
} from './rules/accessibility-table-scope';
Expand Down Expand Up @@ -78,6 +81,8 @@ export default {
[accessibilityLabelForRuleName]: accessibilityLabelFor,
[accessibilityLabelHasAssociatedControlRuleName]:
accessibilityLabelHasAssociatedControl,
[accessibilityRoleHasRequiredAriaRuleName]:
accessibilityRoleHasRequiredAria,
[accessibilityTableScopeRuleName]: accessibilityTableScope,
[accessibilityValidAriaRuleName]: accessibilityValidAria,
[bananaInBoxRuleName]: bananaInBox,
Expand Down
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,
]),
},
],
});
}
},
};
},
});
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,
),
),
);
}

0 comments on commit f684df0

Please sign in to comment.