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 10af6f97f..71aaf1e4e 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-table-scope": "error", diff --git a/packages/eslint-plugin-template/src/index.ts b/packages/eslint-plugin-template/src/index.ts index 4f2deb265..756384f88 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'; @@ -75,6 +78,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, +});