Skip to content

Commit

Permalink
feat(eslint-plugin-template): [accessibility-interactive-supports-foc…
Browse files Browse the repository at this point in the history
…us] add rule (#1134)
  • Loading branch information
sandikbarr committed Sep 26, 2022
1 parent 85c2452 commit d99d8c1
Show file tree
Hide file tree
Showing 10 changed files with 1,359 additions and 19 deletions.

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/eslint-plugin-template/src/configs/all.json
Expand Up @@ -3,6 +3,7 @@
"rules": {
"@angular-eslint/template/accessibility-alt-text": "error",
"@angular-eslint/template/accessibility-elements-content": "error",
"@angular-eslint/template/accessibility-interactive-supports-focus": "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",
Expand Down
5 changes: 5 additions & 0 deletions packages/eslint-plugin-template/src/index.ts
Expand Up @@ -9,6 +9,9 @@ import accessibilityAltText, {
import accessibilityElementsContent, {
RULE_NAME as accessibilityElementsContentRuleName,
} from './rules/accessibility-elements-content';
import accessibilityInteractiveSupportsFocus, {
RULE_NAME as accessibilityInteractiveSupportsFocusRuleName,
} from './rules/accessibility-interactive-supports-focus';
import accessibilityLabelFor, {
RULE_NAME as accessibilityLabelForRuleName,
} from './rules/accessibility-label-for';
Expand Down Expand Up @@ -78,6 +81,8 @@ export default {
rules: {
[accessibilityAltTextRuleName]: accessibilityAltText,
[accessibilityElementsContentRuleName]: accessibilityElementsContent,
[accessibilityInteractiveSupportsFocusRuleName]:
accessibilityInteractiveSupportsFocus,
[accessibilityLabelForRuleName]: accessibilityLabelFor,
[accessibilityLabelHasAssociatedControlRuleName]:
accessibilityLabelHasAssociatedControl,
Expand Down
@@ -0,0 +1,88 @@
import type { TmplAstElement } from '@angular-eslint/bundled-angular-compiler';
import {
createESLintRule,
getTemplateParserServices,
} from '../utils/create-eslint-rule';
import { getDomElements } from '../utils/get-dom-elements';
import { isHiddenFromScreenReader } from '../utils/is-hidden-from-screen-reader';
import {
isInteractiveElement,
isNonInteractiveRole,
} from '../utils/is-interactive-element';
import { isContentEditable } from '../utils/is-content-editable';
import { isDisabledElement } from '../utils/is-disabled-element';
import { isPresentationRole } from '../utils/is-presentation-role';

type Options = [];
export type MessageIds = 'interactiveSupportsFocus';
export const RULE_NAME = 'accessibility-interactive-supports-focus';

export default createESLintRule<Options, MessageIds>({
name: RULE_NAME,
meta: {
type: 'suggestion',
docs: {
description:
'Ensures that elements with interactive handlers like `(click)` are focusable.',
recommended: false,
},
schema: [],
messages: {
interactiveSupportsFocus:
'Elements with interaction handlers must be focusable.',
},
},
defaultOptions: [],
create(context) {
return {
Element$1(node: TmplAstElement) {
const elementType = node.name;
if (!getDomElements().has(elementType)) {
return;
}

const interactiveOutput = node.outputs.find(
(output: { name: string }) =>
output.name === 'click' ||
output.name.startsWith('keyup') ||
output.name.startsWith('keydown') ||
output.name.startsWith('keypress'),
);

if (
!interactiveOutput ||
isDisabledElement(node) ||
isHiddenFromScreenReader(node) ||
isPresentationRole(node)
) {
// Presentation is an intentional signal from the author
// that this element is not meant to be perceivable.
// For example, a click screen overlay to close a dialog.
return;
}

const tabIndex = [...node.attributes, ...node.inputs].find(
(attr) => attr.name === 'tabindex',
);

if (
interactiveOutput &&
!tabIndex &&
!isInteractiveElement(node) &&
!isNonInteractiveRole(node) &&
!isContentEditable(node)
) {
const parserServices = getTemplateParserServices(context);
const loc = parserServices.convertNodeSourceSpanToLoc(
node.sourceSpan,
);
const messageId: MessageIds = 'interactiveSupportsFocus';
context.report({
loc,
messageId,
});
}
},
};
},
});
16 changes: 16 additions & 0 deletions packages/eslint-plugin-template/src/utils/is-content-editable.ts
@@ -0,0 +1,16 @@
import type { TmplAstElement } from '@angular-eslint/bundled-angular-compiler';
import { getOriginalAttributeName } from './get-original-attribute-name';
import { getAttributeValue } from './get-attribute-value';

export function isContentEditable(node: TmplAstElement): boolean {
const attributesInputs = [...node.attributes, ...node.inputs];
const contentEditableAttr = attributesInputs.find(
(attr) => getOriginalAttributeName(attr) === 'contenteditable',
);
const contentEditableValue = getAttributeValue(node, 'contenteditable');
return (
!!contentEditableAttr &&
(contentEditableValue === '' ||
String(contentEditableValue).toLowerCase() === 'true')
);
}
23 changes: 23 additions & 0 deletions packages/eslint-plugin-template/src/utils/is-disabled-element.ts
@@ -0,0 +1,23 @@
import type { TmplAstElement } from '@angular-eslint/bundled-angular-compiler';
import { getOriginalAttributeName } from './get-original-attribute-name';
import { getAttributeValue } from './get-attribute-value';

export function isDisabledElement(node: TmplAstElement): boolean {
const attributesInputs = [...node.attributes, ...node.inputs];
const disabledAttr = attributesInputs.find(
(attr) => getOriginalAttributeName(attr) === 'disabled',
);
const disabledValue = getAttributeValue(node, 'disabled');
const isHTML5Disabled = disabledAttr && disabledValue !== undefined;
if (isHTML5Disabled) {
return true;
}

const isAriaDisabled =
String(getAttributeValue(node, 'aria-disabled')).toLowerCase() === 'true';
if (isAriaDisabled) {
return true;
}

return false;
}
Expand Up @@ -2,17 +2,35 @@ import type { ARIARoleDefintionKey, ARIARoleRelationConcept } from 'aria-query';
import { elementRoles, roles } from 'aria-query';

let nonInteractiveElementRoleSchemas: ARIARoleRelationConcept[] | null = null;
let nonInteractiveRoles: Set<ARIARoleDefintionKey> | 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.
// These functions follow 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` or
// `nonInteractiveRoles` until the functions are called for the first time,
// so we will not take up the memory.
export function getNonInteractiveElementRoleSchemas(): ARIARoleRelationConcept[] {
if (nonInteractiveElementRoleSchemas === null) {
const roleKeys = [...roles.keys()];
const elementRoleEntries = [...elementRoles.entries()];

const nonInteractiveRoles = new Set<ARIARoleDefintionKey>(
nonInteractiveElementRoleSchemas = elementRoleEntries.reduce<
ARIARoleRelationConcept[]
>((accumulator, [elementSchema, roleSet]) => {
return accumulator.concat(
[...roleSet].every((role) => getNonInteractiveRoles().has(role))
? elementSchema
: [],
);
}, []);
}

return nonInteractiveElementRoleSchemas;
}

export function getNonInteractiveRoles(): Set<ARIARoleDefintionKey> {
if (nonInteractiveRoles === null) {
const roleKeys = [...roles.keys()];
nonInteractiveRoles = new Set<ARIARoleDefintionKey>(
roleKeys
.filter((name) => {
const role = roles.get(name);
Expand All @@ -31,17 +49,7 @@ export function getNonInteractiveElementRoleSchemas(): ARIARoleRelationConcept[]
'progressbar',
),
);

nonInteractiveElementRoleSchemas = elementRoleEntries.reduce<
ARIARoleRelationConcept[]
>((accumulator, [elementSchema, roleSet]) => {
return accumulator.concat(
[...roleSet].every((role) => nonInteractiveRoles.has(role))
? elementSchema
: [],
);
}, []);
}

return nonInteractiveElementRoleSchemas;
return nonInteractiveRoles;
}
@@ -1,10 +1,14 @@
import type { TmplAstElement } from '@angular-eslint/bundled-angular-compiler';
import type { ARIARoleRelationConcept } from 'aria-query';
import type { ARIARole, ARIARoleRelationConcept } from 'aria-query';
import { attributesComparator } from '../attributes-comparator';
import { getAttributeValue } from '../get-attribute-value';
import { getDomElements } from '../get-dom-elements';
import { getInteractiveElementAXObjectSchemas } from './get-interactive-element-ax-object-schemas';
import { getInteractiveElementRoleSchemas } from './get-interactive-element-role-schemas';
import { getNonInteractiveElementRoleSchemas } from './get-non-interactive-element-role-schemas';
import {
getNonInteractiveElementRoleSchemas,
getNonInteractiveRoles,
} from './get-non-interactive-element-role-schemas';

function checkIsInteractiveElement(node: TmplAstElement): boolean {
function elementSchemaMatcher({ attributes, name }: ARIARoleRelationConcept) {
Expand All @@ -28,6 +32,12 @@ function checkIsInteractiveElement(node: TmplAstElement): boolean {
return getInteractiveElementAXObjectSchemas().some(elementSchemaMatcher);
}

function checkIsNonInteractiveRole(node: TmplAstElement): boolean {
return getNonInteractiveRoles().has(
getAttributeValue(node, 'role') as ARIARole,
);
}

/**
* Returns boolean indicating whether the given element is
* interactive on the DOM or not. Usually used when an element
Expand All @@ -37,3 +47,7 @@ function checkIsInteractiveElement(node: TmplAstElement): boolean {
export function isInteractiveElement(node: TmplAstElement): boolean {
return getDomElements().has(node.name) && checkIsInteractiveElement(node);
}

export function isNonInteractiveRole(node: TmplAstElement): boolean {
return checkIsNonInteractiveRole(node);
}

0 comments on commit d99d8c1

Please sign in to comment.