@a11yfred/neighbor 0.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/LICENSE +21 -0
- package/README.md +204 -0
- package/lib/helpers-angular.js +131 -0
- package/lib/helpers-jsx.js +181 -0
- package/lib/helpers-vue.js +135 -0
- package/lib/helpers.js +37 -0
- package/lib/rules.js +2158 -0
- package/lib/ulam-rules.js +225 -0
- package/neighbor-eslint-angular.mjs +66 -0
- package/neighbor-eslint-vue.mjs +46 -0
- package/neighbor-eslint.mjs +56 -0
- package/neighbor-stylelint.mjs +196 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mikey Ilagan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# @a11yfred/neighbor
|
|
2
|
+
|
|
3
|
+
Neighbor is an accessibility linting plugin for ESLint and Stylelint that builds on jsx-a11y. It looks to cover gaps: bad ARIA patterns, live region misuse, missing names on roles, and CSS that removes focus indicators. It also brings that coverage to Vue and Angular, where jsx-a11y does not apply.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install --save-dev @a11yfred/neighbor
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Entry points
|
|
12
|
+
|
|
13
|
+
| Import | Use for |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `@a11yfred/neighbor/eslint` | React / JSX |
|
|
16
|
+
| `@a11yfred/neighbor/eslint-vue` | Vue SFCs |
|
|
17
|
+
| `@a11yfred/neighbor/eslint-angular` | Angular templates |
|
|
18
|
+
| `@a11yfred/neighbor` | Stylelint — CSS user-preference fallbacks |
|
|
19
|
+
|
|
20
|
+
## Setup
|
|
21
|
+
|
|
22
|
+
### React / JSX
|
|
23
|
+
|
|
24
|
+
Neighbor works alongside `eslint-plugin-jsx-a11y`. Install both.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install --save-dev eslint-plugin-jsx-a11y @a11yfred/neighbor
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
// eslint.config.js
|
|
32
|
+
import neighbor from '@a11yfred/neighbor/eslint'
|
|
33
|
+
|
|
34
|
+
export default [
|
|
35
|
+
{
|
|
36
|
+
plugins: { ...neighbor.configs.recommended.plugins },
|
|
37
|
+
rules: { ...neighbor.configs.recommended.rules },
|
|
38
|
+
},
|
|
39
|
+
]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Vue
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm install --save-dev eslint-plugin-vuejs-accessibility @a11yfred/neighbor
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
import neighbor from '@a11yfred/neighbor/eslint-vue'
|
|
50
|
+
|
|
51
|
+
export default [
|
|
52
|
+
{
|
|
53
|
+
plugins: { ...neighbor.configs.recommended.plugins },
|
|
54
|
+
rules: { ...neighbor.configs.recommended.rules },
|
|
55
|
+
},
|
|
56
|
+
]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Angular
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npm install --save-dev @angular-eslint/eslint-plugin-template @a11yfred/neighbor
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
import neighbor from '@a11yfred/neighbor/eslint-angular'
|
|
67
|
+
|
|
68
|
+
export default [
|
|
69
|
+
{
|
|
70
|
+
plugins: { ...neighbor.configs.recommended.plugins },
|
|
71
|
+
rules: { ...neighbor.configs.recommended.rules },
|
|
72
|
+
},
|
|
73
|
+
]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Stylelint
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
// .stylelintrc.json
|
|
80
|
+
{
|
|
81
|
+
"plugins": ["@a11yfred/neighbor"],
|
|
82
|
+
"rules": {
|
|
83
|
+
"ulam/user-preferences": true,
|
|
84
|
+
"ulam/no-outline-none": true
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Peer dependencies
|
|
90
|
+
|
|
91
|
+
| Peer | Required for |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `eslint >= 8` | Any ESLint entry point |
|
|
94
|
+
| `eslint-plugin-jsx-a11y >= 6` | React config — neighbor extends it, not replaces it |
|
|
95
|
+
| `eslint-plugin-vuejs-accessibility >= 2` | Vue config |
|
|
96
|
+
| `@angular-eslint/eslint-plugin-template >= 17` | Angular config |
|
|
97
|
+
| `stylelint >= 14` | Stylelint config |
|
|
98
|
+
|
|
99
|
+
All peers are optional. Install only what your project uses.
|
|
100
|
+
|
|
101
|
+
## What neighbor adds
|
|
102
|
+
|
|
103
|
+
### ESLint — React / JSX
|
|
104
|
+
|
|
105
|
+
Base: `eslint-plugin-jsx-a11y`
|
|
106
|
+
|
|
107
|
+
| What it checks | Rule | WCAG SC |
|
|
108
|
+
| --- | --- | --- |
|
|
109
|
+
| `aria-disabled` keeps element reachable | `prefer-aria-disabled` | 2.1.1 |
|
|
110
|
+
| `aria-disabled` must block click handler | `no-unblocked-aria-disabled` | 2.1.1 |
|
|
111
|
+
| `aria-label` on a generic element with no role | `no-aria-label-on-generic` | 1.3.1 |
|
|
112
|
+
| `role="alert"` overuse | `warn-role-alert` | 4.1.3 |
|
|
113
|
+
| `aria-live="assertive"` outside `role="alert"` | `no-assertive-live-overuse` | 4.1.3 |
|
|
114
|
+
| `role="dialog"` requires accessible name | `no-roles-without-name` | 4.1.2 |
|
|
115
|
+
| `role="group"` with form controls requires name | `no-group-without-name` | 1.3.1 |
|
|
116
|
+
| `role="tooltip"` requires `id` on the tooltip | `no-tooltip-role-misuse` | 4.1.2 |
|
|
117
|
+
| `role="application"` disables AT browse mode | `no-application-role` | — |
|
|
118
|
+
| `role="grid"` almost always wrong | `no-grid-role` | — |
|
|
119
|
+
| `role="menu"` on nav triggers wrong AT mode | `no-menu-role-on-nav` | 2.1.1 |
|
|
120
|
+
| `role="presentation"` on a focusable element | `no-presentation-on-focusable` | 2.1.1 |
|
|
121
|
+
| `role="log"` must not contain interactive children | `no-log-with-interactive-children` | 4.1.2 |
|
|
122
|
+
| `role="img"` requires accessible name | `no-image-role-without-name` | 4.1.2 |
|
|
123
|
+
| `role="tab"` requires `aria-selected` | `no-tabs-without-structure` | 4.1.2 |
|
|
124
|
+
| `role="tab"` should declare `aria-controls` | `no-tab-without-controls` | 4.1.2 |
|
|
125
|
+
| `role="combobox"` requires `aria-expanded` | `no-combobox-without-expanded` | 4.1.2 |
|
|
126
|
+
| `role="slider"` requires value range attributes | `no-slider-without-range` | 4.1.2 |
|
|
127
|
+
| `role="spinbutton"` requires value range attributes | `no-spinbutton-without-range` | 4.1.2 |
|
|
128
|
+
| `role="listbox"` requires `role="option"` children | `no-listbox-without-option` | 4.1.2 |
|
|
129
|
+
| `role="tree"` requires `role="treeitem"` children | `no-tree-without-treeitem` | 4.1.2 |
|
|
130
|
+
| `role="feed"` requires `role="article"` children | `no-feed-without-article` | 4.1.2 |
|
|
131
|
+
| `aria-hidden="true"` + `role="none"` is redundant | `no-redundant-aria-hidden-with-presentation` | — |
|
|
132
|
+
| `aria-roledescription` does not translate | `no-aria-roledescription` | — |
|
|
133
|
+
| `aria-readonly` has poor AT support | `no-aria-readonly` | — |
|
|
134
|
+
| `aria-owns` on a void element | `no-aria-owns-on-void` | 4.1.2 |
|
|
135
|
+
| `aria-activedescendant` requires a non-empty static ID | `no-aria-activedescendant-without-id` | 4.1.2 |
|
|
136
|
+
| `aria-required` only valid on form-control roles | `no-aria-required-on-non-form` | 4.1.2 |
|
|
137
|
+
| `<a>` with only aria-hidden children | `no-aria-hidden-in-link` | 4.1.2 |
|
|
138
|
+
| `<button>` with only aria-hidden children | `no-empty-button` | 4.1.2 |
|
|
139
|
+
| `<input>` placeholder used as sole label | `no-placeholder-only` | 1.3.1 |
|
|
140
|
+
| `<input>` with invalid type value | `no-input-type-invalid` | 1.3.5 |
|
|
141
|
+
| `<button>` in a form missing explicit type | `no-button-type-missing` | HTML spec |
|
|
142
|
+
| `<summary>` outside `<details>` | `no-summary-without-details` | 2.1.1 |
|
|
143
|
+
| `<a href="#">` used as a button | `no-href-hash` | 2.1.1 |
|
|
144
|
+
| `target="_blank"` without new-tab disclosure | `no-target-blank-without-label` | 3.2.2 |
|
|
145
|
+
| Duplicate `id` breaks ARIA relationships | `no-duplicate-id` | 1.3.1 |
|
|
146
|
+
| Positive `tabIndex` breaks tab order | `no-positive-tabindex` | 2.4.3 |
|
|
147
|
+
| Heading inside an interactive element | `no-heading-inside-interactive` | 4.1.2 |
|
|
148
|
+
| `title` attribute as the only accessible name | `no-title-as-label` | 2.4.6 |
|
|
149
|
+
| `<video>` or `<audio autoplay>` without controls | `no-autoplay-without-controls` | 1.4.2 |
|
|
150
|
+
| Mouse-only events without keyboard equivalents | `no-mouse-only-events` | 2.1.1 |
|
|
151
|
+
|
|
152
|
+
### ESLint — Vue SFCs
|
|
153
|
+
|
|
154
|
+
Base: `eslint-plugin-vuejs-accessibility`
|
|
155
|
+
|
|
156
|
+
Neighbor adds everything in the React table above, adapted for Vue's AST, plus:
|
|
157
|
+
|
|
158
|
+
| What it checks | Rule | WCAG SC |
|
|
159
|
+
| --- | --- | --- |
|
|
160
|
+
| Ambiguous link text ("click here", "read more") | `no-anchor-ambiguous-text` | 2.4.4 |
|
|
161
|
+
| `<a>` with no content and no accessible name | `no-anchor-no-content` | 4.1.2 |
|
|
162
|
+
| Invalid ARIA attribute values | `no-invalid-aria-prop-value` | 4.1.2 |
|
|
163
|
+
| Invalid `autocomplete` token | `no-autocomplete-invalid` | 1.3.5 |
|
|
164
|
+
| Heading with no content | `no-heading-no-content` | 2.4.6 |
|
|
165
|
+
| `<iframe>` without `title` | `no-iframe-no-title` | 4.1.2 |
|
|
166
|
+
| Alt text contains "image", "photo" | `no-img-redundant-alt` | 1.1.1 |
|
|
167
|
+
| `accessKey` attribute | `no-access-key` | 2.1.4 |
|
|
168
|
+
| `scope` on `<td>` (only valid on `<th>`) | `no-scope-on-td` | 1.3.1 |
|
|
169
|
+
|
|
170
|
+
### ESLint — Angular templates
|
|
171
|
+
|
|
172
|
+
Base: `@angular-eslint/eslint-plugin-template`
|
|
173
|
+
|
|
174
|
+
Neighbor adds the same rule set as Vue, adapted for Angular's template AST.
|
|
175
|
+
|
|
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.
|
|
177
|
+
|
|
178
|
+
### Stylelint — CSS
|
|
179
|
+
|
|
180
|
+
| Rule | What it checks |
|
|
181
|
+
| --- | --- |
|
|
182
|
+
| `ulam/user-preferences` | Warns when motion, transparency, or alpha colors are used without `@media (prefers-*)` fallbacks |
|
|
183
|
+
| `ulam/no-outline-none` | Disallows bare `outline: none` or `outline: 0` outside `:focus` selectors |
|
|
184
|
+
|
|
185
|
+
## Rule severity
|
|
186
|
+
|
|
187
|
+
| Severity | Meaning |
|
|
188
|
+
| --- | --- |
|
|
189
|
+
| `error` | Definite AT breakage or HTML spec violation |
|
|
190
|
+
| `warn` | Strong guidance, occasional legitimate overrides exist |
|
|
191
|
+
|
|
192
|
+
All rules can be overridden in your config.
|
|
193
|
+
|
|
194
|
+
## See also
|
|
195
|
+
|
|
196
|
+
- [RULES.md](RULES.md) — full rule list with descriptions
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
MIT
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
*Built with help from Claude.*
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* neighbor/lib/helpers-angular.js
|
|
3
|
+
* Helpers for Angular template AST via @angular-eslint/template-parser.
|
|
4
|
+
*
|
|
5
|
+
* Parser: @angular-eslint/eslint-plugin-template provides its own rule format.
|
|
6
|
+
* Node type for elements: 'Element$1' (from @angular-eslint/template-parser, the class is
|
|
7
|
+
* TmplAstElement). The node has:
|
|
8
|
+
* node.name — tag name string
|
|
9
|
+
* node.attributes — TmplAstTextAttribute[] (static attrs)
|
|
10
|
+
* node.inputs — TmplAstBoundAttribute[] (property bindings)
|
|
11
|
+
* node.children — TmplAstNode[]
|
|
12
|
+
*
|
|
13
|
+
* We only inspect static (non-bound) attributes for ARIA checks.
|
|
14
|
+
* Bound attributes ([aria-label]="expr") cannot be statically analyzed.
|
|
15
|
+
*
|
|
16
|
+
* Angular ESLint rule visitor uses standard ESLint `create(context)` but the
|
|
17
|
+
* parser emits different node types. The visitor key from @angular-eslint is
|
|
18
|
+
* 'Element$1' (the internal class name exposed via the parser).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
INTERACTIVE_ELEMENTS,
|
|
23
|
+
INTERACTIVE_ROLES,
|
|
24
|
+
} from './helpers.js'
|
|
25
|
+
|
|
26
|
+
/** Find a static TmplAstTextAttribute by name. */
|
|
27
|
+
export function getAttr(tmplElement, name) {
|
|
28
|
+
return (tmplElement.attributes ?? []).find(a => a.name === name) ?? null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** TmplAstTextAttribute.value is a plain string. */
|
|
32
|
+
export function getAttrStringValue(attr) {
|
|
33
|
+
if (!attr) return null
|
|
34
|
+
return typeof attr.value === 'string' ? attr.value || null : null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getElementName(tmplElement) {
|
|
38
|
+
const raw = tmplElement.name ?? ''
|
|
39
|
+
if (!raw) return null
|
|
40
|
+
// Angular custom component selectors are typically kebab-case (app-button),
|
|
41
|
+
// but guard against PascalCase as well to avoid native element name collisions.
|
|
42
|
+
if (raw[0] !== raw[0].toLowerCase()) return null
|
|
43
|
+
return raw.toLowerCase()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hasAttr(tmplElement, name) {
|
|
47
|
+
return getAttr(tmplElement, name) !== null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getRoleValue(tmplElement) {
|
|
51
|
+
return getAttrStringValue(getAttr(tmplElement, 'role'))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function hasAccessibleName(tmplElement) {
|
|
55
|
+
return hasAttr(tmplElement, 'aria-label') || hasAttr(tmplElement, 'aria-labelledby')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isInteractiveElement(tmplElement) {
|
|
59
|
+
const el = getElementName(tmplElement)
|
|
60
|
+
if (el && INTERACTIVE_ELEMENTS.has(el)) return true
|
|
61
|
+
const role = getRoleValue(tmplElement)
|
|
62
|
+
return !!(role && INTERACTIVE_ROLES.has(role))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function hasOnlyHiddenChildren(tmplElement) {
|
|
66
|
+
const children = tmplElement.children ?? []
|
|
67
|
+
if (children.length === 0) return false
|
|
68
|
+
return children.every(child => {
|
|
69
|
+
if (child.constructor?.name === 'TmplAstText') return (child.value ?? '').trim() === ''
|
|
70
|
+
if (child.constructor?.name === 'TmplAstElement') {
|
|
71
|
+
return (child.attributes ?? []).some(a => a.name === 'aria-hidden')
|
|
72
|
+
}
|
|
73
|
+
return false
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const NEW_TAB_PATTERN = /new.tab|new.window|opens in/i
|
|
78
|
+
|
|
79
|
+
function collectAngularText(node) {
|
|
80
|
+
if (!node) return ''
|
|
81
|
+
if (node.constructor?.name === 'TmplAstText') return node.value ?? ''
|
|
82
|
+
if (node.constructor?.name === 'TmplAstElement') return (node.children ?? []).map(collectAngularText).join('')
|
|
83
|
+
return ''
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function hasNewTabWarning(tmplElement) {
|
|
87
|
+
const labelVal = (getAttrStringValue(getAttr(tmplElement, 'aria-label')) ?? '').toLowerCase()
|
|
88
|
+
if (NEW_TAB_PATTERN.test(labelVal)) return true
|
|
89
|
+
const childText = (tmplElement.children ?? []).map(collectAngularText).join('')
|
|
90
|
+
return NEW_TAB_PATTERN.test(childText)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function getParent(tmplElement) {
|
|
94
|
+
// @angular-eslint/template-parser does not set parent on AST nodes —
|
|
95
|
+
// ancestor walking is not available without tracking the stack ourselves.
|
|
96
|
+
// Rules needing ancestor traversal are skipped for Angular.
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function* getAncestors(_tmplElement) { /* not available */ }
|
|
101
|
+
|
|
102
|
+
export function* getChildOpeningElements(tmplElement) {
|
|
103
|
+
for (const child of tmplElement.children ?? []) {
|
|
104
|
+
if (child.constructor?.name === 'TmplAstElement') yield child
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function getClassName(tmplElement) {
|
|
109
|
+
return getAttrStringValue(getAttr(tmplElement, 'class'))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const h = {
|
|
113
|
+
getAttr,
|
|
114
|
+
getAttrStringValue,
|
|
115
|
+
getElementName,
|
|
116
|
+
hasAttr,
|
|
117
|
+
getRoleValue,
|
|
118
|
+
hasAccessibleName,
|
|
119
|
+
isInteractiveElement,
|
|
120
|
+
hasOnlyHiddenChildren,
|
|
121
|
+
hasNewTabWarning,
|
|
122
|
+
getParent,
|
|
123
|
+
getAncestors,
|
|
124
|
+
getChildOpeningElements,
|
|
125
|
+
getClassName,
|
|
126
|
+
elementVisitor: 'Element$1',
|
|
127
|
+
elementWithChildrenVisitor: 'Element$1',
|
|
128
|
+
getOpeningElement: (node) => node,
|
|
129
|
+
getChildOpeningElementsFromWrapper: (node) =>
|
|
130
|
+
(node.children ?? []).filter(c => c.constructor?.name === 'TmplAstElement'),
|
|
131
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* neighbor/lib/helpers-jsx.js
|
|
3
|
+
* Helpers for React/JSX AST (espree + @babel/eslint-parser).
|
|
4
|
+
* Nodes: JSXOpeningElement, JSXAttribute, JSXElement, JSXText, JSXExpressionContainer
|
|
5
|
+
*
|
|
6
|
+
* All helpers conform to the standard interface expected by lib/rules.js:
|
|
7
|
+
* getAttr(node, name) → attribute node or null
|
|
8
|
+
* getAttrStringValue(attr) → string | null
|
|
9
|
+
* getElementName(node) → lowercase string | null
|
|
10
|
+
* hasAttr(node, name) → boolean
|
|
11
|
+
* getRoleValue(node) → string | null
|
|
12
|
+
* hasAccessibleName(node) → boolean
|
|
13
|
+
* isInteractiveElement(node) → boolean
|
|
14
|
+
* hasOnlyHiddenChildren(jsxOrVNode) → boolean (for link-hidden check)
|
|
15
|
+
* getParent(node) → parent opening-element-like node | null
|
|
16
|
+
* getAncestors(node) → iterable of opening-element-like nodes, root-ward
|
|
17
|
+
* getChildOpeningElements(node) → iterable of opening-element-like nodes (direct children)
|
|
18
|
+
* getClassName(node) → string | null
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
INTERACTIVE_ELEMENTS,
|
|
23
|
+
INTERACTIVE_ROLES,
|
|
24
|
+
} from './helpers.js'
|
|
25
|
+
|
|
26
|
+
export function getAttrStringValue(attr) {
|
|
27
|
+
if (!attr) return null
|
|
28
|
+
const val = attr.value
|
|
29
|
+
if (!val) return null
|
|
30
|
+
if (val.type === 'Literal') return String(val.value)
|
|
31
|
+
if (val.type === 'JSXExpressionContainer' && val.expression.type === 'Literal')
|
|
32
|
+
return String(val.expression.value)
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getAttr(openingElement, name) {
|
|
37
|
+
return openingElement.attributes.find(
|
|
38
|
+
a => a.type === 'JSXAttribute' && a.name?.name === name
|
|
39
|
+
) ?? null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getElementName(openingElement) {
|
|
43
|
+
const n = openingElement.name
|
|
44
|
+
if (typeof n.name === 'string') {
|
|
45
|
+
// Custom React components start with uppercase — return null so rules
|
|
46
|
+
// that check element names don't treat them as native HTML elements.
|
|
47
|
+
if (n.name[0] !== n.name[0].toLowerCase()) return null
|
|
48
|
+
return n.name.toLowerCase()
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function hasAttr(openingElement, name) {
|
|
54
|
+
return getAttr(openingElement, name) !== null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getRoleValue(openingElement) {
|
|
58
|
+
return getAttrStringValue(getAttr(openingElement, 'role'))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function hasAccessibleName(openingElement) {
|
|
62
|
+
return hasAttr(openingElement, 'aria-label') || hasAttr(openingElement, 'aria-labelledby')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isInteractiveElement(openingElement) {
|
|
66
|
+
const el = getElementName(openingElement)
|
|
67
|
+
if (el && INTERACTIVE_ELEMENTS.has(el)) return true
|
|
68
|
+
const role = getRoleValue(openingElement)
|
|
69
|
+
return !!(role && INTERACTIVE_ROLES.has(role))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Returns true if every child of the JSXElement is aria-hidden or empty text. */
|
|
73
|
+
export function hasOnlyHiddenChildren(jsxElement) {
|
|
74
|
+
const children = jsxElement.children ?? []
|
|
75
|
+
if (children.length === 0) return false
|
|
76
|
+
return children.every(child => {
|
|
77
|
+
if (child.type === 'JSXText') return child.value.trim() === ''
|
|
78
|
+
if (child.type === 'JSXExpressionContainer')
|
|
79
|
+
return child.expression.type === 'Literal' && String(child.expression.value).trim() === ''
|
|
80
|
+
if (child.type === 'JSXElement') {
|
|
81
|
+
return child.openingElement.attributes.some(
|
|
82
|
+
a => a.type === 'JSXAttribute' && a.name?.name === 'aria-hidden'
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
return false
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Returns the parent opening element (JSXOpeningElement), or null. */
|
|
90
|
+
export function getParent(openingElement) {
|
|
91
|
+
// openingElement → JSXElement → parent JSXElement → openingElement
|
|
92
|
+
const parent = openingElement.parent?.parent
|
|
93
|
+
if (parent?.type === 'JSXElement') return parent.openingElement
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Returns an iterable of ancestor opening elements, root-ward. */
|
|
98
|
+
export function* getAncestors(openingElement) {
|
|
99
|
+
let cur = getParent(openingElement)
|
|
100
|
+
while (cur) {
|
|
101
|
+
yield cur
|
|
102
|
+
cur = getParent(cur)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Returns an iterable of direct-child opening elements. */
|
|
107
|
+
export function* getChildOpeningElements(openingElement) {
|
|
108
|
+
const jsxElement = openingElement.parent
|
|
109
|
+
if (jsxElement?.type !== 'JSXElement') return
|
|
110
|
+
for (const child of jsxElement.children ?? []) {
|
|
111
|
+
if (child.type === 'JSXElement') yield child.openingElement
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function getClassName(openingElement) {
|
|
116
|
+
const classAttr = getAttr(openingElement, 'className') ?? getAttr(openingElement, 'class')
|
|
117
|
+
return getAttrStringValue(classAttr)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const NEW_TAB_PATTERN = /new.tab|new.window|opens in/i
|
|
121
|
+
|
|
122
|
+
/** Recursively collect all text content from a JSX subtree. */
|
|
123
|
+
function collectText(node) {
|
|
124
|
+
if (!node) return ''
|
|
125
|
+
if (node.type === 'JSXText') return node.value
|
|
126
|
+
if (node.type === 'JSXElement') {
|
|
127
|
+
return (node.children ?? []).map(collectText).join('')
|
|
128
|
+
}
|
|
129
|
+
if (node.type === 'JSXExpressionContainer') {
|
|
130
|
+
const ex = node.expression
|
|
131
|
+
if (ex.type === 'Literal') return String(ex.value)
|
|
132
|
+
if (ex.type === 'TemplateLiteral') return ex.quasis.map(q => q.value.raw).join('')
|
|
133
|
+
}
|
|
134
|
+
return ''
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Returns true if the link communicates "opens in new tab" via:
|
|
139
|
+
* - aria-label containing the phrase
|
|
140
|
+
* - any descendant text content containing the phrase (e.g. sr-only span)
|
|
141
|
+
*/
|
|
142
|
+
export function hasNewTabWarning(openingElement) {
|
|
143
|
+
const labelVal = (getAttrStringValue(getAttr(openingElement, 'aria-label')) ?? '').toLowerCase()
|
|
144
|
+
if (NEW_TAB_PATTERN.test(labelVal)) return true
|
|
145
|
+
const jsxElement = openingElement.parent
|
|
146
|
+
if (jsxElement?.type !== 'JSXElement') return false
|
|
147
|
+
const childText = collectText(jsxElement)
|
|
148
|
+
return NEW_TAB_PATTERN.test(childText)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Wrap all JSX helpers into the standard `h` interface expected by rules. */
|
|
152
|
+
export const h = {
|
|
153
|
+
getAttr,
|
|
154
|
+
getAttrStringValue,
|
|
155
|
+
getElementName,
|
|
156
|
+
hasAttr,
|
|
157
|
+
getRoleValue,
|
|
158
|
+
hasAccessibleName,
|
|
159
|
+
isInteractiveElement,
|
|
160
|
+
hasOnlyHiddenChildren: (openingElement) => {
|
|
161
|
+
const el = openingElement.parent
|
|
162
|
+
return el?.type === 'JSXElement' ? hasOnlyHiddenChildren(el) : false
|
|
163
|
+
},
|
|
164
|
+
hasNewTabWarning,
|
|
165
|
+
getParent,
|
|
166
|
+
getAncestors,
|
|
167
|
+
getChildOpeningElements,
|
|
168
|
+
getClassName,
|
|
169
|
+
// Node visitor key for ESLint — what AST node type fires the rule
|
|
170
|
+
elementVisitor: 'JSXOpeningElement',
|
|
171
|
+
// For rules that need to visit element+children (group-without-name, etc.)
|
|
172
|
+
elementWithChildrenVisitor: 'JSXElement',
|
|
173
|
+
/** For elementWithChildrenVisitor: extract the opening element from the wrapper node */
|
|
174
|
+
getOpeningElement: (node) => node.openingElement,
|
|
175
|
+
/** For elementWithChildrenVisitor: get direct child opening elements */
|
|
176
|
+
getChildOpeningElementsFromWrapper: (node) => {
|
|
177
|
+
return (node.children ?? [])
|
|
178
|
+
.filter(c => c.type === 'JSXElement')
|
|
179
|
+
.map(c => c.openingElement)
|
|
180
|
+
},
|
|
181
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* neighbor/lib/helpers-vue.js
|
|
3
|
+
* Helpers for Vue SFC AST via vue-eslint-parser.
|
|
4
|
+
* Nodes: VElement (has startTag.attributes), VAttribute, VLiteral, VExpressionContainer
|
|
5
|
+
*
|
|
6
|
+
* vue-eslint-parser docs: https://github.com/vuejs/vue-eslint-parser/blob/master/docs/ast.md
|
|
7
|
+
* Rule visitor: 'VElement' (fired for every HTML element in the template)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
INTERACTIVE_ELEMENTS,
|
|
12
|
+
INTERACTIVE_ROLES,
|
|
13
|
+
} from './helpers.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* In vue-eslint-parser, an attribute is a VAttribute node.
|
|
17
|
+
* Plain attribute: { type: 'VAttribute', key: { name: 'role' }, value: { type: 'VLiteral', value: '"dialog"' } }
|
|
18
|
+
* v-bind shorthand: { type: 'VAttribute', directive: true, key: { argument: { name: 'role' } }, value: VExpressionContainer }
|
|
19
|
+
* We only read static (non-directive) attributes for ARIA checks.
|
|
20
|
+
*/
|
|
21
|
+
export function getAttr(vElement, name) {
|
|
22
|
+
const attrs = vElement.startTag?.attributes ?? []
|
|
23
|
+
return attrs.find(a => !a.directive && a.key?.name === name) ?? null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getAttrStringValue(attr) {
|
|
27
|
+
if (!attr) return null
|
|
28
|
+
// VLiteral.value includes surrounding quotes — strip them
|
|
29
|
+
const raw = attr.value?.value
|
|
30
|
+
if (typeof raw === 'string') return raw.replace(/^["']|["']$/g, '')
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getElementName(vElement) {
|
|
35
|
+
const raw = vElement.rawName ?? vElement.name ?? ''
|
|
36
|
+
if (!raw) return null
|
|
37
|
+
// Vue custom components can be PascalCase (MyButton) or kebab-case (my-button).
|
|
38
|
+
// PascalCase names would lowercase to a native element name collision (e.g. Button → button).
|
|
39
|
+
// Return null for PascalCase so rules don't treat custom components as native elements.
|
|
40
|
+
if (raw[0] !== raw[0].toLowerCase()) return null
|
|
41
|
+
return raw.toLowerCase()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function hasAttr(vElement, name) {
|
|
45
|
+
return getAttr(vElement, name) !== null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getRoleValue(vElement) {
|
|
49
|
+
return getAttrStringValue(getAttr(vElement, 'role'))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function hasAccessibleName(vElement) {
|
|
53
|
+
return hasAttr(vElement, 'aria-label') || hasAttr(vElement, 'aria-labelledby')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isInteractiveElement(vElement) {
|
|
57
|
+
const el = getElementName(vElement)
|
|
58
|
+
if (el && INTERACTIVE_ELEMENTS.has(el)) return true
|
|
59
|
+
const role = getRoleValue(vElement)
|
|
60
|
+
return !!(role && INTERACTIVE_ROLES.has(role))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Returns true if every child element is aria-hidden or the element has no visible text children. */
|
|
64
|
+
export function hasOnlyHiddenChildren(vElement) {
|
|
65
|
+
const children = vElement.children ?? []
|
|
66
|
+
if (children.length === 0) return false
|
|
67
|
+
return children.every(child => {
|
|
68
|
+
if (child.type === 'VText') return child.value.trim() === ''
|
|
69
|
+
if (child.type === 'VElement') {
|
|
70
|
+
const attrs = child.startTag?.attributes ?? []
|
|
71
|
+
return attrs.some(a => !a.directive && a.key?.name === 'aria-hidden')
|
|
72
|
+
}
|
|
73
|
+
return false
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const NEW_TAB_PATTERN = /new.tab|new.window|opens in/i
|
|
78
|
+
|
|
79
|
+
function collectVueText(node) {
|
|
80
|
+
if (!node) return ''
|
|
81
|
+
if (node.type === 'VText') return node.value ?? ''
|
|
82
|
+
if (node.type === 'VElement') return (node.children ?? []).map(collectVueText).join('')
|
|
83
|
+
return ''
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function hasNewTabWarning(vElement) {
|
|
87
|
+
const labelVal = (getAttrStringValue(getAttr(vElement, 'aria-label')) ?? '').toLowerCase()
|
|
88
|
+
if (NEW_TAB_PATTERN.test(labelVal)) return true
|
|
89
|
+
const childText = (vElement.children ?? []).map(collectVueText).join('')
|
|
90
|
+
return NEW_TAB_PATTERN.test(childText)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Parent VElement, or null. */
|
|
94
|
+
export function getParent(vElement) {
|
|
95
|
+
const p = vElement.parent
|
|
96
|
+
return p?.type === 'VElement' ? p : null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function* getAncestors(vElement) {
|
|
100
|
+
let cur = getParent(vElement)
|
|
101
|
+
while (cur) {
|
|
102
|
+
yield cur
|
|
103
|
+
cur = getParent(cur)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function* getChildOpeningElements(vElement) {
|
|
108
|
+
for (const child of vElement.children ?? []) {
|
|
109
|
+
if (child.type === 'VElement') yield child
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getClassName(vElement) {
|
|
114
|
+
return getAttrStringValue(getAttr(vElement, 'class'))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const h = {
|
|
118
|
+
getAttr,
|
|
119
|
+
getAttrStringValue,
|
|
120
|
+
getElementName,
|
|
121
|
+
hasAttr,
|
|
122
|
+
getRoleValue,
|
|
123
|
+
hasAccessibleName,
|
|
124
|
+
isInteractiveElement,
|
|
125
|
+
hasOnlyHiddenChildren,
|
|
126
|
+
hasNewTabWarning,
|
|
127
|
+
getParent,
|
|
128
|
+
getAncestors,
|
|
129
|
+
getChildOpeningElements,
|
|
130
|
+
getClassName,
|
|
131
|
+
elementVisitor: 'VElement',
|
|
132
|
+
elementWithChildrenVisitor: 'VElement',
|
|
133
|
+
getOpeningElement: (node) => node,
|
|
134
|
+
getChildOpeningElementsFromWrapper: (node) => (node.children ?? []).filter(c => c.type === 'VElement'),
|
|
135
|
+
}
|