@a11yfred/neighbor 1.1.2 → 2.0.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 +81 -90
- package/CONTRIBUTING.md +40 -40
- package/README.md +4 -472
- package/RULES-CONTENT.md +240 -81
- package/RULES-CSS.md +41 -19
- package/RULES-MARKUP.md +168 -94
- package/RULES.md +47 -28
- package/lib/content-rules.js +216 -0
- package/lib/framework-rules.js +282 -0
- package/lib/helpers-webcomponents.js +134 -0
- package/lib/rules.js +374 -3
- package/neighbor-content.mjs +1 -1
- package/neighbor-eslint-angular.mjs +29 -8
- package/neighbor-eslint-lit.mjs +85 -0
- package/neighbor-eslint-remix3.mjs +49 -0
- package/neighbor-eslint-vue.mjs +26 -6
- package/neighbor-eslint-webcomponents.mjs +41 -0
- package/neighbor-eslint.mjs +13 -6
- package/neighbor-stylelint.mjs +141 -3
- package/package.json +10 -11
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @a11yfred/neighbor - ESLint plugin (Vanilla Web Components / HTML)
|
|
3
|
+
*
|
|
4
|
+
* Flags ARIA anti-patterns in vanilla HTML or Web Component templates.
|
|
5
|
+
* Requires @html-eslint/parser as the project's ESLint parser for .html files.
|
|
6
|
+
*
|
|
7
|
+
* Usage in eslint.config.js:
|
|
8
|
+
* import htmlParser from '@html-eslint/parser'
|
|
9
|
+
* import neighbor from '@a11yfred/neighbor/webcomponents'
|
|
10
|
+
*
|
|
11
|
+
* export default [
|
|
12
|
+
* {
|
|
13
|
+
* files: ['**/*.html'],
|
|
14
|
+
* languageOptions: { parser: htmlParser },
|
|
15
|
+
* plugins: { '@a11yfred/neighbor': neighbor },
|
|
16
|
+
* rules: neighbor.configs.recommended.rules,
|
|
17
|
+
* },
|
|
18
|
+
* ]
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { h } from '@a11yfred/neighbor/lib/helpers-webcomponents.js'
|
|
22
|
+
import { buildRules, buildRecommendedRules } from '@a11yfred/neighbor/lib/rules.js'
|
|
23
|
+
|
|
24
|
+
const NS = '@a11yfred/neighbor'
|
|
25
|
+
const rules = buildRules(h)
|
|
26
|
+
|
|
27
|
+
const plugin = { meta: { name: `${NS}/webcomponents` }, rules }
|
|
28
|
+
|
|
29
|
+
export default {
|
|
30
|
+
...plugin,
|
|
31
|
+
configs: {
|
|
32
|
+
recommended: {
|
|
33
|
+
plugins: {
|
|
34
|
+
[NS]: plugin,
|
|
35
|
+
},
|
|
36
|
+
rules: {
|
|
37
|
+
...buildRecommendedRules(NS),
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
}
|
package/neighbor-eslint.mjs
CHANGED
|
@@ -28,28 +28,35 @@
|
|
|
28
28
|
* input missing label → jsx-a11y/label-has-associated-control
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import { buildUlamRules, buildUlamRecommendedRules } from './lib/ulam-rules.js'
|
|
31
|
+
import { h } from '@a11yfred/neighbor/lib/helpers-jsx.js'
|
|
32
|
+
import { buildRules, buildRecommendedRules, buildReactFrameworkRules } from '@a11yfred/neighbor/lib/rules.js'
|
|
33
|
+
import { buildUlamRules, buildUlamRecommendedRules } from '@a11yfred/neighbor/lib/ulam-rules.js'
|
|
35
34
|
|
|
36
35
|
const NS = '@a11yfred/neighbor'
|
|
37
36
|
const rules = { ...buildRules(h), ...buildUlamRules() }
|
|
38
37
|
|
|
39
38
|
const plugin = { meta: { name: NS }, rules }
|
|
40
39
|
|
|
40
|
+
let jsxA11y = null
|
|
41
|
+
try { jsxA11y = (await import('eslint-plugin-jsx-a11y')).default } catch {}
|
|
42
|
+
|
|
41
43
|
export default {
|
|
42
44
|
...plugin,
|
|
43
45
|
configs: {
|
|
44
46
|
recommended: {
|
|
45
47
|
plugins: {
|
|
46
48
|
[NS]: plugin,
|
|
47
|
-
'jsx-a11y': jsxA11y,
|
|
49
|
+
...(jsxA11y ? { 'jsx-a11y': jsxA11y } : {}),
|
|
48
50
|
},
|
|
49
51
|
rules: {
|
|
50
|
-
...jsxA11y.configs.recommended.rules,
|
|
52
|
+
...(jsxA11y ? jsxA11y.configs.recommended.rules : {}),
|
|
53
|
+
'jsx-a11y/accessible-emoji': 'off',
|
|
54
|
+
'jsx-a11y/no-autofocus': 'off',
|
|
55
|
+
'jsx-a11y/no-distracting-elements': 'off',
|
|
56
|
+
'jsx-a11y/no-redundant-roles': 'off',
|
|
51
57
|
...buildRecommendedRules(NS),
|
|
52
58
|
...buildUlamRecommendedRules(NS),
|
|
59
|
+
...buildReactFrameworkRules(NS),
|
|
53
60
|
},
|
|
54
61
|
},
|
|
55
62
|
},
|
package/neighbor-stylelint.mjs
CHANGED
|
@@ -14,6 +14,15 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import stylelint from 'stylelint';
|
|
17
|
+
import { createRequire } from 'module';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
let a11yLoaded = false;
|
|
21
|
+
try {
|
|
22
|
+
require.resolve('stylelint-a11y');
|
|
23
|
+
a11yLoaded = true;
|
|
24
|
+
} catch {}
|
|
25
|
+
|
|
17
26
|
const { utils: { report } } = stylelint;
|
|
18
27
|
|
|
19
28
|
const defined = (x) => x !== undefined && x !== null;
|
|
@@ -126,6 +135,8 @@ function rule(primaryOption) {
|
|
|
126
135
|
|
|
127
136
|
// animation or transition
|
|
128
137
|
if (prop === 'animation' || prop === 'transition' || prop === 'animation-name') {
|
|
138
|
+
// If stylelint-a11y is installed, it already checks prefers-reduced-motion
|
|
139
|
+
if (a11yLoaded) return;
|
|
129
140
|
// Skip "none" values - they're already the reduced state
|
|
130
141
|
if (/^none\b/i.test(value.trim())) return;
|
|
131
142
|
report(decl, messages.animation(prop, value));
|
|
@@ -172,7 +183,7 @@ function isFocusSelector(selector) {
|
|
|
172
183
|
return /:focus(?:-visible|-within)?/i.test(selector);
|
|
173
184
|
}
|
|
174
185
|
|
|
175
|
-
/** Returns true if the node is inside a @media (pointer: fine/coarse) block
|
|
186
|
+
/** Returns true if the node is inside a @media (pointer: fine/coarse) block - keyboard users are unaffected. */
|
|
176
187
|
function insidePointerMedia(node) {
|
|
177
188
|
let current = node.parent;
|
|
178
189
|
while (current) {
|
|
@@ -199,13 +210,16 @@ function insideFocusSelector(decl) {
|
|
|
199
210
|
/** @type {import('stylelint').Rule} */
|
|
200
211
|
function noOutlineNoneRule(_primaryOption) {
|
|
201
212
|
return (root, result) => {
|
|
213
|
+
// Redundant with stylelint-a11y/no-outline-none
|
|
214
|
+
if (a11yLoaded) return;
|
|
215
|
+
|
|
202
216
|
root.walkDecls(/^outline$/i, (decl) => {
|
|
203
217
|
const value = decl.value.trim().toLowerCase();
|
|
204
218
|
if (value !== 'none' && value !== '0') return;
|
|
205
219
|
|
|
206
220
|
// Allow inside :focus / :focus-visible (author is intentionally restyling focus)
|
|
207
221
|
if (insideFocusSelector(decl)) return;
|
|
208
|
-
// Allow inside @media (pointer: fine/coarse)
|
|
222
|
+
// Allow inside @media (pointer: fine/coarse) - keyboard users are unaffected
|
|
209
223
|
if (insidePointerMedia(decl)) return;
|
|
210
224
|
|
|
211
225
|
report({ message: noOutlineNoneMessages.removed(decl.value), node: decl, result, ruleName: noOutlineNoneRuleName });
|
|
@@ -279,4 +293,128 @@ const noForcedColorsNone = {
|
|
|
279
293
|
meta: noForcedColorsNoneMeta,
|
|
280
294
|
};
|
|
281
295
|
|
|
282
|
-
|
|
296
|
+
// ─── Rule: neighbor/no-text-justify ──────────────────────────────────────────
|
|
297
|
+
// text-align: justify creates inconsistent spacing ("rivers of white space").
|
|
298
|
+
// This makes it significantly harder for users with cognitive disabilities like dyslexia to read.
|
|
299
|
+
// Ref: WCAG SC 1.4.8 Visual Presentation (AAA)
|
|
300
|
+
|
|
301
|
+
const noTextJustifyRuleName = 'neighbor/no-text-justify';
|
|
302
|
+
|
|
303
|
+
const noTextJustifyMessages = {
|
|
304
|
+
justify: () =>
|
|
305
|
+
`text-align: justify creates uneven spacing that is difficult for users with dyslexia or cognitive disabilities to read. Use left, right, or center instead. (WCAG 1.4.8)`,
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const noTextJustifyMeta = { url: 'https://github.com/a11yfred/neighbor' };
|
|
309
|
+
|
|
310
|
+
/** @type {import('stylelint').Rule} */
|
|
311
|
+
function noTextJustifyRule(_primaryOption) {
|
|
312
|
+
return (root, result) => {
|
|
313
|
+
// Redundant with stylelint-a11y/no-text-align-justify
|
|
314
|
+
if (a11yLoaded) return;
|
|
315
|
+
|
|
316
|
+
root.walkDecls(/^text-align$/i, (decl) => {
|
|
317
|
+
if (decl.value.trim().toLowerCase() !== 'justify') return;
|
|
318
|
+
report({ message: noTextJustifyMessages.justify(), node: decl, result, ruleName: noTextJustifyRuleName });
|
|
319
|
+
});
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const noTextJustify = {
|
|
324
|
+
ruleName: noTextJustifyRuleName,
|
|
325
|
+
rule: noTextJustifyRule,
|
|
326
|
+
meta: noTextJustifyMeta,
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// ─── Rule: neighbor/no-user-select-all-none ──────────────────────────────────
|
|
330
|
+
// user-select: none prevents users from highlighting text.
|
|
331
|
+
// This breaks screen readers, translation tools, and custom highlighting tools that users rely on.
|
|
332
|
+
// Allowed on global resets (*), html, body, p, h1-h6, span, div.
|
|
333
|
+
|
|
334
|
+
const noUserSelectAllNoneRuleName = 'neighbor/no-user-select-all-none';
|
|
335
|
+
|
|
336
|
+
const noUserSelectAllNoneMessages = {
|
|
337
|
+
none: (selector) =>
|
|
338
|
+
`user-select: none on "${selector}" prevents users from selecting text. This breaks translation tools, text-to-speech, and custom highlighting. Only use this on buttons or interactive UI elements, never on text elements or global selectors.`,
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const noUserSelectAllNoneMeta = { url: 'https://github.com/a11yfred/neighbor' };
|
|
342
|
+
|
|
343
|
+
/** @type {import('stylelint').Rule} */
|
|
344
|
+
function noUserSelectAllNoneRule(_primaryOption) {
|
|
345
|
+
return (root, result) => {
|
|
346
|
+
const textSelectors = new Set(['*', 'html', 'body', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span', 'div', 'main', 'article', 'section']);
|
|
347
|
+
root.walkDecls(/^user-select$/i, (decl) => {
|
|
348
|
+
if (decl.value.trim().toLowerCase() !== 'none') return;
|
|
349
|
+
|
|
350
|
+
const parentSelector = decl.parent?.selector ?? '';
|
|
351
|
+
// Check if it's applying to a broad or text-specific tag
|
|
352
|
+
const selectors = parentSelector.split(',').map(s => s.trim().toLowerCase());
|
|
353
|
+
|
|
354
|
+
let violates = false;
|
|
355
|
+
for (const sel of selectors) {
|
|
356
|
+
// Strip pseudo-classes
|
|
357
|
+
const baseSel = sel.replace(/:[a-z-]+(\([^)]+\))?/g, '');
|
|
358
|
+
if (textSelectors.has(baseSel)) {
|
|
359
|
+
violates = true;
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (violates) {
|
|
365
|
+
report({ message: noUserSelectAllNoneMessages.none(parentSelector), node: decl, result, ruleName: noUserSelectAllNoneRuleName });
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const noUserSelectAllNone = {
|
|
372
|
+
ruleName: noUserSelectAllNoneRuleName,
|
|
373
|
+
rule: noUserSelectAllNoneRule,
|
|
374
|
+
meta: noUserSelectAllNoneMeta,
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// ─── Rule: neighbor/no-absolute-viewport-text ────────────────────────────────
|
|
378
|
+
// font-size: 5vw does not scale when the user zooms in their browser.
|
|
379
|
+
// This violates WCAG 1.4.4 Resize Text.
|
|
380
|
+
// Must be used with calc() or clamp() alongside a fixed unit like rem/em.
|
|
381
|
+
|
|
382
|
+
const noAbsoluteViewportTextRuleName = 'neighbor/no-absolute-viewport-text';
|
|
383
|
+
|
|
384
|
+
const noAbsoluteViewportTextMessages = {
|
|
385
|
+
viewport: (value) =>
|
|
386
|
+
`font-size: ${value} uses pure viewport units. This text will not get bigger when users zoom in with their browser. Wrap it in calc() or clamp() with rem or em to allow resizing. (WCAG 1.4.4)`,
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const noAbsoluteViewportTextMeta = { url: 'https://github.com/a11yfred/neighbor' };
|
|
390
|
+
|
|
391
|
+
/** @type {import('stylelint').Rule} */
|
|
392
|
+
function noAbsoluteViewportTextRule(_primaryOption) {
|
|
393
|
+
return (root, result) => {
|
|
394
|
+
root.walkDecls(/^font-size$/i, (decl) => {
|
|
395
|
+
const value = decl.value.trim().toLowerCase();
|
|
396
|
+
// If it has calc/clamp/max/min, it's likely mixing units, which is okay
|
|
397
|
+
if (value.includes('calc(') || value.includes('clamp(') || value.includes('max(') || value.includes('min(')) return;
|
|
398
|
+
|
|
399
|
+
// If it ends directly with vw, vh, vmin, or vmax
|
|
400
|
+
if (/^\d*\.?\d+(vw|vh|vmin|vmax)$/.test(value)) {
|
|
401
|
+
report({ message: noAbsoluteViewportTextMessages.viewport(value), node: decl, result, ruleName: noAbsoluteViewportTextRuleName });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const noAbsoluteViewportText = {
|
|
408
|
+
ruleName: noAbsoluteViewportTextRuleName,
|
|
409
|
+
rule: noAbsoluteViewportTextRule,
|
|
410
|
+
meta: noAbsoluteViewportTextMeta,
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
export default [
|
|
414
|
+
userPreferences,
|
|
415
|
+
noOutlineNone,
|
|
416
|
+
noForcedColorsNone,
|
|
417
|
+
noTextJustify,
|
|
418
|
+
noUserSelectAllNone,
|
|
419
|
+
noAbsoluteViewportText,
|
|
420
|
+
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a11yfred/neighbor",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
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",
|
|
@@ -16,7 +16,12 @@
|
|
|
16
16
|
"RULES-CSS.md",
|
|
17
17
|
"RULES-CONTENT.md"
|
|
18
18
|
],
|
|
19
|
-
"main": "./
|
|
19
|
+
"main": "./lib/rules.js",
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "node scripts/generate-vale.mjs",
|
|
22
|
+
"test": "echo \"No test specified\" && exit 0",
|
|
23
|
+
"lint": "echo \"No lint specified\" && exit 0"
|
|
24
|
+
},
|
|
20
25
|
"repository": {
|
|
21
26
|
"type": "git",
|
|
22
27
|
"url": "https://github.com/a11yfred/neighbor.git"
|
|
@@ -26,13 +31,8 @@
|
|
|
26
31
|
"url": "https://github.com/a11yfred/neighbor/issues"
|
|
27
32
|
},
|
|
28
33
|
"exports": {
|
|
29
|
-
"
|
|
30
|
-
"./
|
|
31
|
-
"./eslint": "./neighbor-eslint.mjs",
|
|
32
|
-
"./eslint-vue": "./neighbor-eslint-vue.mjs",
|
|
33
|
-
"./eslint-angular": "./neighbor-eslint-angular.mjs",
|
|
34
|
-
"./content": "./neighbor-content.mjs",
|
|
35
|
-
"./rules": "./lib/content-rules.js"
|
|
34
|
+
"./rules": "./lib/content-rules.js",
|
|
35
|
+
"./lib/*": "./lib/*.js"
|
|
36
36
|
},
|
|
37
37
|
"keywords": [
|
|
38
38
|
"stylelint",
|
|
@@ -79,9 +79,8 @@
|
|
|
79
79
|
}
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
|
-
"@a11yfred/neighbor": "^1.0.3",
|
|
83
82
|
"eslint": "^9.39.4",
|
|
84
83
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
|
85
84
|
"stylelint": "^17.11.0"
|
|
86
85
|
}
|
|
87
|
-
}
|
|
86
|
+
}
|