@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.
@@ -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
+ }
@@ -28,28 +28,35 @@
28
28
  * input missing label → jsx-a11y/label-has-associated-control
29
29
  */
30
30
 
31
- import jsxA11y from 'eslint-plugin-jsx-a11y'
32
- import { h } from './lib/helpers-jsx.js'
33
- import { buildRules, buildRecommendedRules } from './lib/rules.js'
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
  },
@@ -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 keyboard users are unaffected. */
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) keyboard users are unaffected
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
- export default [userPreferences, noOutlineNone, noForcedColorsNone];
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": "1.1.2",
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": "./neighbor-stylelint.mjs",
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
- ".": "./neighbor-stylelint.mjs",
30
- "./stylelint": "./neighbor-stylelint.mjs",
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
+ }