@a11yfred/neighbor 0.1.0 → 0.2.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/CHANGELOG.md +28 -0
- package/README.md +78 -4
- package/lib/helpers-angular.js +15 -0
- package/lib/helpers-jsx.js +12 -0
- package/lib/helpers-vue.js +16 -0
- package/lib/rules.js +253 -0
- package/lib/ulam-rules.js +122 -46
- package/neighbor-eslint-angular.mjs +3 -1
- package/neighbor-eslint-vue.mjs +3 -1
- package/package.json +2 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — 2026-05-12
|
|
4
|
+
|
|
5
|
+
### New rules
|
|
6
|
+
|
|
7
|
+
| Rule | What it catches |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `no-labelledby-missing-target` | `aria-labelledby`/`describedby`/`controls`/`owns`/`activedescendant` referencing an `id` that doesn't exist in the file |
|
|
10
|
+
| `no-dynamic-content-without-live` | `dangerouslySetInnerHTML` / `v-html` / `[innerHTML]` on an element outside a live region |
|
|
11
|
+
| `form-field-multiple-labels` | Multiple `<label for="…">` elements targeting the same input |
|
|
12
|
+
| `no-empty-table-header` | `<th>` or `role="columnheader"/"rowheader"` with no accessible text |
|
|
13
|
+
|
|
14
|
+
All four rules run on React, Vue, and Angular.
|
|
15
|
+
|
|
16
|
+
### Extended rules
|
|
17
|
+
|
|
18
|
+
**`no-announce-in-render`** now runs in the Vue and Angular plugins, not just React. Safe contexts are tuned per framework — Vue recognises `onMounted`, `watch`, `watchEffect`, `nextTick`; Angular recognises `ngOnInit`, `ngAfterViewInit`, `ngOnChanges`, and class method event handlers.
|
|
19
|
+
|
|
20
|
+
### Setup improvements
|
|
21
|
+
|
|
22
|
+
README now includes correct parser snippets for Vue and Angular, and separate setup sections for Remix 2 and Remix 3.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 0.1.0 — 2026-04-30
|
|
27
|
+
|
|
28
|
+
Initial release.
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ npm install --save-dev @a11yfred/neighbor
|
|
|
12
12
|
|
|
13
13
|
| Import | Use for |
|
|
14
14
|
| --- | --- |
|
|
15
|
-
| `@a11yfred/neighbor/eslint` | React / JSX |
|
|
15
|
+
| `@a11yfred/neighbor/eslint` | React / JSX, Remix 2 |
|
|
16
16
|
| `@a11yfred/neighbor/eslint-vue` | Vue SFCs |
|
|
17
17
|
| `@a11yfred/neighbor/eslint-angular` | Angular templates |
|
|
18
18
|
| `@a11yfred/neighbor` | Stylelint — CSS user-preference fallbacks |
|
|
@@ -39,6 +39,51 @@ export default [
|
|
|
39
39
|
]
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
### Remix 2
|
|
43
|
+
|
|
44
|
+
Remix 2 is React-based. Use the React entry point. The `no-hash-router-in-remix` and `no-use-page-title-in-remix` rules activate automatically when Remix imports are detected.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm install --save-dev eslint-plugin-jsx-a11y @a11yfred/neighbor
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
// eslint.config.js
|
|
52
|
+
import neighbor from '@a11yfred/neighbor/eslint'
|
|
53
|
+
|
|
54
|
+
export default [
|
|
55
|
+
{
|
|
56
|
+
files: ['**/*.{js,jsx,ts,tsx}'],
|
|
57
|
+
plugins: { ...neighbor.configs.recommended.plugins },
|
|
58
|
+
rules: { ...neighbor.configs.recommended.rules },
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Remix 3
|
|
64
|
+
|
|
65
|
+
Remix 3 is framework-agnostic and does not require React. Neighbor does not have a dedicated Remix 3 entry point — use the entry point that matches your renderer.
|
|
66
|
+
|
|
67
|
+
If you are using React with Remix 3:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm install --save-dev eslint-plugin-jsx-a11y @a11yfred/neighbor
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
import neighbor from '@a11yfred/neighbor/eslint'
|
|
75
|
+
|
|
76
|
+
export default [
|
|
77
|
+
{
|
|
78
|
+
files: ['**/*.{js,jsx,ts,tsx}'],
|
|
79
|
+
plugins: { ...neighbor.configs.recommended.plugins },
|
|
80
|
+
rules: { ...neighbor.configs.recommended.rules },
|
|
81
|
+
},
|
|
82
|
+
]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
If you are not using React with Remix 3, neighbor does not currently have a template-level entry point for your renderer. The Remix-specific rules (`no-hash-router-in-remix`, `no-use-page-title-in-remix`) only apply to React-based Remix projects.
|
|
86
|
+
|
|
42
87
|
### Vue
|
|
43
88
|
|
|
44
89
|
```bash
|
|
@@ -46,10 +91,13 @@ npm install --save-dev eslint-plugin-vuejs-accessibility @a11yfred/neighbor
|
|
|
46
91
|
```
|
|
47
92
|
|
|
48
93
|
```js
|
|
94
|
+
import vueParser from 'vue-eslint-parser'
|
|
49
95
|
import neighbor from '@a11yfred/neighbor/eslint-vue'
|
|
50
96
|
|
|
51
97
|
export default [
|
|
52
98
|
{
|
|
99
|
+
files: ['**/*.vue'],
|
|
100
|
+
languageOptions: { parser: vueParser },
|
|
53
101
|
plugins: { ...neighbor.configs.recommended.plugins },
|
|
54
102
|
rules: { ...neighbor.configs.recommended.rules },
|
|
55
103
|
},
|
|
@@ -63,13 +111,24 @@ npm install --save-dev @angular-eslint/eslint-plugin-template @a11yfred/neighbor
|
|
|
63
111
|
```
|
|
64
112
|
|
|
65
113
|
```js
|
|
114
|
+
import angularTemplateParser from '@angular-eslint/template-parser'
|
|
66
115
|
import neighbor from '@a11yfred/neighbor/eslint-angular'
|
|
67
116
|
|
|
68
117
|
export default [
|
|
69
118
|
{
|
|
119
|
+
files: ['**/*.html'],
|
|
120
|
+
languageOptions: { parser: angularTemplateParser },
|
|
70
121
|
plugins: { ...neighbor.configs.recommended.plugins },
|
|
71
122
|
rules: { ...neighbor.configs.recommended.rules },
|
|
72
123
|
},
|
|
124
|
+
{
|
|
125
|
+
// Also lint component TypeScript files for the announce() rule
|
|
126
|
+
files: ['**/*.ts'],
|
|
127
|
+
plugins: { '@a11yfred/neighbor': neighbor },
|
|
128
|
+
rules: {
|
|
129
|
+
'@a11yfred/neighbor/no-announce-in-render': 'error',
|
|
130
|
+
},
|
|
131
|
+
},
|
|
73
132
|
]
|
|
74
133
|
```
|
|
75
134
|
|
|
@@ -148,12 +207,26 @@ Base: `eslint-plugin-jsx-a11y`
|
|
|
148
207
|
| `title` attribute as the only accessible name | `no-title-as-label` | 2.4.6 |
|
|
149
208
|
| `<video>` or `<audio autoplay>` without controls | `no-autoplay-without-controls` | 1.4.2 |
|
|
150
209
|
| Mouse-only events without keyboard equivalents | `no-mouse-only-events` | 2.1.1 |
|
|
210
|
+
| `aria-labelledby`/`describedby` references missing `id` | `no-labelledby-missing-target` | 4.1.2 |
|
|
211
|
+
| `dangerouslySetInnerHTML` outside a live region | `no-dynamic-content-without-live` | 4.1.3 |
|
|
212
|
+
| Multiple `<label>` elements for the same control | `form-field-multiple-labels` | 1.3.1 |
|
|
213
|
+
| `<th>` or header role with no accessible text | `no-empty-table-header` | 1.3.1 |
|
|
214
|
+
| `announce()` called in component render body | `no-announce-in-render` | 4.1.3 |
|
|
215
|
+
|
|
216
|
+
### ESLint — Remix 2
|
|
217
|
+
|
|
218
|
+
Same as React / JSX. Additional rules activate when Remix imports are detected in the file being linted:
|
|
219
|
+
|
|
220
|
+
| What it checks | Rule | Severity |
|
|
221
|
+
| --- | --- | --- |
|
|
222
|
+
| `@ulam` hash router alongside `react-router` | `no-hash-router-in-remix` | warn |
|
|
223
|
+
| `usePageTitle()` alongside `react-router` | `no-use-page-title-in-remix` | warn |
|
|
151
224
|
|
|
152
225
|
### ESLint — Vue SFCs
|
|
153
226
|
|
|
154
227
|
Base: `eslint-plugin-vuejs-accessibility`
|
|
155
228
|
|
|
156
|
-
Neighbor adds everything in the React table above, adapted for Vue's AST, plus:
|
|
229
|
+
Neighbor adds everything in the React table above, adapted for Vue's AST (`v-html` instead of `dangerouslySetInnerHTML`), plus:
|
|
157
230
|
|
|
158
231
|
| What it checks | Rule | WCAG SC |
|
|
159
232
|
| --- | --- | --- |
|
|
@@ -166,14 +239,15 @@ Neighbor adds everything in the React table above, adapted for Vue's AST, plus:
|
|
|
166
239
|
| Alt text contains "image", "photo" | `no-img-redundant-alt` | 1.1.1 |
|
|
167
240
|
| `accessKey` attribute | `no-access-key` | 2.1.4 |
|
|
168
241
|
| `scope` on `<td>` (only valid on `<th>`) | `no-scope-on-td` | 1.3.1 |
|
|
242
|
+
| `announce()` called outside `onMounted`/`watch`/handler | `no-announce-in-render` | 4.1.3 |
|
|
169
243
|
|
|
170
244
|
### ESLint — Angular templates
|
|
171
245
|
|
|
172
246
|
Base: `@angular-eslint/eslint-plugin-template`
|
|
173
247
|
|
|
174
|
-
Neighbor adds the same rule set as Vue, adapted for Angular's template AST.
|
|
248
|
+
Neighbor adds the same rule set as Vue, adapted for Angular's template AST (`[innerHTML]` instead of `dangerouslySetInnerHTML`). The `no-announce-in-render` rule also lints Angular component TypeScript files — see the setup instructions for how to configure it for `.ts` files alongside `.html` templates.
|
|
175
249
|
|
|
176
|
-
**Known limitation:** Angular's template parser does not attach parent pointers to AST nodes. Rules that need to walk up the tree (`no-summary-without-details`, `no-button-type-missing`, `no-log-with-interactive-children`, `no-menu-role-on-nav`, `no-heading-inside-interactive`) will silently pass in Angular templates.
|
|
250
|
+
**Known limitation:** Angular's template parser does not attach parent pointers to AST nodes. Rules that need to walk up the tree (`no-summary-without-details`, `no-button-type-missing`, `no-log-with-interactive-children`, `no-menu-role-on-nav`, `no-heading-inside-interactive`) will silently pass in Angular templates. The `no-dynamic-content-without-live` rule only checks the element itself for Angular (no ancestor walk).
|
|
177
251
|
|
|
178
252
|
### Stylelint — CSS
|
|
179
253
|
|
package/lib/helpers-angular.js
CHANGED
|
@@ -109,6 +109,19 @@ export function getClassName(tmplElement) {
|
|
|
109
109
|
return getAttrStringValue(getAttr(tmplElement, 'class'))
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Returns the [innerHTML] bound input node if present, else null.
|
|
114
|
+
* In @angular-eslint/template-parser, property bindings live on node.inputs
|
|
115
|
+
* as TmplAstBoundAttribute nodes with { name: 'innerHTML' }.
|
|
116
|
+
*/
|
|
117
|
+
export function getInnerHtmlAttr(tmplElement) {
|
|
118
|
+
return (tmplElement.inputs ?? []).find(i => i.name === 'innerHTML') ?? null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function getInnerHtmlAttrName(_tmplElement) {
|
|
122
|
+
return '[innerHTML]'
|
|
123
|
+
}
|
|
124
|
+
|
|
112
125
|
export const h = {
|
|
113
126
|
getAttr,
|
|
114
127
|
getAttrStringValue,
|
|
@@ -123,6 +136,8 @@ export const h = {
|
|
|
123
136
|
getAncestors,
|
|
124
137
|
getChildOpeningElements,
|
|
125
138
|
getClassName,
|
|
139
|
+
getInnerHtmlAttr,
|
|
140
|
+
getInnerHtmlAttrName,
|
|
126
141
|
elementVisitor: 'Element$1',
|
|
127
142
|
elementWithChildrenVisitor: 'Element$1',
|
|
128
143
|
getOpeningElement: (node) => node,
|
package/lib/helpers-jsx.js
CHANGED
|
@@ -148,6 +148,16 @@ export function hasNewTabWarning(openingElement) {
|
|
|
148
148
|
return NEW_TAB_PATTERN.test(childText)
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/** Returns the dangerouslySetInnerHTML attribute node if present, else null. */
|
|
152
|
+
export function getInnerHtmlAttr(openingElement) {
|
|
153
|
+
return getAttr(openingElement, 'dangerouslySetInnerHTML')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Returns the attribute name string for the inject-HTML attribute. */
|
|
157
|
+
export function getInnerHtmlAttrName(_openingElement) {
|
|
158
|
+
return 'dangerouslySetInnerHTML'
|
|
159
|
+
}
|
|
160
|
+
|
|
151
161
|
/** Wrap all JSX helpers into the standard `h` interface expected by rules. */
|
|
152
162
|
export const h = {
|
|
153
163
|
getAttr,
|
|
@@ -166,6 +176,8 @@ export const h = {
|
|
|
166
176
|
getAncestors,
|
|
167
177
|
getChildOpeningElements,
|
|
168
178
|
getClassName,
|
|
179
|
+
getInnerHtmlAttr,
|
|
180
|
+
getInnerHtmlAttrName,
|
|
169
181
|
// Node visitor key for ESLint — what AST node type fires the rule
|
|
170
182
|
elementVisitor: 'JSXOpeningElement',
|
|
171
183
|
// For rules that need to visit element+children (group-without-name, etc.)
|
package/lib/helpers-vue.js
CHANGED
|
@@ -114,6 +114,20 @@ export function getClassName(vElement) {
|
|
|
114
114
|
return getAttrStringValue(getAttr(vElement, 'class'))
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Returns the v-html directive attribute node if present, else null.
|
|
119
|
+
* v-html is a directive (a.directive === true, key.name === 'html'),
|
|
120
|
+
* not a plain static attribute — so we search differently.
|
|
121
|
+
*/
|
|
122
|
+
export function getInnerHtmlAttr(vElement) {
|
|
123
|
+
const attrs = vElement.startTag?.attributes ?? []
|
|
124
|
+
return attrs.find(a => a.directive && a.key?.name === 'html') ?? null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getInnerHtmlAttrName(_vElement) {
|
|
128
|
+
return 'v-html'
|
|
129
|
+
}
|
|
130
|
+
|
|
117
131
|
export const h = {
|
|
118
132
|
getAttr,
|
|
119
133
|
getAttrStringValue,
|
|
@@ -128,6 +142,8 @@ export const h = {
|
|
|
128
142
|
getAncestors,
|
|
129
143
|
getChildOpeningElements,
|
|
130
144
|
getClassName,
|
|
145
|
+
getInnerHtmlAttr,
|
|
146
|
+
getInnerHtmlAttrName,
|
|
131
147
|
elementVisitor: 'VElement',
|
|
132
148
|
elementWithChildrenVisitor: 'VElement',
|
|
133
149
|
getOpeningElement: (node) => node,
|
package/lib/rules.js
CHANGED
|
@@ -2014,6 +2014,251 @@ export function makeNoInputTypeInvalid(h) {
|
|
|
2014
2014
|
}
|
|
2015
2015
|
}
|
|
2016
2016
|
|
|
2017
|
+
// ─── no-labelledby-missing-target ────────────────────────────────────────────
|
|
2018
|
+
// aria-labelledby and aria-describedby accept a space-separated list of id refs.
|
|
2019
|
+
// If any referenced id does not exist in the same file the association is broken —
|
|
2020
|
+
// AT silently computes an empty name. axe-core catches this at runtime; we can
|
|
2021
|
+
// catch the static case (same file) at lint time.
|
|
2022
|
+
// Ref: axe-core aria-labelledby (reimplemented); ARIA 1.2 §6.2.4; SC 4.1.2
|
|
2023
|
+
|
|
2024
|
+
const LABELLEDBY_ATTRS = ['aria-labelledby', 'aria-describedby', 'aria-controls', 'aria-owns', 'aria-activedescendant']
|
|
2025
|
+
|
|
2026
|
+
export function makeNoLabelledbyMissingTarget(h) {
|
|
2027
|
+
return {
|
|
2028
|
+
meta: {
|
|
2029
|
+
type: 'problem',
|
|
2030
|
+
docs: { description: 'Disallow aria-labelledby/describedby/controls/owns/activedescendant referencing an id that does not exist in the file' },
|
|
2031
|
+
messages: {
|
|
2032
|
+
missingTarget:
|
|
2033
|
+
'{{attr}}="{{ids}}" references id "{{id}}" which does not exist in this file. ' +
|
|
2034
|
+
'AT will compute an empty name for this element. Add an element with id="{{id}}" ' +
|
|
2035
|
+
'or correct the reference. (axe-core aria-labelledby / SC 4.1.2)',
|
|
2036
|
+
},
|
|
2037
|
+
schema: [],
|
|
2038
|
+
},
|
|
2039
|
+
create(context) {
|
|
2040
|
+
const definedIds = new Set()
|
|
2041
|
+
// attr node → { attr, tokens } — collected on first pass, checked at exit
|
|
2042
|
+
const refs = []
|
|
2043
|
+
|
|
2044
|
+
return {
|
|
2045
|
+
[h.elementVisitor](node) {
|
|
2046
|
+
const idVal = h.getAttrStringValue(h.getAttr(node, 'id'))
|
|
2047
|
+
if (idVal) definedIds.add(idVal.trim())
|
|
2048
|
+
|
|
2049
|
+
for (const attrName of LABELLEDBY_ATTRS) {
|
|
2050
|
+
const attrNode = h.getAttr(node, attrName)
|
|
2051
|
+
if (!attrNode) continue
|
|
2052
|
+
const val = h.getAttrStringValue(attrNode)
|
|
2053
|
+
if (!val) continue
|
|
2054
|
+
const tokens = val.trim().split(/\s+/).filter(Boolean)
|
|
2055
|
+
if (tokens.length) refs.push({ attrNode, attrName, tokens })
|
|
2056
|
+
}
|
|
2057
|
+
},
|
|
2058
|
+
|
|
2059
|
+
'Program:exit'() {
|
|
2060
|
+
for (const { attrNode, attrName, tokens } of refs) {
|
|
2061
|
+
for (const id of tokens) {
|
|
2062
|
+
if (!definedIds.has(id)) {
|
|
2063
|
+
context.report({
|
|
2064
|
+
node: attrNode,
|
|
2065
|
+
messageId: 'missingTarget',
|
|
2066
|
+
data: { attr: attrName, ids: tokens.join(' '), id },
|
|
2067
|
+
})
|
|
2068
|
+
break // one report per attribute is enough
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
},
|
|
2073
|
+
}
|
|
2074
|
+
},
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
// ─── no-dynamic-content-without-live ─────────────────────────────────────────
|
|
2079
|
+
// Injecting HTML dynamically (dangerouslySetInnerHTML / v-html / [innerHTML])
|
|
2080
|
+
// replaces the subtree after load. Screen readers do not re-read replaced
|
|
2081
|
+
// content unless a live region wraps it. axe-core catches this at runtime as
|
|
2082
|
+
// "content-changes" violations; we can catch the static pattern at lint time.
|
|
2083
|
+
//
|
|
2084
|
+
// The check: the element using the inject-HTML attribute, or one of its
|
|
2085
|
+
// ancestors, must have aria-live (or role="alert"/"status"/"log"/"marquee"
|
|
2086
|
+
// which carry implicit live region semantics).
|
|
2087
|
+
//
|
|
2088
|
+
// Angular ancestor walking is unavailable (getParent returns null) so for
|
|
2089
|
+
// Angular we only check the element itself — a partial but still useful signal.
|
|
2090
|
+
//
|
|
2091
|
+
// Ref: axe-core (content-changes); WCAG SC 4.1.3 Status Messages
|
|
2092
|
+
|
|
2093
|
+
const IMPLICIT_LIVE_ROLES = new Set(['alert', 'status', 'log', 'marquee', 'timer'])
|
|
2094
|
+
|
|
2095
|
+
function hasLiveRegion(node, h) {
|
|
2096
|
+
if (h.hasAttr(node, 'aria-live')) return true
|
|
2097
|
+
const role = h.getRoleValue(node)
|
|
2098
|
+
if (role && IMPLICIT_LIVE_ROLES.has(role)) return true
|
|
2099
|
+
return false
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
export function makeNoDynamicContentWithoutLive(h) {
|
|
2103
|
+
return {
|
|
2104
|
+
meta: {
|
|
2105
|
+
type: 'problem',
|
|
2106
|
+
docs: { description: 'Require aria-live on elements that inject dynamic HTML content' },
|
|
2107
|
+
messages: {
|
|
2108
|
+
missingLive:
|
|
2109
|
+
'{{attr}} replaces element content after load. Screen readers will not re-read ' +
|
|
2110
|
+
'the new content unless this element or an ancestor has aria-live (or an implicit ' +
|
|
2111
|
+
'live role like role="alert"). Add aria-live="polite" (or role="status") to the ' +
|
|
2112
|
+
'container, or move the inject into an existing live region. (axe-core content-changes / SC 4.1.3)',
|
|
2113
|
+
},
|
|
2114
|
+
schema: [],
|
|
2115
|
+
},
|
|
2116
|
+
create(context) {
|
|
2117
|
+
return {
|
|
2118
|
+
[h.elementVisitor](node) {
|
|
2119
|
+
const injectAttr = h.getInnerHtmlAttr(node)
|
|
2120
|
+
if (!injectAttr) return
|
|
2121
|
+
|
|
2122
|
+
// Check the element itself first
|
|
2123
|
+
if (hasLiveRegion(node, h)) return
|
|
2124
|
+
|
|
2125
|
+
// Walk ancestors (returns nothing for Angular — degrades to element-only check)
|
|
2126
|
+
for (const ancestor of h.getAncestors(node)) {
|
|
2127
|
+
if (hasLiveRegion(ancestor, h)) return
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
const attrName = h.getInnerHtmlAttrName(node)
|
|
2131
|
+
context.report({ node: injectAttr, messageId: 'missingLive', data: { attr: attrName } })
|
|
2132
|
+
},
|
|
2133
|
+
}
|
|
2134
|
+
},
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// ─── form-field-multiple-labels ───────────────────────────────────────────────
|
|
2139
|
+
// A form control should have exactly one label. When multiple <label for="X">
|
|
2140
|
+
// elements point to the same input, screen readers read all of them — the result
|
|
2141
|
+
// is verbose, repetitive, or confusing depending on the AT.
|
|
2142
|
+
// We only flag the case where more than one *static* <label for="id"> targets
|
|
2143
|
+
// the same input in the same file. Dynamic labels (v-bind:for, [for]) are skipped.
|
|
2144
|
+
// Ref: axe-core form-field-multiple-labels (reimplemented); SC 1.3.1
|
|
2145
|
+
|
|
2146
|
+
export function makeFormFieldMultipleLabels(h) {
|
|
2147
|
+
return {
|
|
2148
|
+
meta: {
|
|
2149
|
+
type: 'problem',
|
|
2150
|
+
docs: { description: 'Disallow multiple <label> elements associated with the same form control' },
|
|
2151
|
+
messages: {
|
|
2152
|
+
multipleLabels:
|
|
2153
|
+
'id="{{id}}" is referenced by more than one <label for="...">. Screen readers read all ' +
|
|
2154
|
+
'associated labels — duplicates add noise or conflict. Keep exactly one label per control. ' +
|
|
2155
|
+
'(axe-core form-field-multiple-labels / SC 1.3.1)',
|
|
2156
|
+
},
|
|
2157
|
+
schema: [],
|
|
2158
|
+
},
|
|
2159
|
+
create(context) {
|
|
2160
|
+
// Map from id value → array of <label for="id"> attribute nodes
|
|
2161
|
+
const labelForRefs = new Map()
|
|
2162
|
+
|
|
2163
|
+
return {
|
|
2164
|
+
[h.elementVisitor](node) {
|
|
2165
|
+
if (h.getElementName(node) !== 'label') return
|
|
2166
|
+
// Support both htmlFor (JSX) and for (Vue/Angular)
|
|
2167
|
+
const forAttr = h.getAttr(node, 'htmlFor') ?? h.getAttr(node, 'for')
|
|
2168
|
+
if (!forAttr) return
|
|
2169
|
+
const forVal = h.getAttrStringValue(forAttr)
|
|
2170
|
+
if (!forVal) return
|
|
2171
|
+
const id = forVal.trim()
|
|
2172
|
+
if (!labelForRefs.has(id)) labelForRefs.set(id, [])
|
|
2173
|
+
labelForRefs.get(id).push(forAttr)
|
|
2174
|
+
},
|
|
2175
|
+
|
|
2176
|
+
'Program:exit'() {
|
|
2177
|
+
for (const [id, nodes] of labelForRefs) {
|
|
2178
|
+
if (nodes.length < 2) continue
|
|
2179
|
+
// Report the second and subsequent labels — the first is fine
|
|
2180
|
+
for (const node of nodes.slice(1)) {
|
|
2181
|
+
context.report({ node, messageId: 'multipleLabels', data: { id } })
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
},
|
|
2185
|
+
}
|
|
2186
|
+
},
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
// ─── no-empty-table-header ────────────────────────────────────────────────────
|
|
2191
|
+
// <th> elements (and elements with role="columnheader" or role="rowheader") must
|
|
2192
|
+
// have accessible text — either text content or aria-label / aria-labelledby.
|
|
2193
|
+
// An empty table header is invisible to screen reader users; they cannot navigate
|
|
2194
|
+
// or understand the table structure.
|
|
2195
|
+
// Ref: axe-core empty-table-header (reimplemented); SC 1.3.1
|
|
2196
|
+
|
|
2197
|
+
const TABLE_HEADER_ROLES = new Set(['columnheader', 'rowheader'])
|
|
2198
|
+
|
|
2199
|
+
export function makeNoEmptyTableHeader(h) {
|
|
2200
|
+
return {
|
|
2201
|
+
meta: {
|
|
2202
|
+
type: 'problem',
|
|
2203
|
+
docs: { description: 'Require accessible text on <th> elements and header role elements' },
|
|
2204
|
+
messages: {
|
|
2205
|
+
emptyHeader:
|
|
2206
|
+
'This table header has no accessible name — screen readers cannot describe the column ' +
|
|
2207
|
+
'or row to users. Add visible text, aria-label, or aria-labelledby. ' +
|
|
2208
|
+
'(axe-core empty-table-header / SC 1.3.1)',
|
|
2209
|
+
},
|
|
2210
|
+
schema: [],
|
|
2211
|
+
},
|
|
2212
|
+
create(context) {
|
|
2213
|
+
return {
|
|
2214
|
+
[h.elementWithChildrenVisitor](node) {
|
|
2215
|
+
const opening = h.getOpeningElement(node)
|
|
2216
|
+
const el = h.getElementName(opening)
|
|
2217
|
+
const role = h.getRoleValue(opening)
|
|
2218
|
+
const isTh = el === 'th'
|
|
2219
|
+
const isHeaderRole = role && TABLE_HEADER_ROLES.has(role)
|
|
2220
|
+
if (!isTh && !isHeaderRole) return
|
|
2221
|
+
if (h.hasAccessibleName(opening)) return
|
|
2222
|
+
// Check for visible text children
|
|
2223
|
+
const children = h.getChildOpeningElementsFromWrapper(node)
|
|
2224
|
+
// hasOnlyHiddenChildren checks if ALL children are aria-hidden — if it
|
|
2225
|
+
// returns true on a childless node it returns false, so we also check
|
|
2226
|
+
// whether the element has zero children with text.
|
|
2227
|
+
// Use the wrapper-level text check available for JSX/Vue.
|
|
2228
|
+
if (!h.hasOnlyHiddenChildren(opening) && !isEffectivelyEmpty(node, h)) return
|
|
2229
|
+
context.report({ node: opening, messageId: 'emptyHeader' })
|
|
2230
|
+
},
|
|
2231
|
+
}
|
|
2232
|
+
},
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
/**
|
|
2237
|
+
* Returns true if the element wrapper has no visible text content.
|
|
2238
|
+
* Works for JSX (JSXElement.children) and Vue (VElement.children).
|
|
2239
|
+
* For Angular, elementWithChildrenVisitor === elementVisitor and children
|
|
2240
|
+
* are on tmplElement.children directly.
|
|
2241
|
+
*/
|
|
2242
|
+
function isEffectivelyEmpty(wrapperNode, h) {
|
|
2243
|
+
// For frameworks where wrapper === opening (Vue, Angular), use children directly
|
|
2244
|
+
const children = wrapperNode.children ?? wrapperNode.parent?.children ?? []
|
|
2245
|
+
if (children.length === 0) return true
|
|
2246
|
+
return children.every(child => {
|
|
2247
|
+
// JSX
|
|
2248
|
+
if (child.type === 'JSXText') return child.value.trim() === ''
|
|
2249
|
+
if (child.type === 'JSXExpressionContainer') {
|
|
2250
|
+
const ex = child.expression
|
|
2251
|
+
return ex.type === 'Literal' && String(ex.value).trim() === ''
|
|
2252
|
+
}
|
|
2253
|
+
// Vue
|
|
2254
|
+
if (child.type === 'VText') return (child.value ?? '').trim() === ''
|
|
2255
|
+
// Angular
|
|
2256
|
+
if (child.constructor?.name === 'TmplAstText') return (child.value ?? '').trim() === ''
|
|
2257
|
+
// Child element — not text, assume non-empty (may have aria-label etc.)
|
|
2258
|
+
return false
|
|
2259
|
+
})
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2017
2262
|
// ─── All rules map ────────────────────────────────────────────────────────────
|
|
2018
2263
|
|
|
2019
2264
|
export const RULE_FACTORIES = {
|
|
@@ -2075,6 +2320,10 @@ export const RULE_FACTORIES = {
|
|
|
2075
2320
|
'no-summary-without-details': makeNoSummaryWithoutDetails,
|
|
2076
2321
|
'no-aria-required-on-non-form': makeNoAriaRequiredOnNonForm,
|
|
2077
2322
|
'no-input-type-invalid': makeNoInputTypeInvalid,
|
|
2323
|
+
'no-labelledby-missing-target': makeNoLabelledbyMissingTarget,
|
|
2324
|
+
'no-dynamic-content-without-live': makeNoDynamicContentWithoutLive,
|
|
2325
|
+
'form-field-multiple-labels': makeFormFieldMultipleLabels,
|
|
2326
|
+
'no-empty-table-header': makeNoEmptyTableHeader,
|
|
2078
2327
|
}
|
|
2079
2328
|
|
|
2080
2329
|
/** Build the rules map for a plugin by applying helpers to all factories. */
|
|
@@ -2120,6 +2369,10 @@ export function buildRecommendedRules(ns) {
|
|
|
2120
2369
|
[`${ns}/no-summary-without-details`]: 'error',
|
|
2121
2370
|
[`${ns}/no-aria-required-on-non-form`]: 'error',
|
|
2122
2371
|
[`${ns}/no-input-type-invalid`]: 'error',
|
|
2372
|
+
[`${ns}/no-labelledby-missing-target`]: 'error',
|
|
2373
|
+
[`${ns}/no-dynamic-content-without-live`]: 'error',
|
|
2374
|
+
[`${ns}/form-field-multiple-labels`]: 'error',
|
|
2375
|
+
[`${ns}/no-empty-table-header`]: 'error',
|
|
2123
2376
|
[`${ns}/no-button-type-missing`]: 'warn',
|
|
2124
2377
|
// warnings — strong guidance, occasional legitimate overrides
|
|
2125
2378
|
[`${ns}/no-tooltip-role-misuse`]: 'warn',
|
package/lib/ulam-rules.js
CHANGED
|
@@ -15,61 +15,115 @@
|
|
|
15
15
|
//
|
|
16
16
|
// announce() writes to a live region. Calling it directly in a component body
|
|
17
17
|
// fires on every render, spamming screen readers with repeated announcements.
|
|
18
|
-
// It must only be called inside
|
|
18
|
+
// It must only be called inside a lifecycle hook, effect, or event handler.
|
|
19
|
+
//
|
|
20
|
+
// React: useEffect / useLayoutEffect / event handlers (onClick={...} etc.)
|
|
21
|
+
// Vue: onMounted / onUpdated / watch / watchEffect / nextTick callbacks
|
|
22
|
+
// Angular: ngOnInit / ngAfterViewInit / ngOnChanges / event methods on the class
|
|
19
23
|
|
|
20
24
|
const ANNOUNCE_FNS = new Set(['announce', 'clearAnnouncements'])
|
|
21
|
-
|
|
25
|
+
|
|
26
|
+
const REACT_SAFE_CALLS = new Set([
|
|
22
27
|
'useEffect', 'useLayoutEffect', 'useInsertionEffect',
|
|
23
28
|
'useCallback', 'useMemo',
|
|
24
29
|
])
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
31
|
+
const VUE_SAFE_CALLS = new Set([
|
|
32
|
+
'onMounted', 'onUpdated', 'onBeforeMount', 'onBeforeUpdate',
|
|
33
|
+
'onActivated', 'onDeactivated', 'watch', 'watchEffect', 'watchPostEffect',
|
|
34
|
+
'watchSyncEffect', 'nextTick',
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
const ANGULAR_SAFE_METHODS = new Set([
|
|
38
|
+
'ngOnInit', 'ngAfterViewInit', 'ngAfterContentInit',
|
|
39
|
+
'ngOnChanges', 'ngDoCheck',
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
// Safe call names for each framework, merged for the combined check
|
|
43
|
+
function buildSafeCalls(framework) {
|
|
44
|
+
if (framework === 'vue') return new Set([...REACT_SAFE_CALLS, ...VUE_SAFE_CALLS])
|
|
45
|
+
if (framework === 'angular') return new Set([...REACT_SAFE_CALLS, ...ANGULAR_SAFE_METHODS])
|
|
46
|
+
return REACT_SAFE_CALLS
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makeIsInsideSafeContext(safeCalls, framework) {
|
|
50
|
+
return function isInsideSafeContext(node) {
|
|
51
|
+
let cur = node.parent
|
|
52
|
+
while (cur) {
|
|
53
|
+
// Callback passed to useEffect / onMounted / watch etc.
|
|
54
|
+
if (
|
|
55
|
+
cur.type === 'CallExpression' &&
|
|
56
|
+
cur.callee?.name &&
|
|
57
|
+
safeCalls.has(cur.callee.name)
|
|
58
|
+
) return true
|
|
59
|
+
|
|
60
|
+
// React JSX event handler: onClick={...}, onKeyDown={...}, etc.
|
|
61
|
+
if (
|
|
62
|
+
framework !== 'angular' &&
|
|
63
|
+
cur.type === 'JSXExpressionContainer' &&
|
|
64
|
+
cur.parent?.type === 'JSXAttribute' &&
|
|
65
|
+
cur.parent?.name?.name?.startsWith('on')
|
|
66
|
+
) return true
|
|
67
|
+
|
|
68
|
+
// Angular: method defined directly on the class body (e.g. handleClick() { announce() })
|
|
69
|
+
// These are always safe — Angular calls them only in response to events or lifecycle.
|
|
70
|
+
if (
|
|
71
|
+
framework === 'angular' &&
|
|
72
|
+
cur.type === 'MethodDefinition' &&
|
|
73
|
+
!ANGULAR_SAFE_METHODS.has(cur.key?.name) // lifecycle methods are caught above via CallExpression
|
|
74
|
+
) return true
|
|
75
|
+
|
|
76
|
+
// Regular function not passed as a callback — safe (event listener, async handler, etc.)
|
|
77
|
+
if (
|
|
78
|
+
cur.type === 'FunctionDeclaration' ||
|
|
79
|
+
cur.type === 'FunctionExpression' ||
|
|
80
|
+
cur.type === 'ArrowFunctionExpression'
|
|
81
|
+
) {
|
|
82
|
+
const parent = cur.parent
|
|
83
|
+
if (parent?.type !== 'CallExpression') return true
|
|
84
|
+
if (parent.callee?.name && safeCalls.has(parent.callee.name)) return true
|
|
85
|
+
// Event handler function passed as a prop / method call
|
|
86
|
+
if (parent.callee?.type === 'MemberExpression') return true
|
|
87
|
+
// Keep traversing — may be a nested callback inside an onClick
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
cur = cur.parent
|
|
56
91
|
}
|
|
57
|
-
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function makeAnnounceMessage(framework) {
|
|
97
|
+
if (framework === 'vue') {
|
|
98
|
+
return (
|
|
99
|
+
'`{{fn}}()` called outside onMounted / watch / an event handler will fire on every ' +
|
|
100
|
+
'component setup, spamming screen readers. Move it into onMounted(() => { {{fn}}(...) }) ' +
|
|
101
|
+
'or call it from an event handler. (@ulam/taho)'
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
if (framework === 'angular') {
|
|
105
|
+
return (
|
|
106
|
+
'`{{fn}}()` called outside ngOnInit / ngAfterViewInit / an event method will fire on ' +
|
|
107
|
+
'every change-detection cycle, spamming screen readers. Move it into ngOnInit() or ' +
|
|
108
|
+
'call it from an event handler method. (@ulam/taho)'
|
|
109
|
+
)
|
|
58
110
|
}
|
|
59
|
-
return
|
|
111
|
+
return (
|
|
112
|
+
'`{{fn}}()` called outside a useEffect or event handler will fire on every render, ' +
|
|
113
|
+
'spamming screen readers. Move it into useEffect(() => { {{fn}}(...) }, [dep]) ' +
|
|
114
|
+
'or call it from an event handler. (@ulam/taho)'
|
|
115
|
+
)
|
|
60
116
|
}
|
|
61
117
|
|
|
62
|
-
export function makeNoAnnounceInRender() {
|
|
118
|
+
export function makeNoAnnounceInRender({ framework = 'react' } = {}) {
|
|
119
|
+
const safeCalls = buildSafeCalls(framework)
|
|
120
|
+
const isInsideSafeContext = makeIsInsideSafeContext(safeCalls, framework)
|
|
121
|
+
|
|
63
122
|
return {
|
|
64
123
|
meta: {
|
|
65
124
|
type: 'problem',
|
|
66
|
-
docs: { description: 'Disallow announce() called directly in a component render body' },
|
|
67
|
-
messages: {
|
|
68
|
-
inRender:
|
|
69
|
-
'`{{fn}}()` called outside a useEffect or event handler will fire on every render, ' +
|
|
70
|
-
'spamming screen readers. Move it into useEffect(() => { {{fn}}(...) }, [dep]) ' +
|
|
71
|
-
'or call it from an event handler. (@ulam/taho)',
|
|
72
|
-
},
|
|
125
|
+
docs: { description: 'Disallow announce() called directly in a component render body or setup' },
|
|
126
|
+
messages: { inRender: makeAnnounceMessage(framework) },
|
|
73
127
|
schema: [],
|
|
74
128
|
},
|
|
75
129
|
create(context) {
|
|
@@ -208,12 +262,27 @@ export const ULAM_RULE_FACTORIES = {
|
|
|
208
262
|
'no-use-page-title-in-remix': makeNoUsePageTitleInRemix,
|
|
209
263
|
}
|
|
210
264
|
|
|
265
|
+
/** React plugin: all three ulam rules. */
|
|
211
266
|
export function buildUlamRules() {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
267
|
+
return {
|
|
268
|
+
'no-announce-in-render': makeNoAnnounceInRender({ framework: 'react' }),
|
|
269
|
+
'no-hash-router-in-remix': makeNoHashRouterInRemix(),
|
|
270
|
+
'no-use-page-title-in-remix': makeNoUsePageTitleInRemix(),
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Vue plugin: only the announce rule, tuned for Vue lifecycle hooks. */
|
|
275
|
+
export function buildUlamRulesVue() {
|
|
276
|
+
return {
|
|
277
|
+
'no-announce-in-render': makeNoAnnounceInRender({ framework: 'vue' }),
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Angular plugin: only the announce rule, tuned for Angular lifecycle methods. */
|
|
282
|
+
export function buildUlamRulesAngular() {
|
|
283
|
+
return {
|
|
284
|
+
'no-announce-in-render': makeNoAnnounceInRender({ framework: 'angular' }),
|
|
215
285
|
}
|
|
216
|
-
return rules
|
|
217
286
|
}
|
|
218
287
|
|
|
219
288
|
export function buildUlamRecommendedRules(ns) {
|
|
@@ -223,3 +292,10 @@ export function buildUlamRecommendedRules(ns) {
|
|
|
223
292
|
[`${ns}/no-use-page-title-in-remix`]: 'warn',
|
|
224
293
|
}
|
|
225
294
|
}
|
|
295
|
+
|
|
296
|
+
/** Recommended rules for Vue/Angular — only the announce rule applies. */
|
|
297
|
+
export function buildUlamRecommendedRulesFramework(ns) {
|
|
298
|
+
return {
|
|
299
|
+
[`${ns}/no-announce-in-render`]: 'error',
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -25,9 +25,10 @@
|
|
|
25
25
|
|
|
26
26
|
import { h } from './lib/helpers-angular.js'
|
|
27
27
|
import { buildRules, buildRecommendedRules, buildPortabilityRules } from './lib/rules.js'
|
|
28
|
+
import { buildUlamRulesAngular, buildUlamRecommendedRulesFramework } from './lib/ulam-rules.js'
|
|
28
29
|
|
|
29
30
|
const NS = '@a11yfred/neighbor'
|
|
30
|
-
const rules = buildRules(h)
|
|
31
|
+
const rules = { ...buildRules(h), ...buildUlamRulesAngular() }
|
|
31
32
|
const plugin = { meta: { name: `${NS}/angular` }, rules }
|
|
32
33
|
|
|
33
34
|
let angularA11y = null
|
|
@@ -60,6 +61,7 @@ export default {
|
|
|
60
61
|
...(angularA11y ? getAngularA11yRules(angularA11y) : {}),
|
|
61
62
|
...buildRecommendedRules(NS),
|
|
62
63
|
...buildPortabilityRules(NS),
|
|
64
|
+
...buildUlamRecommendedRulesFramework(NS),
|
|
63
65
|
},
|
|
64
66
|
},
|
|
65
67
|
},
|
package/neighbor-eslint-vue.mjs
CHANGED
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
|
|
21
21
|
import { h } from './lib/helpers-vue.js'
|
|
22
22
|
import { buildRules, buildRecommendedRules, buildPortabilityRules } from './lib/rules.js'
|
|
23
|
+
import { buildUlamRulesVue, buildUlamRecommendedRulesFramework } from './lib/ulam-rules.js'
|
|
23
24
|
|
|
24
25
|
const NS = '@a11yfred/neighbor'
|
|
25
|
-
const rules = buildRules(h)
|
|
26
|
+
const rules = { ...buildRules(h), ...buildUlamRulesVue() }
|
|
26
27
|
const plugin = { meta: { name: `${NS}/vue` }, rules }
|
|
27
28
|
|
|
28
29
|
let vueA11y = null
|
|
@@ -40,6 +41,7 @@ export default {
|
|
|
40
41
|
...(vueA11y ? vueA11y.configs['flat/recommended'].rules : {}),
|
|
41
42
|
...buildRecommendedRules(NS),
|
|
42
43
|
...buildPortabilityRules(NS),
|
|
44
|
+
...buildUlamRecommendedRulesFramework(NS),
|
|
43
45
|
},
|
|
44
46
|
},
|
|
45
47
|
},
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a11yfred/neighbor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Accessibility linting plugin for a11yfred — Stylelint (user-preference fallbacks) and ESLint (bad ARIA patterns). Won't you be my neighbor?",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"files": ["lib", "*.mjs", "LICENSE", "README.md"],
|
|
7
|
+
"files": ["lib", "*.mjs", "LICENSE", "README.md", "CHANGELOG.md"],
|
|
8
8
|
"main": "./neighbor-stylelint.mjs",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|