@a11yfred/neighbor 0.1.0 → 0.3.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,53 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0 — 2026-05-12
4
+
5
+ ### New rule
6
+
7
+ | Rule | What it catches |
8
+ |---|---|
9
+ | `ulam/no-forced-colors-none` | `forced-color-adjust: none` inside `@media (forced-colors)` — actively opts out of Windows High Contrast Mode |
10
+
11
+ ### Severity changes
12
+
13
+ 10 rules moved from `warn` to `off` in the recommended config — they flag real problems but are too noisy for most codebases by default. All remain available to opt in individually:
14
+
15
+ `no-application-role`, `no-grid-role`, `no-aria-roledescription`, `no-aria-readonly`, `no-tab-without-controls`, `no-href-hash`, `warn-role-alert`, `prefer-aria-disabled`, `no-target-blank-without-label`, `no-dialog-without-close`
16
+
17
+ `no-tooltip-role-misuse` and `no-menu-role-on-nav` remain on as warns.
18
+
19
+ ### Docs
20
+
21
+ - WCAG SC and HTML spec links added throughout README and RULES.md
22
+ - CONTRIBUTING.md, PR template, and issue templates added
23
+ - README table of contents added
24
+ - @ulam described as a JavaScript framework (not React-based)
25
+
26
+ ---
27
+
28
+ ## 0.2.0 — 2026-05-12
29
+
30
+ ### New rules
31
+
32
+ | Rule | What it catches |
33
+ |---|---|
34
+ | `no-labelledby-missing-target` | `aria-labelledby`/`describedby`/`controls`/`owns`/`activedescendant` referencing an `id` that doesn't exist in the file |
35
+ | `no-dynamic-content-without-live` | `dangerouslySetInnerHTML` / `v-html` / `[innerHTML]` on an element outside a live region |
36
+ | `form-field-multiple-labels` | Multiple `<label for="…">` elements targeting the same input |
37
+ | `no-empty-table-header` | `<th>` or `role="columnheader"/"rowheader"` with no accessible text |
38
+
39
+ All four rules run on React, Vue, and Angular.
40
+
41
+ ### Extended rules
42
+
43
+ **`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.
44
+
45
+ ### Setup improvements
46
+
47
+ README now includes correct parser snippets for Vue and Angular, and separate setup sections for Remix 2 and Remix 3.
48
+
49
+ ---
50
+
51
+ ## 0.1.0 — 2026-04-30
52
+
53
+ Initial release.
@@ -0,0 +1,115 @@
1
+ # Contributing to @a11yfred/neighbor
2
+
3
+ Neighbor is maintained by [@a11yfred](https://github.com/a11yfred). Contributions are welcome from the accessibility community — practitioners, AT users, spec readers, and people who have found a gap in existing tooling.
4
+
5
+ ## What belongs here
6
+
7
+ A rule belongs in neighbor if it meets all three criteria:
8
+
9
+ 1. **Statically detectable** — the violation can be identified from markup/code alone, without a browser or AT. Runtime-only failures (color contrast, focus order in the DOM) belong in axe-core.
10
+ 2. **Not already covered** — jsx-a11y, vuejs-accessibility, @angular-eslint/template, or axe-core doesn't already flag it in a recommended config.
11
+ 3. **Expert-backed** — there's a WCAG SC, ARIA spec citation, or clear consensus from accessibility practitioners (Roselli, O'Hara, Lauke, Sutton, Pickering, Groves, Eggert, etc.).
12
+
13
+ If you're unsure, open an issue before writing a rule. A brief description and a source is enough to start a conversation.
14
+
15
+ ## What doesn't belong here
16
+
17
+ - Rules that require runtime information (computed styles, DOM layout, AT output)
18
+ - Rules already in jsx-a11y recommended — neighbor extends it, not replaces it
19
+ - Opinionated style rules without a clear accessibility impact
20
+ - Rules with very high false-positive rates on real codebases (see the rejected rules list in [RULES.md](RULES.md))
21
+
22
+ ## Setup
23
+
24
+ ```bash
25
+ git clone https://github.com/a11yfred/neighbor.git
26
+ cd neighbor
27
+ npm install
28
+ ```
29
+
30
+ No build step. Rules are plain ES modules — edit and run ESLint directly.
31
+
32
+ ## How rules are structured
33
+
34
+ All ESLint rules live in [`lib/rules.js`](lib/rules.js) as factory functions:
35
+
36
+ ```js
37
+ export function makeMyNewRule(h) {
38
+ return {
39
+ meta: {
40
+ type: 'problem', // 'problem' | 'suggestion'
41
+ docs: { description: '...' },
42
+ messages: { myMessage: '...' },
43
+ schema: [],
44
+ },
45
+ create(context) {
46
+ return {
47
+ [h.elementVisitor](node) {
48
+ // h adapts the rule to React/Vue/Angular ASTs
49
+ },
50
+ }
51
+ },
52
+ }
53
+ }
54
+ ```
55
+
56
+ The `h` adapter gives you a uniform interface across all three frameworks:
57
+
58
+ | Helper | Returns |
59
+ |---|---|
60
+ | `h.getAttr(node, name)` | attribute node or `null` |
61
+ | `h.getAttrStringValue(attr)` | string or `null` (null for dynamic expressions) |
62
+ | `h.getElementName(node)` | lowercase tag name, or `null` for custom components |
63
+ | `h.hasAttr(node, name)` | boolean |
64
+ | `h.getRoleValue(node)` | role string or `null` |
65
+ | `h.hasAccessibleName(node)` | boolean — checks `aria-label` / `aria-labelledby` |
66
+ | `h.isInteractiveElement(node)` | boolean |
67
+ | `h.getParent(node)` | parent element node or `null` |
68
+ | `h.getAncestors(node)` | iterable of ancestor element nodes, root-ward |
69
+ | `h.getChildOpeningElements(node)` | iterable of direct child element nodes |
70
+ | `h.getInnerHtmlAttr(node)` | `dangerouslySetInnerHTML` / `v-html` / `[innerHTML]` node or `null` |
71
+ | `h.elementVisitor` | AST node type string for `create()` visitor key |
72
+ | `h.elementWithChildrenVisitor` | visitor key for rules that need child access |
73
+
74
+ **Angular caveat:** `getParent()` and `getAncestors()` return `null`/nothing for Angular — the template parser doesn't attach parent pointers. Rules that require ancestor walking should degrade gracefully (skip the check, don't throw).
75
+
76
+ After writing your factory:
77
+
78
+ 1. Add it to `RULE_FACTORIES` at the bottom of `lib/rules.js`
79
+ 2. Add it to `buildRecommendedRules()` at the appropriate severity (`'error'` / `'warn'` / `'off'`)
80
+ 3. If it's Vue/Angular-only (porting a jsx-a11y gap), add it to `buildPortabilityRules()` instead
81
+
82
+ Stylelint rules live in [`neighbor-stylelint.mjs`](neighbor-stylelint.mjs) and use the PostCSS AST directly. See the existing rules for the pattern.
83
+
84
+ ## Severity guidance
85
+
86
+ | Severity | When to use |
87
+ |---|---|
88
+ | `error` | Unambiguous AT breakage — a phantom control, broken name computation, HTML spec violation. No legitimate override. |
89
+ | `warn` | Strong guidance with a clear accessibility basis, but real codebases occasionally have justified exceptions. |
90
+ | `off` | Real problem, but fires too often on legitimate patterns to be on by default. Make it available; let teams opt in. |
91
+
92
+ When in doubt, start at `warn`. It's easier to promote a rule to `error` than to demote it after people have already configured it.
93
+
94
+ ## Commit style
95
+
96
+ ```
97
+ feat: add no-my-new-rule (short description)
98
+ fix: correct false positive in no-existing-rule
99
+ docs: update RULES.md for no-my-new-rule
100
+ ```
101
+
102
+ No ticket numbers required.
103
+
104
+ ## Opening a PR
105
+
106
+ Use the PR template. The key things:
107
+
108
+ - **What problem does this flag?** Link a WCAG SC, ARIA spec section, or expert source.
109
+ - **Why can't axe-core catch it at runtime instead?** (If it can, it probably belongs there.)
110
+ - **What are the false-positive cases?** Be honest — we'd rather move a rule to `off` than reject it.
111
+ - **Does it degrade gracefully for Angular?** (Parent walking unavailable.)
112
+
113
+ ## Questions
114
+
115
+ Open an issue or reach out in the [Web A11y Slack](https://web-a11y.slack.com). The `#tools` channel is a good place to discuss rule ideas before writing code.
package/README.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
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
4
 
5
+ Some rules are specific to **@ulam** — an upcoming JavaScript framework by the same author. Those rules are prefixed `no-announce-in-render`, `no-hash-router-in-remix`, and `no-use-page-title-in-remix`. They activate only when @ulam-related imports are detected and are harmless in non-@ulam projects.
6
+
7
+ ## Contents
8
+
9
+ - [Install](#install)
10
+ - [Entry points](#entry-points)
11
+ - [Setup](#setup)
12
+ - [React / JSX](#react--jsx)
13
+ - [Remix 2](#remix-2)
14
+ - [Remix 3](#remix-3)
15
+ - [Vue](#vue)
16
+ - [Angular](#angular)
17
+ - [Stylelint](#stylelint)
18
+ - [Peer dependencies](#peer-dependencies)
19
+ - [What neighbor adds](#what-neighbor-adds)
20
+ - [ESLint — React / JSX](#eslint--react--jsx)
21
+ - [ESLint — Remix 2](#eslint--remix-2)
22
+ - [ESLint — Vue SFCs](#eslint--vue-sfcs)
23
+ - [ESLint — Angular templates](#eslint--angular-templates)
24
+ - [Stylelint — CSS](#stylelint--css)
25
+ - [Rule severity](#rule-severity)
26
+ - [Contributing](CONTRIBUTING.md)
27
+ - [See also](#see-also)
28
+ - [License](#license)
29
+
5
30
  ## Install
6
31
 
7
32
  ```bash
@@ -12,7 +37,7 @@ npm install --save-dev @a11yfred/neighbor
12
37
 
13
38
  | Import | Use for |
14
39
  | --- | --- |
15
- | `@a11yfred/neighbor/eslint` | React / JSX |
40
+ | `@a11yfred/neighbor/eslint` | React / JSX, Remix 2 |
16
41
  | `@a11yfred/neighbor/eslint-vue` | Vue SFCs |
17
42
  | `@a11yfred/neighbor/eslint-angular` | Angular templates |
18
43
  | `@a11yfred/neighbor` | Stylelint — CSS user-preference fallbacks |
@@ -39,6 +64,51 @@ export default [
39
64
  ]
40
65
  ```
41
66
 
67
+ ### Remix 2
68
+
69
+ 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.
70
+
71
+ ```bash
72
+ npm install --save-dev eslint-plugin-jsx-a11y @a11yfred/neighbor
73
+ ```
74
+
75
+ ```js
76
+ // eslint.config.js
77
+ import neighbor from '@a11yfred/neighbor/eslint'
78
+
79
+ export default [
80
+ {
81
+ files: ['**/*.{js,jsx,ts,tsx}'],
82
+ plugins: { ...neighbor.configs.recommended.plugins },
83
+ rules: { ...neighbor.configs.recommended.rules },
84
+ },
85
+ ]
86
+ ```
87
+
88
+ ### Remix 3
89
+
90
+ 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.
91
+
92
+ If you are using React with Remix 3:
93
+
94
+ ```bash
95
+ npm install --save-dev eslint-plugin-jsx-a11y @a11yfred/neighbor
96
+ ```
97
+
98
+ ```js
99
+ import neighbor from '@a11yfred/neighbor/eslint'
100
+
101
+ export default [
102
+ {
103
+ files: ['**/*.{js,jsx,ts,tsx}'],
104
+ plugins: { ...neighbor.configs.recommended.plugins },
105
+ rules: { ...neighbor.configs.recommended.rules },
106
+ },
107
+ ]
108
+ ```
109
+
110
+ 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.
111
+
42
112
  ### Vue
43
113
 
44
114
  ```bash
@@ -46,10 +116,13 @@ npm install --save-dev eslint-plugin-vuejs-accessibility @a11yfred/neighbor
46
116
  ```
47
117
 
48
118
  ```js
119
+ import vueParser from 'vue-eslint-parser'
49
120
  import neighbor from '@a11yfred/neighbor/eslint-vue'
50
121
 
51
122
  export default [
52
123
  {
124
+ files: ['**/*.vue'],
125
+ languageOptions: { parser: vueParser },
53
126
  plugins: { ...neighbor.configs.recommended.plugins },
54
127
  rules: { ...neighbor.configs.recommended.rules },
55
128
  },
@@ -63,13 +136,24 @@ npm install --save-dev @angular-eslint/eslint-plugin-template @a11yfred/neighbor
63
136
  ```
64
137
 
65
138
  ```js
139
+ import angularTemplateParser from '@angular-eslint/template-parser'
66
140
  import neighbor from '@a11yfred/neighbor/eslint-angular'
67
141
 
68
142
  export default [
69
143
  {
144
+ files: ['**/*.html'],
145
+ languageOptions: { parser: angularTemplateParser },
70
146
  plugins: { ...neighbor.configs.recommended.plugins },
71
147
  rules: { ...neighbor.configs.recommended.rules },
72
148
  },
149
+ {
150
+ // Also lint component TypeScript files for the announce() rule
151
+ files: ['**/*.ts'],
152
+ plugins: { '@a11yfred/neighbor': neighbor },
153
+ rules: {
154
+ '@a11yfred/neighbor/no-announce-in-render': 'error',
155
+ },
156
+ },
73
157
  ]
74
158
  ```
75
159
 
@@ -106,81 +190,97 @@ Base: `eslint-plugin-jsx-a11y`
106
190
 
107
191
  | What it checks | Rule | WCAG SC |
108
192
  | --- | --- | --- |
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 |
193
+ | `aria-disabled` keeps element reachable | `prefer-aria-disabled` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
194
+ | `aria-disabled` must block click handler | `no-unblocked-aria-disabled` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
195
+ | `aria-label` on a generic element with no role | `no-aria-label-on-generic` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
196
+ | `role="alert"` overuse | `warn-role-alert` | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
197
+ | `aria-live="assertive"` outside `role="alert"` | `no-assertive-live-overuse` | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
198
+ | `role="dialog"` requires accessible name | `no-roles-without-name` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
199
+ | `role="group"` with form controls requires name | `no-group-without-name` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
200
+ | `role="tooltip"` requires `id` on the tooltip | `no-tooltip-role-misuse` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
117
201
  | `role="application"` disables AT browse mode | `no-application-role` | — |
118
202
  | `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 |
203
+ | `role="menu"` on nav triggers wrong AT mode | `no-menu-role-on-nav` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
204
+ | `role="presentation"` on a focusable element | `no-presentation-on-focusable` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
205
+ | `role="log"` must not contain interactive children | `no-log-with-interactive-children` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
206
+ | `role="img"` requires accessible name | `no-image-role-without-name` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
207
+ | `role="tab"` requires `aria-selected` | `no-tabs-without-structure` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
208
+ | `role="tab"` should declare `aria-controls` | `no-tab-without-controls` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
209
+ | `role="combobox"` requires `aria-expanded` | `no-combobox-without-expanded` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
210
+ | `role="slider"` requires value range attributes | `no-slider-without-range` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
211
+ | `role="spinbutton"` requires value range attributes | `no-spinbutton-without-range` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
212
+ | `role="listbox"` requires `role="option"` children | `no-listbox-without-option` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
213
+ | `role="tree"` requires `role="treeitem"` children | `no-tree-without-treeitem` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
214
+ | `role="feed"` requires `role="article"` children | `no-feed-without-article` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
131
215
  | `aria-hidden="true"` + `role="none"` is redundant | `no-redundant-aria-hidden-with-presentation` | — |
132
216
  | `aria-roledescription` does not translate | `no-aria-roledescription` | — |
133
217
  | `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 |
218
+ | `aria-owns` on a void element | `no-aria-owns-on-void` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
219
+ | `aria-activedescendant` requires a non-empty static ID | `no-aria-activedescendant-without-id` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
220
+ | `aria-required` only valid on form-control roles | `no-aria-required-on-non-form` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
221
+ | `<a>` with only aria-hidden children | `no-aria-hidden-in-link` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
222
+ | `<button>` with only aria-hidden children | `no-empty-button` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
223
+ | `<input>` placeholder used as sole label | `no-placeholder-only` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
224
+ | `<input>` with invalid type value | `no-input-type-invalid` | [1.3.5](https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose) |
225
+ | `<button>` in a form missing explicit type | `no-button-type-missing` | [HTML spec](https://html.spec.whatwg.org/multipage/form-elements.html#the-button-element) |
226
+ | `<summary>` outside `<details>` | `no-summary-without-details` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
227
+ | `<a href="#">` used as a button | `no-href-hash` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
228
+ | `target="_blank"` without new-tab disclosure | `no-target-blank-without-label` | [3.2.2](https://www.w3.org/WAI/WCAG21/Understanding/on-input) |
229
+ | Duplicate `id` breaks ARIA relationships | `no-duplicate-id` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
230
+ | Positive `tabIndex` breaks tab order | `no-positive-tabindex` | [2.4.3](https://www.w3.org/WAI/WCAG21/Understanding/focus-order) |
231
+ | Heading inside an interactive element | `no-heading-inside-interactive` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
232
+ | `title` attribute as the only accessible name | `no-title-as-label` | [2.4.6](https://www.w3.org/WAI/WCAG21/Understanding/headings-and-labels) |
233
+ | `<video>` or `<audio autoplay>` without controls | `no-autoplay-without-controls` | [1.4.2](https://www.w3.org/WAI/WCAG21/Understanding/audio-control) |
234
+ | Mouse-only events without keyboard equivalents | `no-mouse-only-events` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
235
+ | `aria-labelledby`/`describedby` references missing `id` | `no-labelledby-missing-target` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
236
+ | `dangerouslySetInnerHTML` outside a live region | `no-dynamic-content-without-live` | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
237
+ | Multiple `<label>` elements for the same control | `form-field-multiple-labels` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
238
+ | `<th>` or header role with no accessible text | `no-empty-table-header` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
239
+ | `announce()` called in component render body | `no-announce-in-render` | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
240
+
241
+ ### ESLint — Remix 2
242
+
243
+ Same as React / JSX. Additional rules activate when Remix imports are detected in the file being linted:
244
+
245
+ | What it checks | Rule | Severity |
246
+ | --- | --- | --- |
247
+ | `@ulam` hash router alongside `react-router` | `no-hash-router-in-remix` | warn |
248
+ | `usePageTitle()` alongside `react-router` | `no-use-page-title-in-remix` | warn |
151
249
 
152
250
  ### ESLint — Vue SFCs
153
251
 
154
252
  Base: `eslint-plugin-vuejs-accessibility`
155
253
 
156
- Neighbor adds everything in the React table above, adapted for Vue's AST, plus:
254
+ Neighbor adds everything in the React table above, adapted for Vue's AST (`v-html` instead of `dangerouslySetInnerHTML`), plus:
157
255
 
158
256
  | What it checks | Rule | WCAG SC |
159
257
  | --- | --- | --- |
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 |
258
+ | Ambiguous link text ("click here", "read more") | `no-anchor-ambiguous-text` | [2.4.4](https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context) |
259
+ | `<a>` with no content and no accessible name | `no-anchor-no-content` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
260
+ | Invalid ARIA attribute values | `no-invalid-aria-prop-value` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
261
+ | Invalid `autocomplete` token | `no-autocomplete-invalid` | [1.3.5](https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose) |
262
+ | Heading with no content | `no-heading-no-content` | [2.4.6](https://www.w3.org/WAI/WCAG21/Understanding/headings-and-labels) |
263
+ | `<iframe>` without `title` | `no-iframe-no-title` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
264
+ | Alt text contains "image", "photo" | `no-img-redundant-alt` | [1.1.1](https://www.w3.org/WAI/WCAG21/Understanding/non-text-content) |
265
+ | `accessKey` attribute | `no-access-key` | [2.1.4](https://www.w3.org/WAI/WCAG21/Understanding/character-key-shortcuts) |
266
+ | `scope` on `<td>` (only valid on `<th>`) | `no-scope-on-td` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
267
+ | `announce()` called outside `onMounted`/`watch`/handler | `no-announce-in-render` | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
169
268
 
170
269
  ### ESLint — Angular templates
171
270
 
172
271
  Base: `@angular-eslint/eslint-plugin-template`
173
272
 
174
- Neighbor adds the same rule set as Vue, adapted for Angular's template AST.
273
+ 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
274
 
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.
275
+ **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
276
 
178
277
  ### Stylelint — CSS
179
278
 
180
279
  | Rule | What it checks |
181
280
  | --- | --- |
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 |
281
+ | `ulam/user-preferences` | Warns when motion, transparency, or alpha colors are used without `@media (prefers-*)` fallbacks — [SC 1.4.3](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum) / [SC 2.3.3](https://www.w3.org/WAI/WCAG21/Understanding/animation-from-interactions) |
282
+ | `ulam/no-outline-none` | Disallows bare `outline: none` or `outline: 0` outside `:focus` selectors — [SC 2.4.7](https://www.w3.org/WAI/WCAG21/Understanding/focus-visible) |
283
+ | `ulam/no-forced-colors-none` | Disallows `forced-color-adjust: none` inside `@media (forced-colors)` — opts out of Windows High Contrast Mode — [SC 1.4.11](https://www.w3.org/WAI/WCAG21/Understanding/non-text-contrast) |
184
284
 
185
285
  ## Rule severity
186
286
 
@@ -188,6 +288,7 @@ Neighbor adds the same rule set as Vue, adapted for Angular's template AST.
188
288
  | --- | --- |
189
289
  | `error` | Definite AT breakage or HTML spec violation |
190
290
  | `warn` | Strong guidance, occasional legitimate overrides exist |
291
+ | `off` | Available but disabled — too noisy for most codebases, enable if it fits your project |
191
292
 
192
293
  All rules can be overridden in your config.
193
294
 
@@ -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,20 +2369,26 @@ 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',
2126
- [`${ns}/no-application-role`]: 'warn',
2127
- [`${ns}/no-grid-role`]: 'warn',
2128
2379
  [`${ns}/no-menu-role-on-nav`]: 'warn',
2129
- [`${ns}/no-aria-roledescription`]: 'warn',
2130
- [`${ns}/no-aria-readonly`]: 'warn',
2131
- [`${ns}/no-tab-without-controls`]: 'warn',
2132
- [`${ns}/no-href-hash`]: 'warn',
2133
- [`${ns}/warn-role-alert`]: 'warn',
2134
- [`${ns}/prefer-aria-disabled`]: 'warn',
2135
- [`${ns}/no-target-blank-without-label`]: 'warn',
2136
- [`${ns}/no-dialog-without-close`]: 'warn',
2380
+ // off by default — available to opt in, but noisy in real codebases
2381
+ // enable individually if the pattern applies to your project
2382
+ [`${ns}/no-application-role`]: 'off',
2383
+ [`${ns}/no-grid-role`]: 'off',
2384
+ [`${ns}/no-aria-roledescription`]: 'off',
2385
+ [`${ns}/no-aria-readonly`]: 'off',
2386
+ [`${ns}/no-tab-without-controls`]: 'off',
2387
+ [`${ns}/no-href-hash`]: 'off',
2388
+ [`${ns}/warn-role-alert`]: 'off',
2389
+ [`${ns}/prefer-aria-disabled`]: 'off',
2390
+ [`${ns}/no-target-blank-without-label`]: 'off',
2391
+ [`${ns}/no-dialog-without-close`]: 'off',
2137
2392
  }
2138
2393
  }
2139
2394
 
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
  },
@@ -193,4 +193,64 @@ const noOutlineNone = {
193
193
  meta: noOutlineNoneMeta,
194
194
  };
195
195
 
196
- export default [userPreferences, noOutlineNone];
196
+ // ─── Rule: ulam/no-forced-colors-none ────────────────────────────────────────
197
+ // forced-color-adjust: none inside @media (forced-colors) actively opts out of
198
+ // Windows High Contrast Mode and other forced-colors user settings. For users
199
+ // who depend on forced colors this is their last resort for viewing content —
200
+ // overriding it is a serious accessibility regression.
201
+ //
202
+ // Legitimate narrow exceptions exist (e.g. color pickers where all swatches
203
+ // would collapse to CanvasText). Those should be scoped tightly to the specific
204
+ // element, not a whole section, and are typically few enough to suppress inline.
205
+ //
206
+ // Ref: Sarah Higley — forced-color-adjust: none (sarahmhigley.com)
207
+ // Adrian Roselli — WHCM and System Colors (adrianroselli.com)
208
+ // WCAG SC 1.4.11 Non-text Contrast; SC 1.4.3 Contrast (Minimum)
209
+
210
+ const noForcedColorsNoneRuleName = 'ulam/no-forced-colors-none';
211
+
212
+ const noForcedColorsNoneMessages = {
213
+ none: (selector) =>
214
+ `forced-color-adjust: none on "${selector}" inside @media (forced-colors) opts out of ` +
215
+ `Windows High Contrast Mode, removing all forced-color overrides for these elements. ` +
216
+ `Users who depend on forced colors lose visibility entirely. ` +
217
+ `Remove forced-color-adjust: none, or scope it to the narrowest possible element ` +
218
+ `(e.g. a color-picker swatch) and add a comment explaining why. ` +
219
+ `(Higley / Roselli — WCAG SC 1.4.11 / SC 1.4.3)`,
220
+ };
221
+
222
+ const noForcedColorsNoneMeta = { url: 'https://github.com/a11yfred/neighbor' };
223
+
224
+ /** Returns true if the node is directly inside a @media (forced-colors) block. */
225
+ function insideForcedColorsMedia(node) {
226
+ let current = node.parent;
227
+ while (current) {
228
+ if (
229
+ current.type === 'atrule' &&
230
+ current.name === 'media' &&
231
+ /forced-colors/.test(current.params)
232
+ ) return true;
233
+ current = current.parent;
234
+ }
235
+ return false;
236
+ }
237
+
238
+ /** @type {import('stylelint').Rule} */
239
+ function noForcedColorsNoneRule(_primaryOption) {
240
+ return (root, result) => {
241
+ root.walkDecls(/^forced-color-adjust$/i, (decl) => {
242
+ if (decl.value.trim().toLowerCase() !== 'none') return;
243
+ if (!insideForcedColorsMedia(decl)) return;
244
+ const selector = decl.parent?.selector ?? decl.parent?.name ?? '(unknown)';
245
+ decl.warn(result, noForcedColorsNoneMessages.none(selector), { rule: noForcedColorsNoneRuleName });
246
+ });
247
+ };
248
+ }
249
+
250
+ const noForcedColorsNone = {
251
+ ruleName: noForcedColorsNoneRuleName,
252
+ rule: noForcedColorsNoneRule,
253
+ meta: noForcedColorsNoneMeta,
254
+ };
255
+
256
+ export default [userPreferences, noOutlineNone, noForcedColorsNone];
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@a11yfred/neighbor",
3
- "version": "0.1.0",
3
+ "version": "0.3.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", "CONTRIBUTING.md"],
8
8
  "main": "./neighbor-stylelint.mjs",
9
9
  "repository": {
10
10
  "type": "git",