Skip to content

Commit 82060ee

Browse files
committed
feat(rules): add 4 jsx-a11y parity rules and fix 3 existing rule bugs
Body: New rules (36 → 40): - anchor-is-valid: flags missing/invalid href and onClick-without-href - no-interactive-element-to-noninteractive-role: prevents role=none/presentation stripping semantics from button, a[href], input, select, textarea, summary - no-noninteractive-element-to-interactive-role: requires tabIndex + keyboard handler when adding interactive ARIA roles to static elements - no-redundant-roles: flags role attributes matching the element's implicit ARIA role (e.g. <button role=button>) with auto-fix suggestion Bug fixes: - image-alt: extend coverage to <input type=image> and <area>; allow empty alt on <area> without href (inactive image map areas) - link-text: fix false positive on <a><img alt=Home /></a> — img alt provides an accessible name for the link - form-label: remove cross-file false positive; a control with an id is now treated as having a labeling mechanism (label may be in another file) Bumps version to 0.15.0. recommended config: 24 → 28 rules, strict config: 36 → 40 rules.
1 parent a1d497e commit 82060ee

17 files changed

Lines changed: 717 additions & 91 deletions

CHANGELOG.md

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.15.0] - 2026-02-23
11+
12+
### Added
13+
- 4 new accessibility rules (total: 40 rules)
14+
- `anchor-is-valid` — anchors must have a real href; flags empty `""`, `"#"`, and `javascript:` hrefs, and onClick-without-href (prefer `<button>`)
15+
- `no-interactive-element-to-noninteractive-role` — prevents `role="none"` or `role="presentation"` on interactive elements (`button`, `a[href]`, `input`, `select`, `textarea`, `summary`)
16+
- `no-noninteractive-element-to-interactive-role` — prevents interactive ARIA roles (button, link, checkbox, etc.) on non-interactive elements without both `tabIndex` and a keyboard event handler
17+
- `no-redundant-roles` — flags explicit `role` attributes that match the element's implicit ARIA role (e.g. `<button role="button">`, `<nav role="navigation">`) with auto-fix suggestion
18+
1019
### Fixed
20+
- `image-alt` now also checks `<input type="image">` and `<area>` elements, not just `<img>`; empty alt on `<area>` without `href` is correctly allowed
21+
- `link-text` no longer false-positives on `<a><img alt="Home" /></a>` — an img child with a non-empty alt provides an accessible name for the link
22+
- `form-label` no longer false-positives when a form control has an `id` but its `<label for>` is in a different component file; presence of `id` is now treated as sufficient (label association may be elsewhere)
23+
24+
### Changed
25+
- `recommended` config grows from 24 → 28 rules (new: `anchor-is-valid` as error, `no-interactive-element-to-noninteractive-role` as error, `no-noninteractive-element-to-interactive-role` as warn, `no-redundant-roles` as warn)
26+
- `strict` config grows from 36 → 40 rules (all new rules set to error)
27+
- Updated `config-presets.test.ts` rule counts (recommended: 28, strict: 40)
28+
29+
---
30+
31+
### Fixed (from 0.14.0 unreleased)
1132
- Fixed `./core` CJS export — `require('eslint-plugin-test-a11y-js/core')` now correctly exports `A11yChecker` instead of the ESLint plugin
1233
- Fixed `bin/eslint-with-progress.js` to work with both ESLint v8 and v9 (removed deprecated `useEslintrc` and `extensions` options)
1334
- Removed `vitest` from `peerDependencies` (should only be in devDependencies)
1435
- Replaced `TODO:` placeholder text in `link-text` autofix suggestions with user-friendly text
1536

16-
### Changed
17-
- Updated `plugin-structure.test.ts` to verify all 36 rules (was stale at 6)
37+
### Changed (from 0.14.0 unreleased)
38+
- Updated `plugin-structure.test.ts` to verify all 40 rules
1839
- Enhanced `build-verification.test.ts` with formatter/formatter-progress file checks and export-to-disk validation
19-
- Rewrote `rule-structure.test.ts` to dynamically cover all 36 rules (was hardcoded to 6)
20-
- Rewrote `config-presets.test.ts` to test built plugin with exact rule counts (minimal: 3, recommended: 24, strict: 36)
40+
- Rewrote `rule-structure.test.ts` to dynamically cover all rule files
41+
- Rewrote `config-presets.test.ts` to test built plugin with exact rule counts (minimal: 3, recommended: 28, strict: 40)
2142
- Expanded `test:core` pipeline with config-presets, flat-config, plugin-structure, and all new integration tests
2243
- Clarified recommended config comment about excluded rules (no longer "temporarily disabled")
2344

24-
### Added
45+
### Added (from 0.14.0 unreleased)
2546
- `package-publish-readiness.test.ts` — comprehensive publish gate covering npm pack contents, CJS require, export content validation, rule loading, version consistency, and package.json field checks
2647
- `formatter-output.test.ts` — functional tests for formatter and formatter-with-progress output
2748
- `bin-smoke.test.ts` — smoke test for bin/eslint-with-progress.js (shebang, syntax, structure)

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
-**Zero config** - Works out of the box with React, Vue, and JSX
1212
-**Real-time feedback** - Catch issues in your editor, not in production
13-
-**36 accessibility rules** - Covers images, forms, buttons, landmarks, ARIA, focus, and more
13+
-**40 accessibility rules** - Covers images, forms, buttons, landmarks, ARIA, focus, and more
1414
-**Editor suggestions** - Get actionable fixes directly in your editor
1515
-**Dual API** - Use as ESLint plugin OR programmatic API
1616
-**Large project ready** - Minimal preset for incremental adoption
@@ -289,10 +289,10 @@ In VS Code and other editors with ESLint support, suggestions appear as Quick Fi
289289

290290
## ESLint Rules
291291

292-
The plugin provides **36 accessibility rules**:
292+
The plugin provides **40 accessibility rules**:
293293

294294
**Core rules:**
295-
- `test-a11y-js/image-alt` - Enforce images have alt attributes
295+
- `test-a11y-js/image-alt` - Enforce images, `input[type=image]`, and `area` elements have alt attributes
296296
- `test-a11y-js/button-label` - Enforce buttons have labels
297297
- `test-a11y-js/link-text` - Enforce links have descriptive text
298298
- `test-a11y-js/form-label` - Enforce form controls have labels
@@ -320,6 +320,9 @@ The plugin provides **36 accessibility rules**:
320320
**Focusable & ARIA rules:**
321321
- `test-a11y-js/no-aria-hidden-on-focusable` - Disallow aria-hidden on focusable elements
322322
- `test-a11y-js/no-role-presentation-on-focusable` - Disallow role="presentation" on focusable elements
323+
- `test-a11y-js/no-interactive-element-to-noninteractive-role` - Disallow role="none/presentation" on interactive elements (button, a, input, etc.)
324+
- `test-a11y-js/no-noninteractive-element-to-interactive-role` - Disallow interactive roles on non-interactive elements without keyboard support
325+
- `test-a11y-js/no-redundant-roles` - Disallow explicit roles that match the element's implicit ARIA role
323326
- `test-a11y-js/aria-activedescendant-has-tabindex` - Enforce aria-activedescendant targets are focusable
324327

325328
**Event & keyboard rules:**
@@ -334,6 +337,7 @@ The plugin provides **36 accessibility rules**:
334337
- `test-a11y-js/heading-has-content` - Enforce headings have content
335338
- `test-a11y-js/img-redundant-alt` - Enforce img alt does not contain redundant words
336339
- `test-a11y-js/anchor-ambiguous-text` - Enforce link text is not generic
340+
- `test-a11y-js/anchor-is-valid` - Enforce anchors have valid href (not empty, `#`, or `javascript:`)
337341
- `test-a11y-js/accessible-emoji` - Enforce emoji have accessible labels
338342
- `test-a11y-js/autocomplete-valid` - Enforce autocomplete attribute is valid
339343

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "eslint-plugin-test-a11y-js",
3-
"version": "0.14.0",
3+
"version": "0.15.0",
44
"description": "ESLint accessibility (a11y) plugin for React, Vue, and JSX with flat-config (ESLint v9+) support and a matching runtime A11yChecker API. Alternative to eslint-plugin-jsx-a11y for modern design systems.",
55
"main": "dist/linter/eslint-plugin/index.js",
66
"module": "dist/linter/eslint-plugin/index.mjs",

src/linter/eslint-plugin/configs/recommended.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,13 @@ const recommended: RuleConfig = {
3838
// New rules - Moderate violations (warn)
3939
'test-a11y-js/click-events-have-key-events': 'warn',
4040
'test-a11y-js/no-static-element-interactions': 'warn',
41-
'test-a11y-js/interactive-supports-focus': 'warn'
41+
'test-a11y-js/interactive-supports-focus': 'warn',
42+
43+
// Phase 6: New rules
44+
'test-a11y-js/anchor-is-valid': 'error',
45+
'test-a11y-js/no-interactive-element-to-noninteractive-role': 'error',
46+
'test-a11y-js/no-noninteractive-element-to-interactive-role': 'warn',
47+
'test-a11y-js/no-redundant-roles': 'warn'
4248

4349
// Not included in recommended (available in strict):
4450
// - aria-validation, semantic-html, form-validation

src/linter/eslint-plugin/configs/strict.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,12 @@ const strict: RuleConfig = {
4949
'test-a11y-js/anchor-ambiguous-text': 'error',
5050
'test-a11y-js/img-redundant-alt': 'error',
5151
'test-a11y-js/accessible-emoji': 'error',
52-
'test-a11y-js/html-has-lang': 'error'
52+
'test-a11y-js/html-has-lang': 'error',
53+
// Phase 6: New rules
54+
'test-a11y-js/anchor-is-valid': 'error',
55+
'test-a11y-js/no-interactive-element-to-noninteractive-role': 'error',
56+
'test-a11y-js/no-noninteractive-element-to-interactive-role': 'error',
57+
'test-a11y-js/no-redundant-roles': 'error'
5358
}
5459

5560
export default strict

src/linter/eslint-plugin/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ import anchorAmbiguousText from './rules/anchor-ambiguous-text'
6464
import imgRedundantAlt from './rules/img-redundant-alt'
6565
import accessibleEmoji from './rules/accessible-emoji'
6666
import htmlHasLang from './rules/html-has-lang'
67+
// Phase 6: New rules (parity with jsx-a11y + bug fixes)
68+
import anchorIsValid from './rules/anchor-is-valid'
69+
import noInteractiveElementToNoninteractiveRole from './rules/no-interactive-element-to-noninteractive-role'
70+
import noNoninteractiveElementToInteractiveRole from './rules/no-noninteractive-element-to-interactive-role'
71+
import noRedundantRoles from './rules/no-redundant-roles'
6772

6873
/**
6974
* ESLint plugin for accessibility checking
@@ -114,7 +119,12 @@ const plugin: ESLint.Plugin = {
114119
'anchor-ambiguous-text': anchorAmbiguousText,
115120
'img-redundant-alt': imgRedundantAlt,
116121
'accessible-emoji': accessibleEmoji,
117-
'html-has-lang': htmlHasLang
122+
'html-has-lang': htmlHasLang,
123+
// Phase 6: New rules (parity with jsx-a11y + bug fixes)
124+
'anchor-is-valid': anchorIsValid,
125+
'no-interactive-element-to-noninteractive-role': noInteractiveElementToNoninteractiveRole,
126+
'no-noninteractive-element-to-interactive-role': noNoninteractiveElementToInteractiveRole,
127+
'no-redundant-roles': noRedundantRoles
118128
},
119129
configs: {
120130
minimal: {
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* ESLint rule: anchor-is-valid
3+
*
4+
* Enforces that anchor elements have valid href attributes.
5+
* Anchors without a real href are not valid links and should use
6+
* a <button> element instead for clickable actions.
7+
*/
8+
9+
import type { Rule } from 'eslint'
10+
import { getJSXAttribute, hasJSXAttribute } from '../utils/jsx-ast-utils'
11+
import { getVueAttribute } from '../utils/vue-ast-utils'
12+
import { isElementLike } from '../utils/component-mapping'
13+
14+
const INVALID_HREFS = new Set(['', '#'])
15+
16+
function isInvalidHref(value: string): boolean {
17+
if (INVALID_HREFS.has(value)) return true
18+
if (value.toLowerCase().startsWith('javascript:')) return true
19+
return false
20+
}
21+
22+
const rule: Rule.RuleModule = {
23+
meta: {
24+
type: 'problem',
25+
docs: {
26+
description: 'Enforce anchor elements have valid href attributes',
27+
category: 'Accessibility',
28+
recommended: true,
29+
url: 'https://github.com/nolrm/eslint-plugin-test-a11y-js'
30+
},
31+
messages: {
32+
missingHref: 'Anchor element must have an href attribute to be a valid link. Use a <button> for clickable actions.',
33+
invalidHref: 'The href value "{{href}}" is not a valid URL. Use a real URL, or use a <button> for clickable actions.',
34+
preferButton: 'Anchor elements with click handlers but no href should be <button> elements for proper keyboard accessibility.'
35+
},
36+
hasSuggestions: false,
37+
fixable: undefined,
38+
schema: []
39+
},
40+
create(context: Rule.RuleContext) {
41+
return {
42+
JSXOpeningElement(node: Rule.Node) {
43+
const jsxNode = node as any
44+
45+
if (!jsxNode.name || jsxNode.name.type !== 'JSXIdentifier') {
46+
return
47+
}
48+
49+
if (jsxNode.name.name !== 'a' && !isElementLike(node, context, 'a')) {
50+
return
51+
}
52+
53+
const hrefAttr = getJSXAttribute(jsxNode, 'href')
54+
const hasOnClick = hasJSXAttribute(jsxNode, 'onClick')
55+
56+
if (!hrefAttr) {
57+
// No href at all
58+
if (hasOnClick) {
59+
context.report({ node, messageId: 'preferButton' })
60+
} else {
61+
context.report({ node, messageId: 'missingHref' })
62+
}
63+
return
64+
}
65+
66+
// href exists — validate its value
67+
if (hrefAttr.value?.type === 'Literal' && typeof hrefAttr.value.value === 'string') {
68+
const hrefValue = hrefAttr.value.value
69+
if (isInvalidHref(hrefValue)) {
70+
context.report({
71+
node,
72+
messageId: 'invalidHref',
73+
data: { href: hrefValue }
74+
})
75+
}
76+
}
77+
},
78+
79+
VElement(node: Rule.Node) {
80+
const vueNode = node as any
81+
82+
if (vueNode.name !== 'a') {
83+
return
84+
}
85+
86+
const hrefAttr = getVueAttribute(vueNode, 'href')
87+
const hasClickHandler = vueNode.startTag?.attributes?.some((attr: any) =>
88+
attr.directive &&
89+
attr.key?.name?.name === 'on' &&
90+
attr.key?.argument?.name === 'click'
91+
)
92+
93+
if (!hrefAttr) {
94+
if (hasClickHandler) {
95+
context.report({ node, messageId: 'preferButton' })
96+
} else {
97+
context.report({ node, messageId: 'missingHref' })
98+
}
99+
return
100+
}
101+
102+
// href exists — validate its value
103+
const hrefValue = hrefAttr.value?.value
104+
if (typeof hrefValue === 'string' && isInvalidHref(hrefValue)) {
105+
context.report({
106+
node,
107+
messageId: 'invalidHref',
108+
data: { href: hrefValue }
109+
})
110+
}
111+
}
112+
}
113+
}
114+
}
115+
116+
export default rule

src/linter/eslint-plugin/rules/form-label.ts

Lines changed: 11 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -24,66 +24,35 @@ const rule: Rule.RuleModule = {
2424
schema: []
2525
},
2626
create(context: Rule.RuleContext) {
27-
// Track form controls and their labels in the current file
28-
const formControls = new Map<string, any>()
29-
const labels = new Map<string, any>()
30-
3127
return {
3228
// Check JSX form control elements
3329
JSXOpeningElement(node: Rule.Node) {
3430
const jsxNode = node as any
35-
31+
3632
// Only handle simple identifiers (not member expressions like <Form.Input>)
3733
if (!jsxNode.name || jsxNode.name.type !== 'JSXIdentifier') {
3834
return
3935
}
40-
36+
4137
const tagName = jsxNode.name.name?.toLowerCase()
42-
38+
4339
if (tagName === 'input' || tagName === 'select' || tagName === 'textarea') {
4440
// Check if it has aria-label or aria-labelledby
4541
const hasAriaLabel = hasJSXAttribute(jsxNode, 'aria-label')
4642
const hasAriaLabelledBy = hasJSXAttribute(jsxNode, 'aria-labelledby')
47-
48-
// Get id attribute
49-
const idAttr = jsxNode.attributes?.find((attr: any) =>
43+
44+
// Get id attribute — if present, a label may be in another component
45+
const idAttr = jsxNode.attributes?.find((attr: any) =>
5046
attr.name?.name === 'id'
5147
)
5248
const id = idAttr?.value?.value
5349

54-
// If no aria-label, aria-labelledby, or id, report
50+
// Report only when there is no labeling mechanism at all
5551
if (!hasAriaLabel && !hasAriaLabelledBy && !id) {
5652
context.report({
5753
node,
5854
messageId: 'missingLabel'
5955
})
60-
} else if (id) {
61-
// Store for later checking against label[for]
62-
formControls.set(id, node)
63-
}
64-
}
65-
66-
// Check for label elements
67-
if (tagName === 'label') {
68-
const forAttr = jsxNode.attributes?.find((attr: any) =>
69-
attr.name?.name === 'for' || attr.name?.name === 'htmlFor'
70-
)
71-
const forValue = forAttr?.value?.value
72-
if (forValue) {
73-
labels.set(forValue, node)
74-
}
75-
}
76-
},
77-
78-
// After checking all nodes, verify label associations
79-
'Program:exit'() {
80-
// Check if form controls have matching labels
81-
for (const [id, node] of formControls.entries()) {
82-
if (!labels.has(id)) {
83-
context.report({
84-
node,
85-
messageId: 'missingLabel'
86-
})
8756
}
8857
}
8958
},
@@ -92,34 +61,22 @@ const rule: Rule.RuleModule = {
9261
VElement(node: Rule.Node) {
9362
const vueNode = node as any
9463
const tagName = vueNode.name?.toLowerCase()
95-
64+
9665
if (tagName === 'input' || tagName === 'select' || tagName === 'textarea') {
9766
// Check if it has aria-label or aria-labelledby
9867
const hasAriaLabel = hasVueAttribute(vueNode, 'aria-label')
9968
const hasAriaLabelledBy = hasVueAttribute(vueNode, 'aria-labelledby')
100-
101-
// Get id attribute
69+
70+
// Get id attribute — if present, a label may be in another component
10271
const idAttr = getVueAttribute(vueNode, 'id')
10372
const id = idAttr?.value?.value
10473

105-
// If no aria-label, aria-labelledby, or id, report
74+
// Report only when there is no labeling mechanism at all
10675
if (!hasAriaLabel && !hasAriaLabelledBy && !id) {
10776
context.report({
10877
node,
10978
messageId: 'missingLabel'
11079
})
111-
} else if (id) {
112-
// Store for later checking against label[for]
113-
formControls.set(id, node)
114-
}
115-
}
116-
117-
// Check for label elements
118-
if (tagName === 'label') {
119-
const forAttr = getVueAttribute(vueNode, 'for')
120-
const forValue = forAttr?.value?.value
121-
if (forValue) {
122-
labels.set(forValue, node)
12380
}
12481
}
12582
}

0 commit comments

Comments
 (0)