@a11yfred/neighbor 1.0.1 → 1.0.4
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 +9 -0
- package/README.md +100 -6
- package/lib/rules.js +12 -0
- package/neighbor-stylelint.mjs +36 -11
- package/package.json +14 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.4 - 2026-05-13
|
|
4
|
+
|
|
5
|
+
### Bug fixes
|
|
6
|
+
|
|
7
|
+
- **`no-placeholder-only`** — no longer false-positives on `<input>` elements inside a `role="search"` landmark with an accessible name. The input is correctly labeled at the group level in that pattern.
|
|
8
|
+
- **`no-dialog-without-close`** — no longer false-positives on `role="dialog"` elements whose children are passed dynamically (`{children}`). When a close button cannot be statically detected, the rule skips rather than reporting.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
3
12
|
## 1.0.0 - 2026-05-12
|
|
4
13
|
|
|
5
14
|
### Breaking change
|
package/README.md
CHANGED
|
@@ -9,7 +9,8 @@ Some rules are specific to **@ulam** - an upcoming JavaScript framework by the
|
|
|
9
9
|
- [Install](#install)
|
|
10
10
|
- [Entry points](#entry-points)
|
|
11
11
|
- [Setup](#setup)
|
|
12
|
-
- [
|
|
12
|
+
- [Vanilla JS / plain HTML (no framework)](#vanilla-js--plain-html-no-framework)
|
|
13
|
+
- [React / JSX](#react-jsx)
|
|
13
14
|
- [Remix 2](#remix-2)
|
|
14
15
|
- [Remix 3](#remix-3)
|
|
15
16
|
- [Vue](#vue)
|
|
@@ -18,11 +19,11 @@ Some rules are specific to **@ulam** - an upcoming JavaScript framework by the
|
|
|
18
19
|
- [Content linting](#content-linting)
|
|
19
20
|
- [Peer dependencies](#peer-dependencies)
|
|
20
21
|
- [What neighbor adds](#what-neighbor-adds)
|
|
21
|
-
- [ESLint - React / JSX](#eslint
|
|
22
|
-
- [ESLint - Remix 2](#eslint
|
|
23
|
-
- [ESLint - Vue SFCs](#eslint
|
|
24
|
-
- [ESLint - Angular templates](#eslint
|
|
25
|
-
- [Stylelint - CSS](#stylelint
|
|
22
|
+
- [ESLint - React / JSX](#eslint-react-jsx)
|
|
23
|
+
- [ESLint - Remix 2](#eslint-remix-2)
|
|
24
|
+
- [ESLint - Vue SFCs](#eslint-vue-sfcs)
|
|
25
|
+
- [ESLint - Angular templates](#eslint-angular-templates)
|
|
26
|
+
- [Stylelint - CSS](#stylelint-css)
|
|
26
27
|
- [Content linter](#content-linter)
|
|
27
28
|
- [Rule severity](#rule-severity)
|
|
28
29
|
- [Contributing](CONTRIBUTING.md)
|
|
@@ -48,6 +49,99 @@ npm install --save-dev @a11yfred/neighbor
|
|
|
48
49
|
|
|
49
50
|
## Setup
|
|
50
51
|
|
|
52
|
+
### Vanilla JS / plain HTML (no framework)
|
|
53
|
+
|
|
54
|
+
If you write plain JavaScript with no JSX, React, Vue, or Angular, only the **Stylelint CSS rules** and the **content linter** apply. The ESLint markup rules require a component framework — they lint JSX or template syntax that plain JS does not have.
|
|
55
|
+
|
|
56
|
+
**What you get:**
|
|
57
|
+
|
|
58
|
+
| Plugin | What it checks |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| Stylelint (`@a11yfred/neighbor`) | CSS: bare `outline: none`, forced-colors opt-out, motion/transparency without `prefers-*` fallbacks |
|
|
61
|
+
| Content linter (`@a11yfred/neighbor/content`) | JS strings: ableist language, vague CTAs, unexplained abbreviations, idioms, all-caps prose |
|
|
62
|
+
|
|
63
|
+
**Stylelint setup** (CSS only, no framework needed):
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npm install --save-dev stylelint stylelint-config-standard @a11yfred/neighbor
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
// .stylelintrc.json
|
|
71
|
+
{
|
|
72
|
+
"extends": ["stylelint-config-standard"],
|
|
73
|
+
"plugins": ["@a11yfred/neighbor"],
|
|
74
|
+
"rules": {
|
|
75
|
+
"neighbor/no-outline-none": true,
|
|
76
|
+
"neighbor/no-forced-colors-none": true,
|
|
77
|
+
"neighbor/user-preferences": true
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Run it:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npx stylelint "**/*.css"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Content linter setup** (plain JS string literals, no framework needed):
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npm install --save-dev eslint @a11yfred/neighbor
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
// eslint.config.js (ESLint flat config, ESLint >= 8)
|
|
96
|
+
import neighborContent from '@a11yfred/neighbor/content'
|
|
97
|
+
|
|
98
|
+
export default [
|
|
99
|
+
{
|
|
100
|
+
files: ['**/*.js'],
|
|
101
|
+
plugins: { ...neighborContent.configs.recommended.plugins },
|
|
102
|
+
rules: { ...neighborContent.configs.recommended.rules },
|
|
103
|
+
},
|
|
104
|
+
]
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Run it:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
npx eslint src/
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Both together:**
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
// eslint.config.js
|
|
117
|
+
import neighborContent from '@a11yfred/neighbor/content'
|
|
118
|
+
|
|
119
|
+
export default [
|
|
120
|
+
{
|
|
121
|
+
files: ['**/*.js'],
|
|
122
|
+
plugins: { ...neighborContent.configs.recommended.plugins },
|
|
123
|
+
rules: { ...neighborContent.configs.recommended.rules },
|
|
124
|
+
},
|
|
125
|
+
]
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
// .stylelintrc.json
|
|
130
|
+
{
|
|
131
|
+
"extends": ["stylelint-config-standard"],
|
|
132
|
+
"plugins": ["@a11yfred/neighbor"],
|
|
133
|
+
"rules": {
|
|
134
|
+
"neighbor/no-outline-none": true,
|
|
135
|
+
"neighbor/no-forced-colors-none": true,
|
|
136
|
+
"neighbor/user-preferences": true
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The ESLint markup rules (`@a11yfred/neighbor/eslint` and variants) require JSX or a component template syntax. They will not produce useful output on plain HTML or vanilla JS files and should be skipped.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
51
145
|
### React / JSX
|
|
52
146
|
|
|
53
147
|
Neighbor works alongside `eslint-plugin-jsx-a11y`. Install both.
|
package/lib/rules.js
CHANGED
|
@@ -845,6 +845,11 @@ export function makeNoPlaceholderOnly(h) {
|
|
|
845
845
|
if (h.getElementName(node) !== 'input') return
|
|
846
846
|
if (!h.hasAttr(node, 'placeholder')) return
|
|
847
847
|
if (h.hasAccessibleName(node)) return
|
|
848
|
+
// An input inside a search landmark with an accessible name is labeled at group level.
|
|
849
|
+
// e.g. <form role="search" aria-label="..."><input placeholder="..." /></form>
|
|
850
|
+
for (const ancestor of h.getAncestors(node)) {
|
|
851
|
+
if (h.getRoleValue(ancestor) === 'search' && h.hasAccessibleName(ancestor)) return
|
|
852
|
+
}
|
|
848
853
|
context.report({ node: h.getAttr(node, 'placeholder'), messageId: 'placeholderOnly' })
|
|
849
854
|
},
|
|
850
855
|
}
|
|
@@ -1206,6 +1211,13 @@ export function makeNoDialogWithoutClose(h) {
|
|
|
1206
1211
|
[h.elementWithChildrenVisitor](node) {
|
|
1207
1212
|
const opening = h.getOpeningElement(node)
|
|
1208
1213
|
if (h.getRoleValue(opening) !== 'dialog') return
|
|
1214
|
+
// If any child is a JSX expression container or spread, children are
|
|
1215
|
+
// passed dynamically and cannot be statically inspected for a close button.
|
|
1216
|
+
const children = node.children ?? []
|
|
1217
|
+
const hasDynamicChildren = children.some(
|
|
1218
|
+
c => c.type === 'JSXExpressionContainer' || c.type === 'JSXSpreadChild'
|
|
1219
|
+
)
|
|
1220
|
+
if (hasDynamicChildren) return
|
|
1209
1221
|
const hasClose = h.getChildOpeningElementsFromWrapper(node).some(child => {
|
|
1210
1222
|
const el = h.getElementName(child)
|
|
1211
1223
|
const role = h.getRoleValue(child)
|
package/neighbor-stylelint.mjs
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
* double-great/stylelint-a11y github.com/double-great/stylelint-a11y
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import stylelint from 'stylelint';
|
|
17
|
+
const { utils: { report } } = stylelint;
|
|
18
|
+
|
|
16
19
|
const defined = (x) => x !== undefined && x !== null;
|
|
17
20
|
|
|
18
21
|
/** True if the node or any ancestor is a prefers-* / forced-colors media block */
|
|
@@ -117,7 +120,7 @@ function rule(primaryOption) {
|
|
|
117
120
|
|
|
118
121
|
// opacity - warn on non-structural values (i.e. dims like 0.5, 0.75)
|
|
119
122
|
if (prop === 'opacity' && !isStructuralOpacity(value)) {
|
|
120
|
-
decl
|
|
123
|
+
report(decl, messages.opacity(value));
|
|
121
124
|
return;
|
|
122
125
|
}
|
|
123
126
|
|
|
@@ -125,7 +128,7 @@ function rule(primaryOption) {
|
|
|
125
128
|
if (prop === 'animation' || prop === 'transition' || prop === 'animation-name') {
|
|
126
129
|
// Skip "none" values - they're already the reduced state
|
|
127
130
|
if (/^none\b/i.test(value.trim())) return;
|
|
128
|
-
decl
|
|
131
|
+
report(decl, messages.animation(prop, value));
|
|
129
132
|
return;
|
|
130
133
|
}
|
|
131
134
|
|
|
@@ -136,7 +139,7 @@ function rule(primaryOption) {
|
|
|
136
139
|
'outline-color', 'box-shadow', 'text-shadow', 'fill', 'stroke',
|
|
137
140
|
]);
|
|
138
141
|
if (visualProps.has(prop) && hasAlphaChannel(value)) {
|
|
139
|
-
decl
|
|
142
|
+
report(decl, messages.alpha(value));
|
|
140
143
|
}
|
|
141
144
|
});
|
|
142
145
|
};
|
|
@@ -169,6 +172,30 @@ function isFocusSelector(selector) {
|
|
|
169
172
|
return /:focus(?:-visible|-within)?/i.test(selector);
|
|
170
173
|
}
|
|
171
174
|
|
|
175
|
+
/** Returns true if the node is inside a @media (pointer: fine/coarse) block — keyboard users are unaffected. */
|
|
176
|
+
function insidePointerMedia(node) {
|
|
177
|
+
let current = node.parent;
|
|
178
|
+
while (current) {
|
|
179
|
+
if (
|
|
180
|
+
current.type === 'atrule' &&
|
|
181
|
+
current.name === 'media' &&
|
|
182
|
+
/pointer\s*:\s*(fine|coarse)/.test(current.params)
|
|
183
|
+
) return true;
|
|
184
|
+
current = current.parent;
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Returns true if the decl or any ancestor rule node targets a focus state. */
|
|
190
|
+
function insideFocusSelector(decl) {
|
|
191
|
+
let current = decl.parent;
|
|
192
|
+
while (current) {
|
|
193
|
+
if (current.type === 'rule' && isFocusSelector(current.selector ?? '')) return true;
|
|
194
|
+
current = current.parent;
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
172
199
|
/** @type {import('stylelint').Rule} */
|
|
173
200
|
function noOutlineNoneRule(_primaryOption) {
|
|
174
201
|
return (root, result) => {
|
|
@@ -176,14 +203,12 @@ function noOutlineNoneRule(_primaryOption) {
|
|
|
176
203
|
const value = decl.value.trim().toLowerCase();
|
|
177
204
|
if (value !== 'none' && value !== '0') return;
|
|
178
205
|
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
if (parent?.type === 'rule' && isFocusSelector(parent.selector ?? '')) return;
|
|
206
|
+
// Allow inside :focus / :focus-visible (author is intentionally restyling focus)
|
|
207
|
+
if (insideFocusSelector(decl)) return;
|
|
208
|
+
// Allow inside @media (pointer: fine/coarse) — keyboard users are unaffected
|
|
209
|
+
if (insidePointerMedia(decl)) return;
|
|
184
210
|
|
|
185
|
-
|
|
186
|
-
decl.warn(result, noOutlineNoneMessages.removed(decl.value), { rule: noOutlineNoneRuleName });
|
|
211
|
+
report({ message: noOutlineNoneMessages.removed(decl.value), node: decl, result, ruleName: noOutlineNoneRuleName });
|
|
187
212
|
});
|
|
188
213
|
};
|
|
189
214
|
}
|
|
@@ -243,7 +268,7 @@ function noForcedColorsNoneRule(_primaryOption) {
|
|
|
243
268
|
if (decl.value.trim().toLowerCase() !== 'none') return;
|
|
244
269
|
if (!insideForcedColorsMedia(decl)) return;
|
|
245
270
|
const selector = decl.parent?.selector ?? decl.parent?.name ?? '(unknown)';
|
|
246
|
-
|
|
271
|
+
report({ message: noForcedColorsNoneMessages.none(selector), node: decl, result, ruleName: noForcedColorsNoneRuleName });
|
|
247
272
|
});
|
|
248
273
|
};
|
|
249
274
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a11yfred/neighbor",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Accessibility linting for a11yfred - ESLint (markup + content), Stylelint (CSS). Won't you be my neighbor?",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"files": [
|
|
7
|
+
"files": [
|
|
8
|
+
"lib",
|
|
9
|
+
"*.mjs",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"README.md",
|
|
12
|
+
"CHANGELOG.md",
|
|
13
|
+
"CONTRIBUTING.md",
|
|
14
|
+
"RULES.md",
|
|
15
|
+
"RULES-MARKUP.md",
|
|
16
|
+
"RULES-CSS.md",
|
|
17
|
+
"RULES-CONTENT.md"
|
|
18
|
+
],
|
|
8
19
|
"main": "./neighbor-stylelint.mjs",
|
|
9
20
|
"repository": {
|
|
10
21
|
"type": "git",
|
|
@@ -67,6 +78,7 @@
|
|
|
67
78
|
}
|
|
68
79
|
},
|
|
69
80
|
"devDependencies": {
|
|
81
|
+
"@a11yfred/neighbor": "^1.0.3",
|
|
70
82
|
"eslint": "^9.39.4",
|
|
71
83
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
|
72
84
|
"stylelint": "^17.11.0"
|