Skip to content

Commit

Permalink
fix(eslint-plugin-template): accessibility-valid-aria not reporting i… (
Browse files Browse the repository at this point in the history
  • Loading branch information
rafaelss95 committed Jan 16, 2021
1 parent 1199a7c commit 391980f
Show file tree
Hide file tree
Showing 3 changed files with 181 additions and 25 deletions.
137 changes: 123 additions & 14 deletions packages/eslint-plugin-template/src/rules/accessibility-valid-aria.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { aria } from 'aria-query';
import { TmplAstBoundAttribute, TmplAstTextAttribute } from '@angular/compiler';
import { aria, ARIAProperty, ARIAPropertyDefinition } from 'aria-query';
import {
AST,
ASTWithSource,
LiteralArray,
LiteralMap,
LiteralPrimitive,
TmplAstBoundAttribute,
TmplAstTextAttribute,
} from '@angular/compiler';

import {
createESLintRule,
getTemplateParserServices,
} from '../utils/create-eslint-rule';

type Options = [];
export type MessageIds = 'accessibilityValidAria';
export type MessageIds =
| 'accessibilityValidAria'
| 'accessibilityValidAriaValue';
export const RULE_NAME = 'accessibility-valid-aria';
const ARIA_PATTERN = /^aria-.*/;

Expand All @@ -16,40 +26,139 @@ export default createESLintRule<Options, MessageIds>({
meta: {
type: 'suggestion',
docs: {
description: 'Ensures that correct ARIA attributes are used',
description:
'Ensures that correct ARIA attributes and respective values are used',
category: 'Best Practices',
recommended: false,
},
schema: [],
messages: {
accessibilityValidAria:
'The `{{attribute}}` is an invalid ARIA attribute',
accessibilityValidAriaValue:
'The `{{attribute}}` has an invalid value. Check the valid values at https://raw.githack.com/w3c/aria/stable/#roles',
},
},
defaultOptions: [],
create(context) {
const parserServices = getTemplateParserServices(context);

return {
[`BoundAttribute[name=${ARIA_PATTERN}], TextAttribute[name=${ARIA_PATTERN}]`]({
name: attribute,
sourceSpan,
}: TmplAstBoundAttribute | TmplAstTextAttribute) {
if (getAriaAttributes().has(attribute)) return;

[`BoundAttribute[name=${ARIA_PATTERN}], TextAttribute[name=${ARIA_PATTERN}]`](
astAttribute: TmplAstBoundAttribute | TmplAstTextAttribute,
) {
const { name: attribute, sourceSpan } = astAttribute;
const ariaPropertyDefinition = aria.get(attribute as ARIAProperty) as
| ARIAPropertyDefinition
| undefined;
const loc = parserServices.convertNodeSourceSpanToLoc(sourceSpan);

if (!ariaPropertyDefinition) {
context.report({
loc,
messageId: 'accessibilityValidAria',
data: { attribute },
});

return;
}

const ast = extractASTFrom(astAttribute);

if (
canIgnoreNode(ast) ||
isValidAriaPropertyValue(
ariaPropertyDefinition,
(ast as LiteralPrimitive | TmplAstTextAttribute).value,
)
) {
return;
}

context.report({
loc,
messageId: 'accessibilityValidAria',
messageId: 'accessibilityValidAriaValue',
data: { attribute },
});
},
};
},
});

let ariaAttributes: ReadonlySet<string> | null = null;
function getAriaAttributes(): ReadonlySet<string> {
return ariaAttributes || (ariaAttributes = new Set<string>(aria.keys()));
function isLiteralCollection(ast: unknown): ast is LiteralArray | LiteralMap {
return ast instanceof LiteralArray || ast instanceof LiteralMap;
}

function isPrimitive(
ast: unknown,
): ast is LiteralPrimitive | TmplAstTextAttribute {
return ast instanceof LiteralPrimitive || ast instanceof TmplAstTextAttribute;
}

function canIgnoreNode(ast: unknown): boolean {
return !isLiteralCollection(ast) && !isPrimitive(ast);
}

function extractASTFrom(
attribute: TmplAstBoundAttribute | TmplAstTextAttribute,
): AST | TmplAstTextAttribute {
return attribute instanceof TmplAstBoundAttribute
? (attribute.value as ASTWithSource).ast
: attribute;
}

function isBooleanLike(value: unknown): value is boolean | 'false' | 'true' {
return typeof value === 'boolean' || value === 'false' || value === 'true';
}

function isInteger(value: unknown): boolean {
return (
!Number.isNaN(value) &&
parseInt((Number(value) as unknown) as string) == value &&
!Number.isNaN(parseInt(value as string, 10))
);
}

function isNumeric(value: unknown): boolean {
return (
!Number.isNaN(Number.parseFloat(value as string)) &&
Number.isFinite(value as number)
);
}

function isNil(value: unknown): value is null | undefined {
return value == null;
}

function isString(value: unknown): value is string {
return typeof value == 'string';
}

function isValidAriaPropertyValue(
{ allowundefined, type, values }: ARIAPropertyDefinition,
attributeValue: boolean | number | string,
): boolean {
if (allowundefined && isNil(attributeValue)) return true;

switch (type) {
case 'boolean':
return isBooleanLike(attributeValue);
case 'tristate':
return isBooleanLike(attributeValue) || isNil(attributeValue);
case 'id':
case 'idlist':
return true;
case 'integer':
return isInteger(attributeValue);
case 'number':
return isNumeric(attributeValue);
case 'string':
return isString(attributeValue);
case 'token':
case 'tokenlist':
const parsedAttributeValue = isBooleanLike(attributeValue)
? JSON.parse((attributeValue as unknown) as string)
: attributeValue;
return Boolean(values?.includes(parsedAttributeValue));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,33 @@ import rule, {
const ruleTester = new RuleTester({
parser: '@angular-eslint/template-parser',
});
const messageId: MessageIds = 'accessibilityValidAria';
const accessibilityValidAria: MessageIds = 'accessibilityValidAria';
const accessibilityValidAriaValue: MessageIds = 'accessibilityValidAriaValue';

ruleTester.run(RULE_NAME, rule, {
valid: [
'<input aria-labelledby="Text">',
'<div ariaselected="0"></div>',
'<textarea [attr.aria-readonly]="readonly"></textarea>',
'<button [variant]="variant">Text</button>',
`
<input aria-labelledby="Text">
<div ariaselected="0"></div>
<textarea [attr.aria-readonly]="readonly"></textarea>
<button [variant]="variant">Text</button>
<div aria-expanded="true">aria-expanded</div>
<div aria-haspopup="menu">aria-haspopup</div>
<div [attr.aria-pressed]="undefined">aria-pressed</div>
<input [attr.aria-rowcount]="2">
<div aria-relevant="additions">additions</div>
<div aria-checked="false">checked</div>
<div role="slider" [aria-valuemin]="1"></div>
<input
aria-placeholder="Placeholder"
aria-orientation="undefined"
[attr.aria-checked]="test && isChecked"
[attr.aria-hidden]="'abc' | appAria"
[attr.aria-invalid]="hasError ? 'grammar' : 'spelling'"
[attr.aria-label]="inputSchema!.label"
[attr.aria-live]="inputSchema['live']"
[attr.aria-required]="inputSchema?.isRequired">
`,
],
invalid: [
convertAnnotatedSourceToFailureCase({
Expand All @@ -35,9 +54,37 @@ ruleTester.run(RULE_NAME, rule, {
#################################
`,
messages: [
{ char: '~', messageId },
{ char: '^', messageId },
{ char: '#', messageId },
{ char: '~', messageId: accessibilityValidAria },
{ char: '^', messageId: accessibilityValidAria },
{ char: '#', messageId: accessibilityValidAria },
],
}),
convertAnnotatedSourceToFailureCase({
description: 'should fail if the ARIA attribute has an invalid value',
annotatedSource: `
<div aria-expanded="notABoolean">notABoolean</div>
~~~~~~~~~~~~~~~~~~~~~~~~~~~
<div aria-haspopup="notAToken">notAToken</div>
^^^^^^^^^^^^^^^^^^^^^^^^^
<input [attr.aria-rowcount]="{ a: 2 }">notAnInteger
###############################
<div aria-relevant="notATokenList">notATokenList</div>
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
<div aria-checked="notATristate">notATristate</div>
¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶
<div role="slider" [attr.aria-valuemin]="[1, 2]">notANumber</div>
¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨¨
<input [attr.aria-placeholder]="4">notAPlaceholder
@@@@@@@@@@@@@@@@@@@@@@@@@@@
`,
messages: [
{ char: '~', messageId: accessibilityValidAriaValue },
{ char: '^', messageId: accessibilityValidAriaValue },
{ char: '#', messageId: accessibilityValidAriaValue },
{ char: '%', messageId: accessibilityValidAriaValue },
{ char: '¶', messageId: accessibilityValidAriaValue },
{ char: '¨', messageId: accessibilityValidAriaValue },
{ char: '@', messageId: accessibilityValidAriaValue },
],
}),
],
Expand Down
6 changes: 3 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3132,9 +3132,9 @@
"@sinonjs/commons" "^1.7.0"

"@types/aria-query@^4.2.0":
version "4.2.0"
resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.0.tgz#14264692a9d6e2fa4db3df5e56e94b5e25647ac0"
integrity sha512-iIgQNzCm0v7QMhhe4Jjn9uRh+I6GoPmt03CbEtwx3ao8/EfoQcmgtqH4vQ5Db/lxiIGaWDv6nwvunuh0RyX0+A==
version "4.2.1"
resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.1.tgz#78b5433344e2f92e8b306c06a5622c50c245bf6b"
integrity sha512-S6oPal772qJZHoRZLFc/XoZW2gFvwXusYUmXPXkgxJLuEk2vOt7jc4Yo6z/vtI0EBkbPBVrJJ0B+prLIKiWqHg==

"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7":
version "7.1.12"
Expand Down

0 comments on commit 391980f

Please sign in to comment.