Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(eslint-plugin-template): [accessibility-interactive-supports-focus] add rule #1134

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",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have updated the rule name to follow the accessibility- name prefix convention.

"@angular-eslint/template/accessibility-label-for": "error",
"@angular-eslint/template/accessibility-label-has-associated-control": "error",
"@angular-eslint/template/accessibility-table-scope": "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 @@ -75,6 +78,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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update to support contenteditable: I was realizing I had used only (click) events in my test cases and updated some to use keyup, keydown, and keypress events and realized those key events might be used for contenteditable which is focusable and interactive by default.

) {
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);
}