Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(eslint-plugin-template): [accessibility-interactive-supports-foc…
…us] add rule (#1134)
- Loading branch information
1 parent
85c2452
commit d99d8c1
Showing
10 changed files
with
1,359 additions
and
19 deletions.
There are no files selected for viewing
881 changes: 881 additions & 0 deletions
881
...s/eslint-plugin-template/docs/rules/accessibility-interactive-supports-focus.md
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
88 changes: 88 additions & 0 deletions
88
packages/eslint-plugin-template/src/rules/accessibility-interactive-supports-focus.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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
16
packages/eslint-plugin-template/src/utils/is-content-editable.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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
23
packages/eslint-plugin-template/src/utils/is-disabled-element.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.