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.
Files changed (83) hide show
  1. package/AGENTS.md +110 -0
  2. package/CHANGELOG.md +37 -0
  3. package/LICENSE +22 -0
  4. package/README.md +213 -0
  5. package/package.json +79 -0
  6. package/src/index.d.ts +40 -0
  7. package/src/index.js +279 -0
  8. package/src/rules/alt-text.d.ts +23 -0
  9. package/src/rules/alt-text.js +205 -0
  10. package/src/rules/anchor-ambiguous-text.d.ts +20 -0
  11. package/src/rules/anchor-ambiguous-text.js +169 -0
  12. package/src/rules/anchor-has-content.d.ts +20 -0
  13. package/src/rules/anchor-has-content.js +85 -0
  14. package/src/rules/anchor-is-valid.d.ts +23 -0
  15. package/src/rules/anchor-is-valid.js +100 -0
  16. package/src/rules/aria-activedescendant-has-tabindex.d.ts +15 -0
  17. package/src/rules/aria-activedescendant-has-tabindex.js +63 -0
  18. package/src/rules/aria-props.d.ts +15 -0
  19. package/src/rules/aria-props.js +51 -0
  20. package/src/rules/aria-role.d.ts +21 -0
  21. package/src/rules/aria-role.js +72 -0
  22. package/src/rules/aria-unsupported-elements.d.ts +15 -0
  23. package/src/rules/aria-unsupported-elements.js +54 -0
  24. package/src/rules/autocomplete-valid.d.ts +20 -0
  25. package/src/rules/autocomplete-valid.js +79 -0
  26. package/src/rules/click-events-have-key-events.d.ts +15 -0
  27. package/src/rules/click-events-have-key-events.js +60 -0
  28. package/src/rules/control-has-associated-label.d.ts +24 -0
  29. package/src/rules/control-has-associated-label.js +178 -0
  30. package/src/rules/heading-has-content.d.ts +20 -0
  31. package/src/rules/heading-has-content.js +72 -0
  32. package/src/rules/html-has-lang.d.ts +15 -0
  33. package/src/rules/html-has-lang.js +50 -0
  34. package/src/rules/iframe-has-title.d.ts +15 -0
  35. package/src/rules/iframe-has-title.js +51 -0
  36. package/src/rules/img-redundant-alt.d.ts +21 -0
  37. package/src/rules/img-redundant-alt.js +85 -0
  38. package/src/rules/interactive-supports-focus.d.ts +20 -0
  39. package/src/rules/interactive-supports-focus.js +81 -0
  40. package/src/rules/label-has-associated-control.d.ts +24 -0
  41. package/src/rules/label-has-associated-control.js +93 -0
  42. package/src/rules/lang.d.ts +15 -0
  43. package/src/rules/lang.js +53 -0
  44. package/src/rules/media-has-caption.d.ts +22 -0
  45. package/src/rules/media-has-caption.js +84 -0
  46. package/src/rules/mouse-events-have-key-events.d.ts +17 -0
  47. package/src/rules/mouse-events-have-key-events.js +62 -0
  48. package/src/rules/no-access-key.d.ts +15 -0
  49. package/src/rules/no-access-key.js +43 -0
  50. package/src/rules/no-aria-hidden-on-focusable.d.ts +15 -0
  51. package/src/rules/no-aria-hidden-on-focusable.js +67 -0
  52. package/src/rules/no-autofocus.d.ts +20 -0
  53. package/src/rules/no-autofocus.js +59 -0
  54. package/src/rules/no-distracting-elements.d.ts +20 -0
  55. package/src/rules/no-distracting-elements.js +64 -0
  56. package/src/rules/no-interactive-element-to-noninteractive-role.d.ts +18 -0
  57. package/src/rules/no-interactive-element-to-noninteractive-role.js +86 -0
  58. package/src/rules/no-keyboard-inaccessible-elements.d.ts +24 -0
  59. package/src/rules/no-keyboard-inaccessible-elements.js +136 -0
  60. package/src/rules/no-missing-aria-labels.d.ts +24 -0
  61. package/src/rules/no-missing-aria-labels.js +131 -0
  62. package/src/rules/no-noninteractive-element-interactions.d.ts +20 -0
  63. package/src/rules/no-noninteractive-element-interactions.js +78 -0
  64. package/src/rules/no-noninteractive-element-to-interactive-role.d.ts +18 -0
  65. package/src/rules/no-noninteractive-element-to-interactive-role.js +95 -0
  66. package/src/rules/no-noninteractive-tabindex.d.ts +22 -0
  67. package/src/rules/no-noninteractive-tabindex.js +172 -0
  68. package/src/rules/no-redundant-roles.d.ts +24 -0
  69. package/src/rules/no-redundant-roles.js +115 -0
  70. package/src/rules/no-static-element-interactions.d.ts +20 -0
  71. package/src/rules/no-static-element-interactions.js +72 -0
  72. package/src/rules/prefer-tag-over-role.d.ts +15 -0
  73. package/src/rules/prefer-tag-over-role.js +101 -0
  74. package/src/rules/role-has-required-aria-props.d.ts +15 -0
  75. package/src/rules/role-has-required-aria-props.js +65 -0
  76. package/src/rules/role-supports-aria-props.d.ts +15 -0
  77. package/src/rules/role-supports-aria-props.js +115 -0
  78. package/src/rules/scope.d.ts +15 -0
  79. package/src/rules/scope.js +49 -0
  80. package/src/rules/tabindex-no-positive.d.ts +15 -0
  81. package/src/rules/tabindex-no-positive.js +55 -0
  82. package/src/types/index.d.ts +208 -0
  83. 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
+ });