@a11yfred/neighbor 0.2.0 → 1.0.1

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.
@@ -1,193 +1,193 @@
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 uppercasereturn 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
- /** 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
-
161
- /** Wrap all JSX helpers into the standard `h` interface expected by rules. */
162
- export const h = {
163
- getAttr,
164
- getAttrStringValue,
165
- getElementName,
166
- hasAttr,
167
- getRoleValue,
168
- hasAccessibleName,
169
- isInteractiveElement,
170
- hasOnlyHiddenChildren: (openingElement) => {
171
- const el = openingElement.parent
172
- return el?.type === 'JSXElement' ? hasOnlyHiddenChildren(el) : false
173
- },
174
- hasNewTabWarning,
175
- getParent,
176
- getAncestors,
177
- getChildOpeningElements,
178
- getClassName,
179
- getInnerHtmlAttr,
180
- getInnerHtmlAttrName,
181
- // Node visitor key for ESLintwhat AST node type fires the rule
182
- elementVisitor: 'JSXOpeningElement',
183
- // For rules that need to visit element+children (group-without-name, etc.)
184
- elementWithChildrenVisitor: 'JSXElement',
185
- /** For elementWithChildrenVisitor: extract the opening element from the wrapper node */
186
- getOpeningElement: (node) => node.openingElement,
187
- /** For elementWithChildrenVisitor: get direct child opening elements */
188
- getChildOpeningElementsFromWrapper: (node) => {
189
- return (node.children ?? [])
190
- .filter(c => c.type === 'JSXElement')
191
- .map(c => c.openingElement)
192
- },
193
- }
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
+ /** 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
+
161
+ /** Wrap all JSX helpers into the standard `h` interface expected by rules. */
162
+ export const h = {
163
+ getAttr,
164
+ getAttrStringValue,
165
+ getElementName,
166
+ hasAttr,
167
+ getRoleValue,
168
+ hasAccessibleName,
169
+ isInteractiveElement,
170
+ hasOnlyHiddenChildren: (openingElement) => {
171
+ const el = openingElement.parent
172
+ return el?.type === 'JSXElement' ? hasOnlyHiddenChildren(el) : false
173
+ },
174
+ hasNewTabWarning,
175
+ getParent,
176
+ getAncestors,
177
+ getChildOpeningElements,
178
+ getClassName,
179
+ getInnerHtmlAttr,
180
+ getInnerHtmlAttrName,
181
+ // Node visitor key for ESLint - what AST node type fires the rule
182
+ elementVisitor: 'JSXOpeningElement',
183
+ // For rules that need to visit element+children (group-without-name, etc.)
184
+ elementWithChildrenVisitor: 'JSXElement',
185
+ /** For elementWithChildrenVisitor: extract the opening element from the wrapper node */
186
+ getOpeningElement: (node) => node.openingElement,
187
+ /** For elementWithChildrenVisitor: get direct child opening elements */
188
+ getChildOpeningElementsFromWrapper: (node) => {
189
+ return (node.children ?? [])
190
+ .filter(c => c.type === 'JSXElement')
191
+ .map(c => c.openingElement)
192
+ },
193
+ }