@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 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
+ }