@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 +53 -0
- package/CONTRIBUTING.md +115 -0
- package/README.md +153 -52
- package/lib/helpers-angular.js +15 -0
- package/lib/helpers-jsx.js +12 -0
- package/lib/helpers-vue.js +16 -0
- package/lib/rules.js +265 -10
- package/lib/ulam-rules.js +122 -46
- package/neighbor-eslint-angular.mjs +3 -1
- package/neighbor-eslint-vue.mjs +3 -1
- package/neighbor-stylelint.mjs +61 -1
- package/package.json +2 -2
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.
|
package/CONTRIBUTING.md
ADDED
|
@@ -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
|
|
package/lib/helpers-angular.js
CHANGED
|
@@ -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,
|
package/lib/helpers-jsx.js
CHANGED
|
@@ -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.)
|
package/lib/helpers-vue.js
CHANGED
|
@@ -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
|
-
|
|
2130
|
-
|
|
2131
|
-
[`${ns}/no-
|
|
2132
|
-
[`${ns}/no-
|
|
2133
|
-
[`${ns}/
|
|
2134
|
-
[`${ns}/
|
|
2135
|
-
[`${ns}/no-
|
|
2136
|
-
[`${ns}/no-
|
|
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
|
|
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
|
-
|
|
25
|
+
|
|
26
|
+
const REACT_SAFE_CALLS = new Set([
|
|
22
27
|
'useEffect', 'useLayoutEffect', 'useInsertionEffect',
|
|
23
28
|
'useCallback', 'useMemo',
|
|
24
29
|
])
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
},
|
package/neighbor-eslint-vue.mjs
CHANGED
|
@@ -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/neighbor-stylelint.mjs
CHANGED
|
@@ -193,4 +193,64 @@ const noOutlineNone = {
|
|
|
193
193
|
meta: noOutlineNoneMeta,
|
|
194
194
|
};
|
|
195
195
|
|
|
196
|
-
|
|
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.
|
|
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",
|