@a11yfred/neighbor 0.3.0 → 1.0.3
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 +59 -7
- package/CONTRIBUTING.md +10 -10
- package/README.md +196 -32
- package/RULES-CONTENT.md +296 -0
- package/RULES-CSS.md +61 -0
- package/RULES-MARKUP.md +156 -0
- package/RULES.md +55 -0
- package/lib/content-rules.js +858 -0
- package/lib/helpers-angular.js +146 -146
- package/lib/helpers-jsx.js +193 -193
- package/lib/helpers-vue.js +151 -151
- package/lib/helpers.js +37 -37
- package/lib/rules.js +2413 -2413
- package/lib/ulam-rules.js +301 -301
- package/neighbor-content.mjs +80 -0
- package/neighbor-eslint-angular.mjs +68 -68
- package/neighbor-eslint-vue.mjs +48 -48
- package/neighbor-eslint.mjs +56 -56
- package/neighbor-stylelint.mjs +282 -256
- package/package.json +30 -5
package/lib/helpers-vue.js
CHANGED
|
@@ -1,151 +1,151 @@
|
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
131
|
-
export const h = {
|
|
132
|
-
getAttr,
|
|
133
|
-
getAttrStringValue,
|
|
134
|
-
getElementName,
|
|
135
|
-
hasAttr,
|
|
136
|
-
getRoleValue,
|
|
137
|
-
hasAccessibleName,
|
|
138
|
-
isInteractiveElement,
|
|
139
|
-
hasOnlyHiddenChildren,
|
|
140
|
-
hasNewTabWarning,
|
|
141
|
-
getParent,
|
|
142
|
-
getAncestors,
|
|
143
|
-
getChildOpeningElements,
|
|
144
|
-
getClassName,
|
|
145
|
-
getInnerHtmlAttr,
|
|
146
|
-
getInnerHtmlAttrName,
|
|
147
|
-
elementVisitor: 'VElement',
|
|
148
|
-
elementWithChildrenVisitor: 'VElement',
|
|
149
|
-
getOpeningElement: (node) => node,
|
|
150
|
-
getChildOpeningElementsFromWrapper: (node) => (node.children ?? []).filter(c => c.type === 'VElement'),
|
|
151
|
-
}
|
|
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
|
+
/**
|
|
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
|
+
|
|
131
|
+
export const h = {
|
|
132
|
+
getAttr,
|
|
133
|
+
getAttrStringValue,
|
|
134
|
+
getElementName,
|
|
135
|
+
hasAttr,
|
|
136
|
+
getRoleValue,
|
|
137
|
+
hasAccessibleName,
|
|
138
|
+
isInteractiveElement,
|
|
139
|
+
hasOnlyHiddenChildren,
|
|
140
|
+
hasNewTabWarning,
|
|
141
|
+
getParent,
|
|
142
|
+
getAncestors,
|
|
143
|
+
getChildOpeningElements,
|
|
144
|
+
getClassName,
|
|
145
|
+
getInnerHtmlAttr,
|
|
146
|
+
getInnerHtmlAttrName,
|
|
147
|
+
elementVisitor: 'VElement',
|
|
148
|
+
elementWithChildrenVisitor: 'VElement',
|
|
149
|
+
getOpeningElement: (node) => node,
|
|
150
|
+
getChildOpeningElementsFromWrapper: (node) => (node.children ?? []).filter(c => c.type === 'VElement'),
|
|
151
|
+
}
|
package/lib/helpers.js
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* neighbor/lib/helpers.js
|
|
3
|
-
* Shared constants and pure data
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export const INTERACTIVE_ELEMENTS = new Set([
|
|
7
|
-
'a', 'button', 'input', 'select', 'textarea', 'details', 'summary',
|
|
8
|
-
])
|
|
9
|
-
|
|
10
|
-
export const INTERACTIVE_ROLES = new Set([
|
|
11
|
-
'button', 'link', 'checkbox', 'radio', 'textbox', 'combobox', 'listbox',
|
|
12
|
-
'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'tab', 'switch',
|
|
13
|
-
'slider', 'spinbutton', 'searchbox', 'gridcell', 'treeitem',
|
|
14
|
-
])
|
|
15
|
-
|
|
16
|
-
// Truly generic containers
|
|
17
|
-
export const GENERIC_CONTAINERS = new Set(['div', 'span', 'p'])
|
|
18
|
-
|
|
19
|
-
// Void/leaf elements that cannot have children (aria-owns on these is wrong)
|
|
20
|
-
export const VOID_ELEMENTS = new Set([
|
|
21
|
-
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link',
|
|
22
|
-
'meta', 'param', 'source', 'track', 'wbr',
|
|
23
|
-
])
|
|
24
|
-
|
|
25
|
-
export const HEADING_ELEMENTS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
|
|
26
|
-
|
|
27
|
-
export const NAV_MENU_ROLES = new Set([
|
|
28
|
-
'menu', 'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
|
|
29
|
-
])
|
|
30
|
-
|
|
31
|
-
export const ROLES_REQUIRING_NAME = new Set([
|
|
32
|
-
'region', 'dialog', 'alertdialog', 'application', 'marquee', 'searchbox',
|
|
33
|
-
])
|
|
34
|
-
|
|
35
|
-
export const FORM_ELEMENTS = new Set(['input', 'select', 'textarea'])
|
|
36
|
-
|
|
37
|
-
export const CAROUSEL_CLASS_PATTERN = /carousel|slider|slideshow|rotator/i
|
|
1
|
+
/**
|
|
2
|
+
* neighbor/lib/helpers.js
|
|
3
|
+
* Shared constants and pure data - no AST dependency.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const INTERACTIVE_ELEMENTS = new Set([
|
|
7
|
+
'a', 'button', 'input', 'select', 'textarea', 'details', 'summary',
|
|
8
|
+
])
|
|
9
|
+
|
|
10
|
+
export const INTERACTIVE_ROLES = new Set([
|
|
11
|
+
'button', 'link', 'checkbox', 'radio', 'textbox', 'combobox', 'listbox',
|
|
12
|
+
'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'tab', 'switch',
|
|
13
|
+
'slider', 'spinbutton', 'searchbox', 'gridcell', 'treeitem',
|
|
14
|
+
])
|
|
15
|
+
|
|
16
|
+
// Truly generic containers - not landmarks, not interactive, not sectioning
|
|
17
|
+
export const GENERIC_CONTAINERS = new Set(['div', 'span', 'p'])
|
|
18
|
+
|
|
19
|
+
// Void/leaf elements that cannot have children (aria-owns on these is wrong)
|
|
20
|
+
export const VOID_ELEMENTS = new Set([
|
|
21
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link',
|
|
22
|
+
'meta', 'param', 'source', 'track', 'wbr',
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
export const HEADING_ELEMENTS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
|
|
26
|
+
|
|
27
|
+
export const NAV_MENU_ROLES = new Set([
|
|
28
|
+
'menu', 'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
export const ROLES_REQUIRING_NAME = new Set([
|
|
32
|
+
'region', 'dialog', 'alertdialog', 'application', 'marquee', 'searchbox',
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
export const FORM_ELEMENTS = new Set(['input', 'select', 'textarea'])
|
|
36
|
+
|
|
37
|
+
export const CAROUSEL_CLASS_PATTERN = /carousel|slider|slideshow|rotator/i
|