@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 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
 
@@ -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,
@@ -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.)
@@ -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 useEffect, useLayoutEffect, or event handlers.
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
- const SAFE_PARENT_CALLS = new Set([
25
+
26
+ const REACT_SAFE_CALLS = new Set([
22
27
  'useEffect', 'useLayoutEffect', 'useInsertionEffect',
23
28
  'useCallback', 'useMemo',
24
29
  ])
25
30
 
26
- function isInsideSafeContext(node) {
27
- let cur = node.parent
28
- while (cur) {
29
- // Arrow or function expression passed as argument to useEffect etc.
30
- if (
31
- cur.type === 'CallExpression' &&
32
- cur.callee?.name &&
33
- SAFE_PARENT_CALLS.has(cur.callee.name)
34
- ) return true
35
- // Event handler: onClick={...}, onKeyDown={...}, etc.
36
- if (
37
- cur.type === 'JSXExpressionContainer' &&
38
- cur.parent?.type === 'JSXAttribute' &&
39
- cur.parent?.name?.name?.startsWith('on')
40
- ) return true
41
- // Regular function (not a component body) — event listeners, async handlers
42
- if (
43
- cur.type === 'FunctionDeclaration' ||
44
- cur.type === 'FunctionExpression' ||
45
- cur.type === 'ArrowFunctionExpression'
46
- ) {
47
- // Standalone function (not a callback) — safe
48
- const parent = cur.parent
49
- if (parent?.type !== 'CallExpression') return true
50
- // It's a callback — only safe if the callee is a known safe hook
51
- if (parent.callee?.name && SAFE_PARENT_CALLS.has(parent.callee.name)) return true
52
- // Could be an event handler function passed as a prop — allow it
53
- if (parent.callee?.type === 'MemberExpression') return true
54
- // Nested callback (e.g. setState inside onClick) — keep traversing upward
55
- // Don't bail here; let the loop continue to find the enclosing context
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
- cur = cur.parent
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 false
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
- const rules = {}
213
- for (const [name, factory] of Object.entries(ULAM_RULE_FACTORIES)) {
214
- rules[name] = factory()
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
  },
@@ -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.1.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",