@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 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
- - [React / JSX](#react--jsx)
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--react--jsx)
22
- - [ESLint - Remix 2](#eslint--remix-2)
23
- - [ESLint - Vue SFCs](#eslint--vue-sfcs)
24
- - [ESLint - Angular templates](#eslint--angular-templates)
25
- - [Stylelint - CSS](#stylelint--css)
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)
@@ -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.warn(result, messages.opacity(value), { rule: ruleName });
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.warn(result, messages.animation(prop, value), { rule: ruleName });
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.warn(result, messages.alpha(value), { rule: ruleName });
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
- // If this declaration is already inside a :focus / :focus-visible rule, it's fine -
180
- // the author is intentionally restyling focus, which is acceptable as long as they
181
- // provide an alternative (we can't verify the alternative statically, so we allow it).
182
- const parent = decl.parent;
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
- // Flag it
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
- decl.warn(result, noForcedColorsNoneMessages.none(selector), { rule: noForcedColorsNoneRuleName });
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.1",
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": ["lib", "*.mjs", "LICENSE", "README.md", "CHANGELOG.md", "CONTRIBUTING.md", "RULES.md", "RULES-MARKUP.md", "RULES-CSS.md", "RULES-CONTENT.md"],
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"