eslint-plugin-react-a11y 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +110 -0
- package/CHANGELOG.md +37 -0
- package/LICENSE +22 -0
- package/README.md +213 -0
- package/package.json +79 -0
- package/src/index.d.ts +40 -0
- package/src/index.js +279 -0
- package/src/rules/alt-text.d.ts +23 -0
- package/src/rules/alt-text.js +205 -0
- package/src/rules/anchor-ambiguous-text.d.ts +20 -0
- package/src/rules/anchor-ambiguous-text.js +169 -0
- package/src/rules/anchor-has-content.d.ts +20 -0
- package/src/rules/anchor-has-content.js +85 -0
- package/src/rules/anchor-is-valid.d.ts +23 -0
- package/src/rules/anchor-is-valid.js +100 -0
- package/src/rules/aria-activedescendant-has-tabindex.d.ts +15 -0
- package/src/rules/aria-activedescendant-has-tabindex.js +63 -0
- package/src/rules/aria-props.d.ts +15 -0
- package/src/rules/aria-props.js +51 -0
- package/src/rules/aria-role.d.ts +21 -0
- package/src/rules/aria-role.js +72 -0
- package/src/rules/aria-unsupported-elements.d.ts +15 -0
- package/src/rules/aria-unsupported-elements.js +54 -0
- package/src/rules/autocomplete-valid.d.ts +20 -0
- package/src/rules/autocomplete-valid.js +79 -0
- package/src/rules/click-events-have-key-events.d.ts +15 -0
- package/src/rules/click-events-have-key-events.js +60 -0
- package/src/rules/control-has-associated-label.d.ts +24 -0
- package/src/rules/control-has-associated-label.js +178 -0
- package/src/rules/heading-has-content.d.ts +20 -0
- package/src/rules/heading-has-content.js +72 -0
- package/src/rules/html-has-lang.d.ts +15 -0
- package/src/rules/html-has-lang.js +50 -0
- package/src/rules/iframe-has-title.d.ts +15 -0
- package/src/rules/iframe-has-title.js +51 -0
- package/src/rules/img-redundant-alt.d.ts +21 -0
- package/src/rules/img-redundant-alt.js +85 -0
- package/src/rules/interactive-supports-focus.d.ts +20 -0
- package/src/rules/interactive-supports-focus.js +81 -0
- package/src/rules/label-has-associated-control.d.ts +24 -0
- package/src/rules/label-has-associated-control.js +93 -0
- package/src/rules/lang.d.ts +15 -0
- package/src/rules/lang.js +53 -0
- package/src/rules/media-has-caption.d.ts +22 -0
- package/src/rules/media-has-caption.js +84 -0
- package/src/rules/mouse-events-have-key-events.d.ts +17 -0
- package/src/rules/mouse-events-have-key-events.js +62 -0
- package/src/rules/no-access-key.d.ts +15 -0
- package/src/rules/no-access-key.js +43 -0
- package/src/rules/no-aria-hidden-on-focusable.d.ts +15 -0
- package/src/rules/no-aria-hidden-on-focusable.js +67 -0
- package/src/rules/no-autofocus.d.ts +20 -0
- package/src/rules/no-autofocus.js +59 -0
- package/src/rules/no-distracting-elements.d.ts +20 -0
- package/src/rules/no-distracting-elements.js +64 -0
- package/src/rules/no-interactive-element-to-noninteractive-role.d.ts +18 -0
- package/src/rules/no-interactive-element-to-noninteractive-role.js +86 -0
- package/src/rules/no-keyboard-inaccessible-elements.d.ts +24 -0
- package/src/rules/no-keyboard-inaccessible-elements.js +136 -0
- package/src/rules/no-missing-aria-labels.d.ts +24 -0
- package/src/rules/no-missing-aria-labels.js +131 -0
- package/src/rules/no-noninteractive-element-interactions.d.ts +20 -0
- package/src/rules/no-noninteractive-element-interactions.js +78 -0
- package/src/rules/no-noninteractive-element-to-interactive-role.d.ts +18 -0
- package/src/rules/no-noninteractive-element-to-interactive-role.js +95 -0
- package/src/rules/no-noninteractive-tabindex.d.ts +22 -0
- package/src/rules/no-noninteractive-tabindex.js +172 -0
- package/src/rules/no-redundant-roles.d.ts +24 -0
- package/src/rules/no-redundant-roles.js +115 -0
- package/src/rules/no-static-element-interactions.d.ts +20 -0
- package/src/rules/no-static-element-interactions.js +72 -0
- package/src/rules/prefer-tag-over-role.d.ts +15 -0
- package/src/rules/prefer-tag-over-role.js +101 -0
- package/src/rules/role-has-required-aria-props.d.ts +15 -0
- package/src/rules/role-has-required-aria-props.js +65 -0
- package/src/rules/role-supports-aria-props.d.ts +15 -0
- package/src/rules/role-supports-aria-props.js +115 -0
- package/src/rules/scope.d.ts +15 -0
- package/src/rules/scope.js +49 -0
- package/src/rules/tabindex-no-positive.d.ts +15 -0
- package/src/rules/tabindex-no-positive.js +55 -0
- package/src/types/index.d.ts +208 -0
- package/src/types/index.js +7 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
4
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
5
|
+
* MIT license that can be found in the LICENSE file.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.ariaUnsupportedElements = void 0;
|
|
9
|
+
const eslint_devkit_1 = require("@interlace/eslint-devkit");
|
|
10
|
+
const eslint_devkit_2 = require("@interlace/eslint-devkit");
|
|
11
|
+
exports.ariaUnsupportedElements = (0, eslint_devkit_2.createRule)({
|
|
12
|
+
name: 'aria-unsupported-elements',
|
|
13
|
+
meta: {
|
|
14
|
+
type: 'problem',
|
|
15
|
+
docs: {
|
|
16
|
+
description: 'Enforce that elements that don\'t support ARIA roles, states, and properties do not contain them',
|
|
17
|
+
},
|
|
18
|
+
messages: {
|
|
19
|
+
unsupportedAria: (0, eslint_devkit_1.formatLLMMessage)({
|
|
20
|
+
icon: eslint_devkit_1.MessageIcons.ACCESSIBILITY,
|
|
21
|
+
issueName: 'Unsupported ARIA',
|
|
22
|
+
description: '<{{element}}> should not have ARIA attributes',
|
|
23
|
+
severity: 'MEDIUM',
|
|
24
|
+
fix: 'Remove ARIA attributes from this element',
|
|
25
|
+
documentationLink: 'https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/aria-unsupported-elements.md',
|
|
26
|
+
}),
|
|
27
|
+
},
|
|
28
|
+
schema: [],
|
|
29
|
+
},
|
|
30
|
+
defaultOptions: [],
|
|
31
|
+
create(context) {
|
|
32
|
+
return {
|
|
33
|
+
JSXOpeningElement(node) {
|
|
34
|
+
if (node.name.type !== 'JSXIdentifier')
|
|
35
|
+
return;
|
|
36
|
+
const element = node.name.name;
|
|
37
|
+
if (!eslint_devkit_1.ARIA_UNSUPPORTED_ELEMENTS.has(element))
|
|
38
|
+
return;
|
|
39
|
+
const hasAria = node.attributes.some((attr) => attr.type === 'JSXAttribute' &&
|
|
40
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
41
|
+
(attr.name.name.startsWith('aria-') || attr.name.name === 'role'));
|
|
42
|
+
if (hasAria) {
|
|
43
|
+
context.report({
|
|
44
|
+
node,
|
|
45
|
+
messageId: 'unsupportedAria',
|
|
46
|
+
data: {
|
|
47
|
+
element
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* ESLint Rule: autocomplete-valid
|
|
8
|
+
* Enforce that autocomplete attribute has valid value
|
|
9
|
+
*
|
|
10
|
+
* @see https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/autocomplete-valid.md
|
|
11
|
+
*/
|
|
12
|
+
import type { TSESLint } from '@interlace/eslint-devkit';
|
|
13
|
+
type Options = {
|
|
14
|
+
inputComponents?: string[];
|
|
15
|
+
};
|
|
16
|
+
type RuleOptions = [Options?];
|
|
17
|
+
export declare const autocompleteValid: TSESLint.RuleModule<"invalidAutocomplete", RuleOptions, unknown, TSESLint.RuleListener> & {
|
|
18
|
+
name: string;
|
|
19
|
+
};
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
4
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
5
|
+
* MIT license that can be found in the LICENSE file.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.autocompleteValid = void 0;
|
|
9
|
+
const eslint_devkit_1 = require("@interlace/eslint-devkit");
|
|
10
|
+
const eslint_devkit_2 = require("@interlace/eslint-devkit");
|
|
11
|
+
const VALID_AUTOCOMPLETE_VALUES = new Set([
|
|
12
|
+
'on', 'off', 'name', 'honorific-prefix', 'given-name', 'additional-name', 'family-name',
|
|
13
|
+
'honorific-suffix', 'nickname', 'email', 'username', 'new-password', 'current-password',
|
|
14
|
+
'organization-title', 'organization', 'street-address', 'address-line1', 'address-line2',
|
|
15
|
+
'address-line3', 'address-level4', 'address-level3', 'address-level2', 'address-level1',
|
|
16
|
+
'country', 'country-name', 'postal-code', 'cc-name', 'cc-given-name', 'cc-additional-name',
|
|
17
|
+
'cc-family-name', 'cc-number', 'cc-exp', 'cc-exp-month', 'cc-exp-year', 'cc-csc', 'cc-type',
|
|
18
|
+
'transaction-currency', 'transaction-amount', 'language', 'bday', 'bday-day', 'bday-month',
|
|
19
|
+
'bday-year', 'sex', 'tel', 'tel-country-code', 'tel-national', 'tel-area-code', 'tel-local',
|
|
20
|
+
'tel-extension', 'impp', 'url', 'photo'
|
|
21
|
+
]);
|
|
22
|
+
exports.autocompleteValid = (0, eslint_devkit_2.createRule)({
|
|
23
|
+
name: 'autocomplete-valid',
|
|
24
|
+
meta: {
|
|
25
|
+
type: 'problem',
|
|
26
|
+
docs: {
|
|
27
|
+
description: 'Enforce that autocomplete attribute has valid value',
|
|
28
|
+
},
|
|
29
|
+
messages: {
|
|
30
|
+
invalidAutocomplete: (0, eslint_devkit_1.formatLLMMessage)({
|
|
31
|
+
icon: eslint_devkit_1.MessageIcons.ACCESSIBILITY,
|
|
32
|
+
issueName: 'Invalid Autocomplete',
|
|
33
|
+
description: 'Invalid autocomplete value',
|
|
34
|
+
severity: 'MEDIUM',
|
|
35
|
+
fix: 'Use a valid autocomplete token (e.g., "username", "current-password")',
|
|
36
|
+
documentationLink: 'https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/autocomplete-valid.md',
|
|
37
|
+
}),
|
|
38
|
+
},
|
|
39
|
+
schema: [
|
|
40
|
+
{
|
|
41
|
+
type: 'object',
|
|
42
|
+
properties: {
|
|
43
|
+
inputComponents: { type: 'array', items: { type: 'string' } }
|
|
44
|
+
},
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
defaultOptions: [{}],
|
|
50
|
+
create(context, [options = {}]) {
|
|
51
|
+
const { inputComponents = [] } = options ?? {};
|
|
52
|
+
const inputs = ['input', ...inputComponents];
|
|
53
|
+
return {
|
|
54
|
+
JSXOpeningElement(node) {
|
|
55
|
+
if (node.name.type !== 'JSXIdentifier' || !inputs.includes(node.name.name))
|
|
56
|
+
return;
|
|
57
|
+
const autocomplete = node.attributes.find((attr) => attr.type === 'JSXAttribute' &&
|
|
58
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
59
|
+
attr.name.name === 'autocomplete');
|
|
60
|
+
if (!autocomplete || autocomplete.type !== 'JSXAttribute' || !autocomplete.value || autocomplete.value.type !== 'Literal' || typeof autocomplete.value.value !== 'string')
|
|
61
|
+
return;
|
|
62
|
+
const value = autocomplete.value.value;
|
|
63
|
+
// Handle space-separated values
|
|
64
|
+
const tokens = value.split(/\s+/);
|
|
65
|
+
for (const token of tokens) {
|
|
66
|
+
// Handle optional 'section-' prefix
|
|
67
|
+
const effectiveToken = token.startsWith('section-') ? token.replace(/^section-/, '') : token;
|
|
68
|
+
if (!VALID_AUTOCOMPLETE_VALUES.has(effectiveToken)) {
|
|
69
|
+
context.report({
|
|
70
|
+
node: autocomplete,
|
|
71
|
+
messageId: 'invalidAutocomplete',
|
|
72
|
+
});
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* ESLint Rule: click-events-have-key-events
|
|
8
|
+
* Enforce that onClick is accompanied by keyboard events
|
|
9
|
+
*
|
|
10
|
+
* @see https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/click-events-have-key-events.md
|
|
11
|
+
*/
|
|
12
|
+
import type { TSESLint } from '@interlace/eslint-devkit';
|
|
13
|
+
export declare const clickEventsHaveKeyEvents: TSESLint.RuleModule<"missingKeyboardEvent", [], unknown, TSESLint.RuleListener> & {
|
|
14
|
+
name: string;
|
|
15
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
4
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
5
|
+
* MIT license that can be found in the LICENSE file.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.clickEventsHaveKeyEvents = void 0;
|
|
9
|
+
const eslint_devkit_1 = require("@interlace/eslint-devkit");
|
|
10
|
+
const eslint_devkit_2 = require("@interlace/eslint-devkit");
|
|
11
|
+
exports.clickEventsHaveKeyEvents = (0, eslint_devkit_2.createRule)({
|
|
12
|
+
name: 'click-events-have-key-events',
|
|
13
|
+
meta: {
|
|
14
|
+
type: 'problem',
|
|
15
|
+
docs: {
|
|
16
|
+
description: 'Enforce that onClick is accompanied by keyboard events',
|
|
17
|
+
},
|
|
18
|
+
messages: {
|
|
19
|
+
missingKeyboardEvent: (0, eslint_devkit_1.formatLLMMessage)({
|
|
20
|
+
icon: eslint_devkit_1.MessageIcons.ACCESSIBILITY,
|
|
21
|
+
issueName: 'Missing Keyboard Listener',
|
|
22
|
+
description: 'onClick must be accompanied by onKeyUp, onKeyDown, or onKeyPress',
|
|
23
|
+
severity: 'MEDIUM',
|
|
24
|
+
fix: 'Add onKeyDown/onKeyUp handler',
|
|
25
|
+
documentationLink: 'https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/click-events-have-key-events.md',
|
|
26
|
+
}),
|
|
27
|
+
},
|
|
28
|
+
schema: [],
|
|
29
|
+
},
|
|
30
|
+
defaultOptions: [],
|
|
31
|
+
create(context) {
|
|
32
|
+
return {
|
|
33
|
+
JSXOpeningElement(node) {
|
|
34
|
+
const hasOnClick = node.attributes.some((attr) => attr.type === 'JSXAttribute' &&
|
|
35
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
36
|
+
attr.name.name === 'onClick');
|
|
37
|
+
if (!hasOnClick)
|
|
38
|
+
return;
|
|
39
|
+
const hasKeyboardEvent = node.attributes.some((attr) => attr.type === 'JSXAttribute' &&
|
|
40
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
41
|
+
['onKeyUp', 'onKeyDown', 'onKeyPress'].includes(attr.name.name));
|
|
42
|
+
// Exception: hidden inputs or non-interactive elements that shouldn't have click?
|
|
43
|
+
// Actually, if it has click, it should have keyboard support.
|
|
44
|
+
// But if it's a button or link, it's native.
|
|
45
|
+
const tagName = node.name.type === 'JSXIdentifier' ? node.name.name : null;
|
|
46
|
+
const isInteractive = ['button', 'a', 'input', 'select', 'textarea', 'option'].includes(tagName || '');
|
|
47
|
+
// Native interactive elements handle keyboard click automatically.
|
|
48
|
+
// But if it's a div/span with onClick, it needs keyboard listener.
|
|
49
|
+
if (isInteractive)
|
|
50
|
+
return;
|
|
51
|
+
if (!hasKeyboardEvent) {
|
|
52
|
+
context.report({
|
|
53
|
+
node,
|
|
54
|
+
messageId: 'missingKeyboardEvent',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* ESLint Rule: control-has-associated-label
|
|
8
|
+
* Enforce that controls (interactive elements) have associated labels
|
|
9
|
+
*
|
|
10
|
+
* @see https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/control-has-associated-label.md
|
|
11
|
+
*/
|
|
12
|
+
import type { TSESLint } from '@interlace/eslint-devkit';
|
|
13
|
+
type Options = {
|
|
14
|
+
labelAttributes?: string[];
|
|
15
|
+
controlComponents?: string[];
|
|
16
|
+
ignoreElements?: string[];
|
|
17
|
+
ignoreRoles?: string[];
|
|
18
|
+
depth?: number;
|
|
19
|
+
};
|
|
20
|
+
type RuleOptions = [Options?];
|
|
21
|
+
export declare const controlHasAssociatedLabel: TSESLint.RuleModule<"missingLabel", RuleOptions, unknown, TSESLint.RuleListener> & {
|
|
22
|
+
name: string;
|
|
23
|
+
};
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
4
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
5
|
+
* MIT license that can be found in the LICENSE file.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.controlHasAssociatedLabel = void 0;
|
|
9
|
+
const eslint_devkit_1 = require("@interlace/eslint-devkit");
|
|
10
|
+
const eslint_devkit_2 = require("@interlace/eslint-devkit");
|
|
11
|
+
const DEFAULT_IGNORE_ELEMENTS = [
|
|
12
|
+
'audio', 'canvas', 'embed', 'tr', 'video'
|
|
13
|
+
];
|
|
14
|
+
const DEFAULT_IGNORE_ROLES = [
|
|
15
|
+
'grid', 'listbox', 'menu', 'menubar', 'radiogroup', 'row', 'tablist', 'toolbar', 'tree', 'treegrid'
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* Check if element has accessible text content
|
|
19
|
+
*/
|
|
20
|
+
function hasAccessibleText(node, depth, labelAttributes) {
|
|
21
|
+
// Check aria-label
|
|
22
|
+
const ariaLabel = node.openingElement.attributes.find((attr) => attr.type === 'JSXAttribute' &&
|
|
23
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
24
|
+
attr.name.name === 'aria-label');
|
|
25
|
+
if (ariaLabel && ariaLabel.type === 'JSXAttribute' && ariaLabel.value)
|
|
26
|
+
return true;
|
|
27
|
+
// Check aria-labelledby
|
|
28
|
+
const ariaLabelledby = node.openingElement.attributes.find((attr) => attr.type === 'JSXAttribute' &&
|
|
29
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
30
|
+
attr.name.name === 'aria-labelledby');
|
|
31
|
+
if (ariaLabelledby && ariaLabelledby.type === 'JSXAttribute' && ariaLabelledby.value)
|
|
32
|
+
return true;
|
|
33
|
+
// Check custom label attributes
|
|
34
|
+
for (const attr of labelAttributes) {
|
|
35
|
+
const labelAttr = node.openingElement.attributes.find((a) => a.type === 'JSXAttribute' &&
|
|
36
|
+
a.name.type === 'JSXIdentifier' &&
|
|
37
|
+
a.name.name === attr);
|
|
38
|
+
if (labelAttr && labelAttr.type === 'JSXAttribute' && labelAttr.value)
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
// For img elements and input type="image", check alt
|
|
42
|
+
if (node.openingElement.name.type === 'JSXIdentifier') {
|
|
43
|
+
if (node.openingElement.name.name === 'img') {
|
|
44
|
+
const altAttr = node.openingElement.attributes.find((attr) => attr.type === 'JSXAttribute' &&
|
|
45
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
46
|
+
attr.name.name === 'alt');
|
|
47
|
+
if (altAttr && altAttr.type === 'JSXAttribute' && altAttr.value)
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
else if (node.openingElement.name.name === 'input') {
|
|
51
|
+
// Check if it's an image input with alt
|
|
52
|
+
const typeAttr = node.openingElement.attributes.find((attr) => attr.type === 'JSXAttribute' &&
|
|
53
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
54
|
+
attr.name.name === 'type');
|
|
55
|
+
if (typeAttr && typeAttr.type === 'JSXAttribute' && typeAttr.value && typeAttr.value.type === 'Literal' && typeAttr.value.value === 'image') {
|
|
56
|
+
const altAttr = node.openingElement.attributes.find((attr) => attr.type === 'JSXAttribute' &&
|
|
57
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
58
|
+
attr.name.name === 'alt');
|
|
59
|
+
if (altAttr && altAttr.type === 'JSXAttribute' && altAttr.value)
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Check text content in children (up to specified depth)
|
|
65
|
+
return hasTextInChildren(node.children, depth);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Check if children contain text content
|
|
69
|
+
*/
|
|
70
|
+
function hasTextInChildren(children, depth) {
|
|
71
|
+
if (depth <= 0)
|
|
72
|
+
return false;
|
|
73
|
+
for (const child of children) {
|
|
74
|
+
if (child.type === 'JSXText') {
|
|
75
|
+
if (child.value.trim())
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
else if (child.type === 'JSXElement') {
|
|
79
|
+
if (hasAccessibleText(child, depth - 1, []))
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
else if (child.type === 'JSXExpressionContainer') {
|
|
83
|
+
// Assume expressions might contain labels
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Check if element is a control component
|
|
91
|
+
*/
|
|
92
|
+
function isControlComponent(elementName, controlComponents) {
|
|
93
|
+
const controls = ['button', 'input', 'select', 'textarea', 'a', ...controlComponents];
|
|
94
|
+
return controls.includes(elementName);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Check if element has an interactive role
|
|
98
|
+
*/
|
|
99
|
+
function hasInteractiveRole(node, ignoreRoles) {
|
|
100
|
+
const roleAttr = node.attributes.find((attr) => attr.type === 'JSXAttribute' &&
|
|
101
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
102
|
+
attr.name.name === 'role');
|
|
103
|
+
if (!roleAttr || roleAttr.type !== 'JSXAttribute' || !roleAttr.value || roleAttr.value.type !== 'Literal')
|
|
104
|
+
return false;
|
|
105
|
+
const role = roleAttr.value.value;
|
|
106
|
+
if (typeof role !== 'string')
|
|
107
|
+
return false;
|
|
108
|
+
// Interactive roles that require labels
|
|
109
|
+
const interactiveRoles = [
|
|
110
|
+
'button', 'checkbox', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
|
|
111
|
+
'option', 'radio', 'switch', 'tab', 'textbox', 'combobox', 'listbox', 'searchbox',
|
|
112
|
+
'slider', 'spinbutton', 'progressbar'
|
|
113
|
+
];
|
|
114
|
+
return interactiveRoles.includes(role) && !ignoreRoles.includes(role);
|
|
115
|
+
}
|
|
116
|
+
exports.controlHasAssociatedLabel = (0, eslint_devkit_2.createRule)({
|
|
117
|
+
name: 'control-has-associated-label',
|
|
118
|
+
meta: {
|
|
119
|
+
type: 'problem',
|
|
120
|
+
docs: {
|
|
121
|
+
description: 'Enforce that controls (interactive elements) have associated labels',
|
|
122
|
+
},
|
|
123
|
+
messages: {
|
|
124
|
+
missingLabel: (0, eslint_devkit_1.formatLLMMessage)({
|
|
125
|
+
icon: eslint_devkit_1.MessageIcons.ACCESSIBILITY,
|
|
126
|
+
issueName: 'Control Missing Label',
|
|
127
|
+
description: '<{{element}}> must have an accessible label',
|
|
128
|
+
severity: 'HIGH',
|
|
129
|
+
fix: 'Add text content, aria-label, or aria-labelledby',
|
|
130
|
+
documentationLink: 'https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/control-has-associated-label.md',
|
|
131
|
+
cwe: 'CWE-252'
|
|
132
|
+
}),
|
|
133
|
+
},
|
|
134
|
+
schema: [
|
|
135
|
+
{
|
|
136
|
+
type: 'object',
|
|
137
|
+
properties: {
|
|
138
|
+
labelAttributes: { type: 'array', items: { type: 'string' } },
|
|
139
|
+
controlComponents: { type: 'array', items: { type: 'string' } },
|
|
140
|
+
ignoreElements: { type: 'array', items: { type: 'string' } },
|
|
141
|
+
ignoreRoles: { type: 'array', items: { type: 'string' } },
|
|
142
|
+
depth: { type: 'integer', minimum: 1, maximum: 25 },
|
|
143
|
+
},
|
|
144
|
+
additionalProperties: false,
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
defaultOptions: [{}],
|
|
149
|
+
create(context, [options = {}]) {
|
|
150
|
+
const { labelAttributes = [], controlComponents = [], ignoreElements = DEFAULT_IGNORE_ELEMENTS, ignoreRoles = DEFAULT_IGNORE_ROLES, depth = 2, } = options ?? {};
|
|
151
|
+
return {
|
|
152
|
+
JSXElement(node) {
|
|
153
|
+
const openingElement = node.openingElement;
|
|
154
|
+
if (openingElement.name.type !== 'JSXIdentifier')
|
|
155
|
+
return;
|
|
156
|
+
const elementName = openingElement.name.name;
|
|
157
|
+
// Skip ignored elements
|
|
158
|
+
if (ignoreElements.includes(elementName))
|
|
159
|
+
return;
|
|
160
|
+
// Check if it's a control component or has interactive role
|
|
161
|
+
const isControl = isControlComponent(elementName, controlComponents);
|
|
162
|
+
const hasInteractiveRoleAttr = hasInteractiveRole(openingElement, ignoreRoles);
|
|
163
|
+
if (!isControl && !hasInteractiveRoleAttr)
|
|
164
|
+
return;
|
|
165
|
+
// Check if it has accessible text
|
|
166
|
+
if (!hasAccessibleText(node, depth, labelAttributes)) {
|
|
167
|
+
context.report({
|
|
168
|
+
node: openingElement,
|
|
169
|
+
messageId: 'missingLabel',
|
|
170
|
+
data: {
|
|
171
|
+
element: elementName,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* ESLint Rule: heading-has-content
|
|
8
|
+
* Enforce that headings have content
|
|
9
|
+
*
|
|
10
|
+
* @see https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/heading-has-content.md
|
|
11
|
+
*/
|
|
12
|
+
import type { TSESLint } from '@interlace/eslint-devkit';
|
|
13
|
+
type Options = {
|
|
14
|
+
components?: string[];
|
|
15
|
+
};
|
|
16
|
+
type RuleOptions = [Options?];
|
|
17
|
+
export declare const headingHasContent: TSESLint.RuleModule<"missingContent", RuleOptions, unknown, TSESLint.RuleListener> & {
|
|
18
|
+
name: string;
|
|
19
|
+
};
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
4
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
5
|
+
* MIT license that can be found in the LICENSE file.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.headingHasContent = void 0;
|
|
9
|
+
const eslint_devkit_1 = require("@interlace/eslint-devkit");
|
|
10
|
+
const eslint_devkit_2 = require("@interlace/eslint-devkit");
|
|
11
|
+
function hasContent(node, children) {
|
|
12
|
+
if (children.length > 0)
|
|
13
|
+
return true;
|
|
14
|
+
return node.attributes.some((attr) => {
|
|
15
|
+
if (attr.type !== 'JSXAttribute' || attr.name.type !== 'JSXIdentifier')
|
|
16
|
+
return false;
|
|
17
|
+
const name = attr.name.name;
|
|
18
|
+
return (name === 'dangerouslySetInnerHTML' ||
|
|
19
|
+
name === 'children' ||
|
|
20
|
+
name === 'title'
|
|
21
|
+
// Headings don't typically use aria-label as primary content source, but usually accepted if no text
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
exports.headingHasContent = (0, eslint_devkit_2.createRule)({
|
|
26
|
+
name: 'heading-has-content',
|
|
27
|
+
meta: {
|
|
28
|
+
type: 'problem',
|
|
29
|
+
docs: {
|
|
30
|
+
description: 'Enforce that headings have content',
|
|
31
|
+
},
|
|
32
|
+
messages: {
|
|
33
|
+
missingContent: (0, eslint_devkit_1.formatLLMMessage)({
|
|
34
|
+
icon: eslint_devkit_1.MessageIcons.ACCESSIBILITY,
|
|
35
|
+
issueName: 'Heading Missing Content',
|
|
36
|
+
description: 'Heading elements must have accessible content',
|
|
37
|
+
severity: 'HIGH',
|
|
38
|
+
fix: 'Provide text content for the heading',
|
|
39
|
+
documentationLink: 'https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/heading-has-content.md',
|
|
40
|
+
cwe: 'CWE-252'
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
schema: [
|
|
44
|
+
{
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
components: { type: 'array', items: { type: 'string' } },
|
|
48
|
+
},
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
defaultOptions: [{}],
|
|
54
|
+
create(context, [options = {}]) {
|
|
55
|
+
const { components = [] } = options ?? {};
|
|
56
|
+
const headings = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', ...components];
|
|
57
|
+
return {
|
|
58
|
+
JSXElement(node) {
|
|
59
|
+
const openingElement = node.openingElement;
|
|
60
|
+
if (openingElement.name.type !== 'JSXIdentifier' || !headings.includes(openingElement.name.name)) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (!hasContent(openingElement, node.children)) {
|
|
64
|
+
context.report({
|
|
65
|
+
node: openingElement,
|
|
66
|
+
messageId: 'missingContent',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* ESLint Rule: html-has-lang
|
|
8
|
+
* Enforce that html element has lang attribute
|
|
9
|
+
*
|
|
10
|
+
* @see https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/html-has-lang.md
|
|
11
|
+
*/
|
|
12
|
+
import type { TSESLint } from '@interlace/eslint-devkit';
|
|
13
|
+
export declare const htmlHasLang: TSESLint.RuleModule<"missingLang", [], unknown, TSESLint.RuleListener> & {
|
|
14
|
+
name: string;
|
|
15
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
4
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
5
|
+
* MIT license that can be found in the LICENSE file.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.htmlHasLang = void 0;
|
|
9
|
+
const eslint_devkit_1 = require("@interlace/eslint-devkit");
|
|
10
|
+
const eslint_devkit_2 = require("@interlace/eslint-devkit");
|
|
11
|
+
exports.htmlHasLang = (0, eslint_devkit_2.createRule)({
|
|
12
|
+
name: 'html-has-lang',
|
|
13
|
+
meta: {
|
|
14
|
+
type: 'problem',
|
|
15
|
+
docs: {
|
|
16
|
+
description: 'Enforce that html element has lang attribute',
|
|
17
|
+
},
|
|
18
|
+
messages: {
|
|
19
|
+
missingLang: (0, eslint_devkit_1.formatLLMMessage)({
|
|
20
|
+
icon: eslint_devkit_1.MessageIcons.ACCESSIBILITY,
|
|
21
|
+
issueName: 'Missing Lang Attribute',
|
|
22
|
+
description: '<html> element must have a lang attribute',
|
|
23
|
+
severity: 'CRITICAL',
|
|
24
|
+
fix: 'Add lang="en" (or appropriate language code)',
|
|
25
|
+
documentationLink: 'https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/html-has-lang.md',
|
|
26
|
+
cwe: 'CWE-252'
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
schema: [],
|
|
30
|
+
},
|
|
31
|
+
defaultOptions: [],
|
|
32
|
+
create(context) {
|
|
33
|
+
return {
|
|
34
|
+
JSXOpeningElement(node) {
|
|
35
|
+
if (node.name.type === 'JSXIdentifier' && node.name.name === 'html') {
|
|
36
|
+
const hasLang = node.attributes.some((attr) => attr.type === 'JSXAttribute' &&
|
|
37
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
38
|
+
attr.name.name === 'lang' &&
|
|
39
|
+
attr.value?.type === 'Literal');
|
|
40
|
+
if (!hasLang) {
|
|
41
|
+
context.report({
|
|
42
|
+
node,
|
|
43
|
+
messageId: 'missingLang',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* ESLint Rule: iframe-has-title
|
|
8
|
+
* Enforce that iframes have a title attribute
|
|
9
|
+
*
|
|
10
|
+
* @see https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/iframe-has-title.md
|
|
11
|
+
*/
|
|
12
|
+
import type { TSESLint } from '@interlace/eslint-devkit';
|
|
13
|
+
export declare const iframeHasTitle: TSESLint.RuleModule<"missingTitle", [], unknown, TSESLint.RuleListener> & {
|
|
14
|
+
name: string;
|
|
15
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
4
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
5
|
+
* MIT license that can be found in the LICENSE file.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.iframeHasTitle = void 0;
|
|
9
|
+
const eslint_devkit_1 = require("@interlace/eslint-devkit");
|
|
10
|
+
const eslint_devkit_2 = require("@interlace/eslint-devkit");
|
|
11
|
+
exports.iframeHasTitle = (0, eslint_devkit_2.createRule)({
|
|
12
|
+
name: 'iframe-has-title',
|
|
13
|
+
meta: {
|
|
14
|
+
type: 'problem',
|
|
15
|
+
docs: {
|
|
16
|
+
description: 'Enforce that iframes have a title attribute',
|
|
17
|
+
},
|
|
18
|
+
messages: {
|
|
19
|
+
missingTitle: (0, eslint_devkit_1.formatLLMMessage)({
|
|
20
|
+
icon: eslint_devkit_1.MessageIcons.ACCESSIBILITY,
|
|
21
|
+
issueName: 'Iframe Missing Title',
|
|
22
|
+
description: '<iframe> must have a unique title property',
|
|
23
|
+
severity: 'CRITICAL',
|
|
24
|
+
fix: 'Add title="Description of content"',
|
|
25
|
+
documentationLink: 'https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/iframe-has-title.md',
|
|
26
|
+
cwe: 'CWE-252'
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
schema: [],
|
|
30
|
+
},
|
|
31
|
+
defaultOptions: [],
|
|
32
|
+
create(context) {
|
|
33
|
+
return {
|
|
34
|
+
JSXOpeningElement(node) {
|
|
35
|
+
if (node.name.type !== 'JSXIdentifier' || node.name.name !== 'iframe') {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const hasTitle = node.attributes.some((attr) => attr.type === 'JSXAttribute' &&
|
|
39
|
+
attr.name.type === 'JSXIdentifier' &&
|
|
40
|
+
attr.name.name === 'title' &&
|
|
41
|
+
attr.value?.type === 'Literal');
|
|
42
|
+
if (!hasTitle) {
|
|
43
|
+
context.report({
|
|
44
|
+
node,
|
|
45
|
+
messageId: 'missingTitle',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
});
|