From d99d8c1c23ece85c5ee37c3515912e90a335be46 Mon Sep 17 00:00:00 2001
From: Sandi Barr <2250413+sandikbarr@users.noreply.github.com>
Date: Mon, 26 Sep 2022 09:54:44 -0500
Subject: [PATCH] feat(eslint-plugin-template):
[accessibility-interactive-supports-focus] add rule (#1134)
---
...ccessibility-interactive-supports-focus.md | 881 ++++++++++++++++++
.../src/configs/all.json | 1 +
packages/eslint-plugin-template/src/index.ts | 5 +
...ccessibility-interactive-supports-focus.ts | 88 ++
.../src/utils/is-content-editable.ts | 16 +
.../src/utils/is-disabled-element.ts | 23 +
...et-non-interactive-element-role-schemas.ts | 42 +-
.../src/utils/is-interactive-element/index.ts | 18 +-
.../cases.ts | 290 ++++++
.../spec.ts | 14 +
10 files changed, 1359 insertions(+), 19 deletions(-)
create mode 100644 packages/eslint-plugin-template/docs/rules/accessibility-interactive-supports-focus.md
create mode 100644 packages/eslint-plugin-template/src/rules/accessibility-interactive-supports-focus.ts
create mode 100644 packages/eslint-plugin-template/src/utils/is-content-editable.ts
create mode 100644 packages/eslint-plugin-template/src/utils/is-disabled-element.ts
create mode 100644 packages/eslint-plugin-template/tests/rules/accessibility-interactive-supports-focus/cases.ts
create mode 100644 packages/eslint-plugin-template/tests/rules/accessibility-interactive-supports-focus/spec.ts
diff --git a/packages/eslint-plugin-template/docs/rules/accessibility-interactive-supports-focus.md b/packages/eslint-plugin-template/docs/rules/accessibility-interactive-supports-focus.md
new file mode 100644
index 000000000..fb7bae82c
--- /dev/null
+++ b/packages/eslint-plugin-template/docs/rules/accessibility-interactive-supports-focus.md
@@ -0,0 +1,881 @@
+
+
+
+
+# `@angular-eslint/template/accessibility-interactive-supports-focus`
+
+Ensures that elements with interactive handlers like `(click)` are focusable.
+
+- Type: suggestion
+
+
+
+## Rule Options
+
+The rule does not have any configuration options.
+
+
+
+## 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.
+
+
+
+
+❌ - Toggle examples of incorrect code for this rule
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+Submit
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+Click me
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+Cannot be focused
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```html
+Cannot be focused
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+```
+
+
+
+
+
+---
+
+
+
+
+✅ - Toggle examples of correct code for this rule
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+
+
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+
+
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+
+
+
+
+
+Foo
+
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+
+
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+Click me
+Click me',
+Click me',
+Click me',
+Click me',
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+hash
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+x.y.z
+x.y.z
+Click ALL the things!
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+x.y.z
+x.y.z
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+
+Submit
+Submit
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+Click me!
+Click me!
+Click me!
+Click me!
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/template/accessibility-interactive-supports-focus": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```html
+Edit this text
+Edit this text
+Edit this too!
+```
+
+
+
+
diff --git a/packages/eslint-plugin-template/src/configs/all.json b/packages/eslint-plugin-template/src/configs/all.json
index 0185d6a3f..846befb7e 100644
--- a/packages/eslint-plugin-template/src/configs/all.json
+++ b/packages/eslint-plugin-template/src/configs/all.json
@@ -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",
diff --git a/packages/eslint-plugin-template/src/index.ts b/packages/eslint-plugin-template/src/index.ts
index d2a1ee39d..a306b2536 100644
--- a/packages/eslint-plugin-template/src/index.ts
+++ b/packages/eslint-plugin-template/src/index.ts
@@ -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';
@@ -78,6 +81,8 @@ export default {
rules: {
[accessibilityAltTextRuleName]: accessibilityAltText,
[accessibilityElementsContentRuleName]: accessibilityElementsContent,
+ [accessibilityInteractiveSupportsFocusRuleName]:
+ accessibilityInteractiveSupportsFocus,
[accessibilityLabelForRuleName]: accessibilityLabelFor,
[accessibilityLabelHasAssociatedControlRuleName]:
accessibilityLabelHasAssociatedControl,
diff --git a/packages/eslint-plugin-template/src/rules/accessibility-interactive-supports-focus.ts b/packages/eslint-plugin-template/src/rules/accessibility-interactive-supports-focus.ts
new file mode 100644
index 000000000..58733c572
--- /dev/null
+++ b/packages/eslint-plugin-template/src/rules/accessibility-interactive-supports-focus.ts
@@ -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({
+ 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,
+ });
+ }
+ },
+ };
+ },
+});
diff --git a/packages/eslint-plugin-template/src/utils/is-content-editable.ts b/packages/eslint-plugin-template/src/utils/is-content-editable.ts
new file mode 100644
index 000000000..98295f954
--- /dev/null
+++ b/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')
+ );
+}
diff --git a/packages/eslint-plugin-template/src/utils/is-disabled-element.ts b/packages/eslint-plugin-template/src/utils/is-disabled-element.ts
new file mode 100644
index 000000000..f014dd205
--- /dev/null
+++ b/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;
+}
diff --git a/packages/eslint-plugin-template/src/utils/is-interactive-element/get-non-interactive-element-role-schemas.ts b/packages/eslint-plugin-template/src/utils/is-interactive-element/get-non-interactive-element-role-schemas.ts
index 03dfab062..29634e2b3 100644
--- a/packages/eslint-plugin-template/src/utils/is-interactive-element/get-non-interactive-element-role-schemas.ts
+++ b/packages/eslint-plugin-template/src/utils/is-interactive-element/get-non-interactive-element-role-schemas.ts
@@ -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 | 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(
+ nonInteractiveElementRoleSchemas = elementRoleEntries.reduce<
+ ARIARoleRelationConcept[]
+ >((accumulator, [elementSchema, roleSet]) => {
+ return accumulator.concat(
+ [...roleSet].every((role) => getNonInteractiveRoles().has(role))
+ ? elementSchema
+ : [],
+ );
+ }, []);
+ }
+
+ return nonInteractiveElementRoleSchemas;
+}
+
+export function getNonInteractiveRoles(): Set {
+ if (nonInteractiveRoles === null) {
+ const roleKeys = [...roles.keys()];
+ nonInteractiveRoles = new Set(
roleKeys
.filter((name) => {
const role = roles.get(name);
@@ -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;
}
diff --git a/packages/eslint-plugin-template/src/utils/is-interactive-element/index.ts b/packages/eslint-plugin-template/src/utils/is-interactive-element/index.ts
index f91ad69f6..22614d89c 100644
--- a/packages/eslint-plugin-template/src/utils/is-interactive-element/index.ts
+++ b/packages/eslint-plugin-template/src/utils/is-interactive-element/index.ts
@@ -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) {
@@ -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
@@ -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);
+}
diff --git a/packages/eslint-plugin-template/tests/rules/accessibility-interactive-supports-focus/cases.ts b/packages/eslint-plugin-template/tests/rules/accessibility-interactive-supports-focus/cases.ts
new file mode 100644
index 000000000..c991c9fc1
--- /dev/null
+++ b/packages/eslint-plugin-template/tests/rules/accessibility-interactive-supports-focus/cases.ts
@@ -0,0 +1,290 @@
+import { convertAnnotatedSourceToFailureCase } from '@angular-eslint/utils';
+import type { MessageIds } from '../../../src/rules/accessibility-interactive-supports-focus';
+
+const messageId: MessageIds = 'interactiveSupportsFocus';
+
+export const valid = [
+ // no interactive outputs
+ { code: '' },
+
+ // aria-hidden
+ {
+ code: `
+
+
+
+ `,
+ },
+
+ // aria-disabled
+ {
+ code: `
+
+
+ `,
+ },
+
+ // presentation role
+ {
+ code: `
+
+
+ `,
+ },
+
+ // explicitly assigned not interactive role
+ {
+ code: `
+
+
+ `,
+ },
+
+ // hidden input is not interactive, tabindex is not required
+ {
+ code: `
+
+
+
+ `,
+ },
+
+ // interactive elements
+ {
+ code: `
+
+
+
+
+
+
+ Foo
+
+ `,
+ },
+
+ // disabled
+ {
+ code: `
+
+
+
+ `,
+ },
+
+ // area without href needs tabindex for focus
+ {
+ code: `
+
+
+ `,
+ },
+
+ // a without href needs tabindex for focus
+ {
+ code: `
+ Click me
+ Click me',
+ Click me',
+ Click me',
+ Click me',
+ `,
+ },
+
+ // interactive role with href and click
+ { code: 'hash' },
+ // href and click
+ {
+ code: `
+ x.y.z
+ x.y.z
+ Click ALL the things!
+ `,
+ },
+ // click and tabindex (focusable but generally not recommended)
+ {
+ code: `
+ x.y.z
+ x.y.z
+ `,
+ },
+ // routerLink
+ {
+ code: `
+
+
+ `,
+ },
+
+ // invalid tabindex
+ {
+ code: `
+
+
+ Submit
+ Submit
+ `,
+ },
+
+ // valid tabindex
+ {
+ code: `
+ Click me!
+ Click me!
+ Click me!
+ Click me!
+ `,
+ },
+
+ // interactive role with tabindex
+ {
+ code: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ },
+
+ // elements with contenteditable enabled are interactive by default
+ {
+ code: `
+ Edit this text
+ Edit this text
+ Edit this too!
+ `,
+ },
+];
+
+export const invalid = [
+ // aria-hidden="false"
+ convertAnnotatedSourceToFailureCase({
+ description: 'should fail not hidden from screen reader',
+ annotatedSource: `
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ messageId,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description:
+ 'should fail not hidden from screen reader with bound aria-hidden attribute',
+ annotatedSource: `
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ messageId,
+ }),
+ // aria-disabled="false"
+ convertAnnotatedSourceToFailureCase({
+ description: 'should fail aria-disabled is false',
+ annotatedSource: `
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ messageId,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: 'should fail aria-disabled is false with bound attribute',
+ annotatedSource: `
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ messageId,
+ }),
+
+ // interactive role, non interactive element
+ convertAnnotatedSourceToFailureCase({
+ description:
+ 'should fail interactive role but element does not support focus',
+ annotatedSource: `
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ messageId,
+ }),
+
+ // no role, non interactive element
+ convertAnnotatedSourceToFailureCase({
+ description: 'should fail non-interactive element does not support focus',
+ annotatedSource: `
+ Submit
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ messageId,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description:
+ 'should fail non-interactive element with aria-label does not support focus',
+ annotatedSource: `
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ messageId,
+ }),
+
+ // invalid role, non interactive element
+ convertAnnotatedSourceToFailureCase({
+ description:
+ 'should fail non-interactive element with invalid role does not support focus',
+ annotatedSource: `
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ messageId,
+ data: {
+ role: 'invalid',
+ },
+ }),
+
+ // area and a are not interactive without href
+ convertAnnotatedSourceToFailureCase({
+ description:
+ 'should fail non-interactive element does not support focus, area should have href',
+ annotatedSource: `
+
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ messageId,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description:
+ 'should fail non-interactive element does not support focus, anchor should have href',
+ annotatedSource: `
+ Click me
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ messageId,
+ }),
+
+ // non-interactive element with keyup, keydown, keypress interaction handlers
+ convertAnnotatedSourceToFailureCase({
+ description:
+ 'should fail non-interactive element with key event does not support focus',
+ annotatedSource: `
+ Cannot be focused
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ messageId,
+ }),
+
+ // contenteditable="false"
+ convertAnnotatedSourceToFailureCase({
+ description:
+ 'should fail non-interactive element with contenteditable disabled does not support focus',
+ annotatedSource: `
+ Cannot be focused
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ `,
+ messageId,
+ }),
+];
diff --git a/packages/eslint-plugin-template/tests/rules/accessibility-interactive-supports-focus/spec.ts b/packages/eslint-plugin-template/tests/rules/accessibility-interactive-supports-focus/spec.ts
new file mode 100644
index 000000000..d5d5f2abe
--- /dev/null
+++ b/packages/eslint-plugin-template/tests/rules/accessibility-interactive-supports-focus/spec.ts
@@ -0,0 +1,14 @@
+import { RuleTester } from '@angular-eslint/utils';
+import rule, {
+ RULE_NAME,
+} from '../../../src/rules/accessibility-interactive-supports-focus';
+import { invalid, valid } from './cases';
+
+const ruleTester = new RuleTester({
+ parser: '@angular-eslint/template-parser',
+});
+
+ruleTester.run(RULE_NAME, rule, {
+ valid,
+ invalid,
+});