@a11yfred/neighbor 1.0.4 → 1.1.2

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,11 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0 - 2026-05-23
4
+
5
+ ### New rules
6
+
7
+ | Rule | What it catches |
8
+ | --- | --- |
9
+ | `no-disabled-and-aria-disabled` | Elements with both `disabled` and `aria-disabled` attributes - causes conflicting states in assistive tech |
10
+
11
+ ### Severity changes
12
+
13
+ - `prefer-aria-disabled` moved from `off` to `error` by default to enforce discoverable form controls in tab order.
14
+
15
+ ---
16
+
17
+ ## 1.0.6 - 2026-05-13
18
+
19
+ Add Severity column to all rule tables in README.
20
+
21
+ ---
22
+
23
+ ## 1.0.5 - 2026-05-13
24
+
25
+ Docs cleanup: remove em dashes, fix MD036/MD040 markdownlint issues across all docs.
26
+
27
+ ---
28
+
3
29
  ## 1.0.4 - 2026-05-13
4
30
 
5
31
  ### Bug fixes
6
32
 
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.
33
+ - **`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.
34
+ - **`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
35
 
10
36
  ---
11
37
 
@@ -91,7 +117,7 @@ RULES.md is now an index. Full references split into:
91
117
  ### New rules
92
118
 
93
119
  | Rule | What it catches |
94
- |---|---|
120
+ | --- | --- |
95
121
  | `no-labelledby-missing-target` | `aria-labelledby`/`describedby`/`controls`/`owns`/`activedescendant` referencing an `id` that doesn't exist in the file |
96
122
  | `no-dynamic-content-without-live` | `dangerouslySetInnerHTML` / `v-html` / `[innerHTML]` on an element outside a live region |
97
123
  | `form-field-multiple-labels` | Multiple `<label for="…">` elements targeting the same input |
package/CONTRIBUTING.md CHANGED
@@ -93,7 +93,7 @@ When in doubt, start at `warn`. It's easier to promote a rule to `error` than to
93
93
 
94
94
  ## Commit style
95
95
 
96
- ```
96
+ ```text
97
97
  feat: add no-my-new-rule (short description)
98
98
  fix: correct false positive in no-existing-rule
99
99
  docs: update RULES.md for no-my-new-rule
package/README.md CHANGED
@@ -51,9 +51,9 @@ npm install --save-dev @a11yfred/neighbor
51
51
 
52
52
  ### Vanilla JS / plain HTML (no framework)
53
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.
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
55
 
56
- **What you get:**
56
+ What you get:
57
57
 
58
58
  | Plugin | What it checks |
59
59
  | --- | --- |
@@ -110,7 +110,7 @@ Run it:
110
110
  npx eslint src/
111
111
  ```
112
112
 
113
- **Both together:**
113
+ Both together:
114
114
 
115
115
  ```js
116
116
  // eslint.config.js
@@ -330,55 +330,55 @@ All peers are optional. Install only what your project uses.
330
330
 
331
331
  Base: `eslint-plugin-jsx-a11y`
332
332
 
333
- | What it checks | Rule | WCAG SC |
334
- | --- | --- | --- |
335
- | `aria-disabled` keeps element reachable | `prefer-aria-disabled` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
336
- | `aria-disabled` must block click handler | `no-unblocked-aria-disabled` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
337
- | `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) |
338
- | `role="alert"` overuse | `warn-role-alert` | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
339
- | `aria-live="assertive"` outside `role="alert"` | `no-assertive-live-overuse` | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
340
- | `role="dialog"` requires accessible name | `no-roles-without-name` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
341
- | `role="group"` with form controls requires name | `no-group-without-name` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
342
- | `role="tooltip"` requires `id` on the tooltip | `no-tooltip-role-misuse` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
343
- | `role="application"` disables AT browse mode | `no-application-role` | - |
344
- | `role="grid"` almost always wrong | `no-grid-role` | - |
345
- | `role="menu"` on nav triggers wrong AT mode | `no-menu-role-on-nav` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
346
- | `role="presentation"` on a focusable element | `no-presentation-on-focusable` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
347
- | `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) |
348
- | `role="img"` requires accessible name | `no-image-role-without-name` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
349
- | `role="tab"` requires `aria-selected` | `no-tabs-without-structure` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
350
- | `role="tab"` should declare `aria-controls` | `no-tab-without-controls` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
351
- | `role="combobox"` requires `aria-expanded` | `no-combobox-without-expanded` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
352
- | `role="slider"` requires value range attributes | `no-slider-without-range` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
353
- | `role="spinbutton"` requires value range attributes | `no-spinbutton-without-range` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
354
- | `role="listbox"` requires `role="option"` children | `no-listbox-without-option` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
355
- | `role="tree"` requires `role="treeitem"` children | `no-tree-without-treeitem` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
356
- | `role="feed"` requires `role="article"` children | `no-feed-without-article` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
357
- | `aria-hidden="true"` + `role="none"` is redundant | `no-redundant-aria-hidden-with-presentation` | - |
358
- | `aria-roledescription` does not translate | `no-aria-roledescription` | - |
359
- | `aria-readonly` has poor AT support | `no-aria-readonly` | - |
360
- | `aria-owns` on a void element | `no-aria-owns-on-void` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
361
- | `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) |
362
- | `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) |
363
- | `<a>` with only aria-hidden children | `no-aria-hidden-in-link` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
364
- | `<button>` with only aria-hidden children | `no-empty-button` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
365
- | `<input>` placeholder used as sole label | `no-placeholder-only` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
366
- | `<input>` with invalid type value | `no-input-type-invalid` | [1.3.5](https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose) |
367
- | `<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) |
368
- | `<summary>` outside `<details>` | `no-summary-without-details` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
369
- | `<a href="#">` used as a button | `no-href-hash` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
370
- | `target="_blank"` without new-tab disclosure | `no-target-blank-without-label` | [3.2.2](https://www.w3.org/WAI/WCAG21/Understanding/on-input) |
371
- | Duplicate `id` breaks ARIA relationships | `no-duplicate-id` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
372
- | Positive `tabIndex` breaks tab order | `no-positive-tabindex` | [2.4.3](https://www.w3.org/WAI/WCAG21/Understanding/focus-order) |
373
- | Heading inside an interactive element | `no-heading-inside-interactive` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
374
- | `title` attribute as the only accessible name | `no-title-as-label` | [2.4.6](https://www.w3.org/WAI/WCAG21/Understanding/headings-and-labels) |
375
- | `<video>` or `<audio autoplay>` without controls | `no-autoplay-without-controls` | [1.4.2](https://www.w3.org/WAI/WCAG21/Understanding/audio-control) |
376
- | Mouse-only events without keyboard equivalents | `no-mouse-only-events` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
377
- | `aria-labelledby`/`describedby` references missing `id` | `no-labelledby-missing-target` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
378
- | `dangerouslySetInnerHTML` outside a live region | `no-dynamic-content-without-live` | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
379
- | Multiple `<label>` elements for the same control | `form-field-multiple-labels` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
380
- | `<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) |
381
- | `announce()` called in component render body | `no-announce-in-render` | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
333
+ | What it checks | Rule | Severity | WCAG SC |
334
+ | --- | --- | --- | --- |
335
+ | `aria-disabled` keeps element reachable | `prefer-aria-disabled` | off | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
336
+ | `aria-disabled` must block click handler | `no-unblocked-aria-disabled` | error | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
337
+ | `aria-label` on a generic element with no role | `no-aria-label-on-generic` | error | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
338
+ | `role="alert"` overuse | `warn-role-alert` | off | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
339
+ | `aria-live="assertive"` outside `role="alert"` | `no-assertive-live-overuse` | error | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
340
+ | `role="dialog"` requires accessible name | `no-roles-without-name` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
341
+ | `role="group"` with form controls requires name | `no-group-without-name` | error | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
342
+ | `role="tooltip"` requires `id` on the tooltip | `no-tooltip-role-misuse` | warn | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
343
+ | `role="application"` disables AT browse mode | `no-application-role` | off | - |
344
+ | `role="grid"` almost always wrong | `no-grid-role` | off | - |
345
+ | `role="menu"` on nav triggers wrong AT mode | `no-menu-role-on-nav` | warn | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
346
+ | `role="presentation"` on a focusable element | `no-presentation-on-focusable` | error | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
347
+ | `role="log"` must not contain interactive children | `no-log-with-interactive-children` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
348
+ | `role="img"` requires accessible name | `no-image-role-without-name` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
349
+ | `role="tab"` requires `aria-selected` | `no-tabs-without-structure` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
350
+ | `role="tab"` should declare `aria-controls` | `no-tab-without-controls` | off | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
351
+ | `role="combobox"` requires `aria-expanded` | `no-combobox-without-expanded` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
352
+ | `role="slider"` requires value range attributes | `no-slider-without-range` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
353
+ | `role="spinbutton"` requires value range attributes | `no-spinbutton-without-range` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
354
+ | `role="listbox"` requires `role="option"` children | `no-listbox-without-option` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
355
+ | `role="tree"` requires `role="treeitem"` children | `no-tree-without-treeitem` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
356
+ | `role="feed"` requires `role="article"` children | `no-feed-without-article` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
357
+ | `aria-hidden="true"` + `role="none"` is redundant | `no-redundant-aria-hidden-with-presentation` | error | - |
358
+ | `aria-roledescription` does not translate | `no-aria-roledescription` | off | - |
359
+ | `aria-readonly` has poor AT support | `no-aria-readonly` | off | - |
360
+ | `aria-owns` on a void element | `no-aria-owns-on-void` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
361
+ | `aria-activedescendant` requires a non-empty static ID | `no-aria-activedescendant-without-id` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
362
+ | `aria-required` only valid on form-control roles | `no-aria-required-on-non-form` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
363
+ | `<a>` with only aria-hidden children | `no-aria-hidden-in-link` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
364
+ | `<button>` with only aria-hidden children | `no-empty-button` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
365
+ | `<input>` placeholder used as sole label | `no-placeholder-only` | error | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
366
+ | `<input>` with invalid type value | `no-input-type-invalid` | error | [1.3.5](https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose) |
367
+ | `<button>` in a form missing explicit type | `no-button-type-missing` | warn | [HTML spec](https://html.spec.whatwg.org/multipage/form-elements.html#the-button-element) |
368
+ | `<summary>` outside `<details>` | `no-summary-without-details` | error | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
369
+ | `<a href="#">` used as a button | `no-href-hash` | off | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
370
+ | `target="_blank"` without new-tab disclosure | `no-target-blank-without-label` | off | [3.2.2](https://www.w3.org/WAI/WCAG21/Understanding/on-input) |
371
+ | Duplicate `id` breaks ARIA relationships | `no-duplicate-id` | error | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
372
+ | Positive `tabIndex` breaks tab order | `no-positive-tabindex` | error | [2.4.3](https://www.w3.org/WAI/WCAG21/Understanding/focus-order) |
373
+ | Heading inside an interactive element | `no-heading-inside-interactive` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
374
+ | `title` attribute as the only accessible name | `no-title-as-label` | error | [2.4.6](https://www.w3.org/WAI/WCAG21/Understanding/headings-and-labels) |
375
+ | `<video>` or `<audio autoplay>` without controls | `no-autoplay-without-controls` | error | [1.4.2](https://www.w3.org/WAI/WCAG21/Understanding/audio-control) |
376
+ | Mouse-only events without keyboard equivalents | `no-mouse-only-events` | error | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
377
+ | `aria-labelledby`/`describedby` references missing `id` | `no-labelledby-missing-target` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
378
+ | `dangerouslySetInnerHTML` outside a live region | `no-dynamic-content-without-live` | error | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
379
+ | Multiple `<label>` elements for the same control | `form-field-multiple-labels` | error | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
380
+ | `<th>` or header role with no accessible text | `no-empty-table-header` | error | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
381
+ | `announce()` called in component render body | `no-announce-in-render` | error | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
382
382
 
383
383
  ### ESLint - Remix 2
384
384
 
@@ -395,18 +395,18 @@ Base: `eslint-plugin-vuejs-accessibility`
395
395
 
396
396
  Neighbor adds everything in the React table above, adapted for Vue's AST (`v-html` instead of `dangerouslySetInnerHTML`), plus:
397
397
 
398
- | What it checks | Rule | WCAG SC |
399
- | --- | --- | --- |
400
- | 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) |
401
- | `<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) |
402
- | Invalid ARIA attribute values | `no-invalid-aria-prop-value` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
403
- | Invalid `autocomplete` token | `no-autocomplete-invalid` | [1.3.5](https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose) |
404
- | Heading with no content | `no-heading-no-content` | [2.4.6](https://www.w3.org/WAI/WCAG21/Understanding/headings-and-labels) |
405
- | `<iframe>` without `title` | `no-iframe-no-title` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
406
- | Alt text contains "image", "photo" | `no-img-redundant-alt` | [1.1.1](https://www.w3.org/WAI/WCAG21/Understanding/non-text-content) |
407
- | `accessKey` attribute | `no-access-key` | [2.1.4](https://www.w3.org/WAI/WCAG21/Understanding/character-key-shortcuts) |
408
- | `scope` on `<td>` (only valid on `<th>`) | `no-scope-on-td` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
409
- | `announce()` called outside `onMounted`/`watch`/handler | `no-announce-in-render` | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
398
+ | What it checks | Rule | Severity | WCAG SC |
399
+ | --- | --- | --- | --- |
400
+ | Ambiguous link text ("click here", "read more") | `no-anchor-ambiguous-text` | error | [2.4.4](https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context) |
401
+ | `<a>` with no content and no accessible name | `no-anchor-no-content` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
402
+ | Invalid ARIA attribute values | `no-invalid-aria-prop-value` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
403
+ | Invalid `autocomplete` token | `no-autocomplete-invalid` | error | [1.3.5](https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose) |
404
+ | Heading with no content | `no-heading-no-content` | error | [2.4.6](https://www.w3.org/WAI/WCAG21/Understanding/headings-and-labels) |
405
+ | `<iframe>` without `title` | `no-iframe-no-title` | error | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
406
+ | Alt text contains "image", "photo" | `no-img-redundant-alt` | warn | [1.1.1](https://www.w3.org/WAI/WCAG21/Understanding/non-text-content) |
407
+ | `accessKey` attribute | `no-access-key` | warn | [2.1.4](https://www.w3.org/WAI/WCAG21/Understanding/character-key-shortcuts) |
408
+ | `scope` on `<td>` (only valid on `<th>`) | `no-scope-on-td` | error | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
409
+ | `announce()` called outside `onMounted`/`watch`/handler | `no-announce-in-render` | error | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
410
410
 
411
411
  ### ESLint - Angular templates
412
412
 
@@ -418,11 +418,11 @@ Neighbor adds the same rule set as Vue, adapted for Angular's template AST (`[in
418
418
 
419
419
  ### Stylelint - CSS
420
420
 
421
- | Rule | What it checks |
422
- | --- | --- |
423
- | `neighbor/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) |
424
- | `neighbor/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) |
425
- | `neighbor/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) |
421
+ | Rule | Severity | What it checks |
422
+ | --- | --- | --- |
423
+ | `neighbor/user-preferences` | warn | 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) |
424
+ | `neighbor/no-outline-none` | error | Disallows bare `outline: none` or `outline: 0` outside `:focus` selectors - [SC 2.4.7](https://www.w3.org/WAI/WCAG21/Understanding/focus-visible) |
425
+ | `neighbor/no-forced-colors-none` | error | 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) |
426
426
 
427
427
  ### Content linter
428
428
 
@@ -452,6 +452,12 @@ See [RULES-CONTENT.md](RULES-CONTENT.md) for the full rule reference including s
452
452
 
453
453
  All rules can be overridden in your config.
454
454
 
455
+ ## Roadmap
456
+
457
+ Planned improvements and extensions to neighbor:
458
+
459
+ - [ ] **Browser extension: live page linting** — A Chrome/Firefox extension that applies neighbor's accessibility rules to live web content in real-time, highlighting violations inline. Useful for auditors and testers who want to spot issues while testing third-party sites without a build step. Would reuse existing rule logic and integrate with a debug panel UI similar to [@a11yfred/rogers](https://github.com/a11yfred/rogers).
460
+
455
461
  ## See also
456
462
 
457
463
  - [RULES.md](RULES.md) - rule index across all domains
package/RULES-CONTENT.md CHANGED
@@ -106,7 +106,7 @@ Flags slurs, condescending euphemisms, and suffering-framing when writing about
106
106
 
107
107
  **Consensus:** Every disability language guide surveyed - NCDJ, AP Stylebook, ADA National Network, APA Style, SIGACCESS - independently prohibits these terms. No credible source defends them.
108
108
 
109
- **What it catches:**
109
+ What it catches:
110
110
 
111
111
  | Avoid | Instead use | Sources |
112
112
  | --- | --- | --- |
@@ -129,7 +129,7 @@ Flags slurs, condescending euphemisms, and suffering-framing when writing about
129
129
 
130
130
  **Identity-first vs person-first language:** "Autistic person" and "person with autism" are both used in disability communities. APA (2022) accepts both and recommends following individual preference. [Nicolas Steenhout](https://incl.ca/disability-language-is-a-nuanced-thing/) notes the current momentum in disability advocacy is toward identity-first language as reclamation, while person-first remains standard in many clinical and government contexts. [Léonie Watson](https://tink.uk), cited by Steenhout: *"There is no right or wrong answer because it is a matter of personal choice, and the choice depends on context."* This rule does not flag either form.
131
131
 
132
- **Configuration:**
132
+ Configuration:
133
133
 
134
134
  ```js
135
135
  '@a11yfred/neighbor/content/no-ableist-language': ['warn', {
@@ -145,7 +145,7 @@ Flags figurative uses of disability language - disability used as a metaphor i
145
145
 
146
146
  **WCAG basis:** No direct SC. Grounded in NCDJ, A11y Collective, and APA guidance that these uses normalise disability as a negative even when not intended that way.
147
147
 
148
- **What it catches:**
148
+ What it catches:
149
149
 
150
150
  | Avoid | Instead use | Sources |
151
151
  | --- | --- | --- |
@@ -169,7 +169,7 @@ Flags English idioms and sports metaphors that are opaque to ESL readers and int
169
169
 
170
170
  **Sources:** Canadian Government accessible documents guide, SJSU accessible writing strategies, UX Content Co., A11y Collective.
171
171
 
172
- **What it catches:**
172
+ What it catches:
173
173
 
174
174
  | Avoid | Instead use |
175
175
  | --- | --- |
@@ -238,7 +238,7 @@ Flags ALL CAPS words in prose content.
238
238
 
239
239
  **Sources:** Google Developer Style Guide, GOV.UK publishing guide, Canadian Government guide.
240
240
 
241
- **Configuration:**
241
+ Configuration:
242
242
 
243
243
  ```js
244
244
  '@a11yfred/neighbor/content/no-all-caps-prose': ['warn', {
package/RULES-MARKUP.md CHANGED
@@ -67,6 +67,8 @@ All rules run on React, Vue, and Angular unless noted.
67
67
  | `no-dynamic-content-without-live` | `dangerouslySetInnerHTML` / `v-html` / `[innerHTML]` on an element outside a live region | [SC 4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
68
68
  | `form-field-multiple-labels` | Multiple `<label for="…">` elements targeting the same input | [SC 1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
69
69
  | `no-empty-table-header` | `<th>` or `role="columnheader"/"rowheader"` with no accessible text or `aria-label` | [SC 1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
70
+ | `prefer-aria-disabled` | HTML `disabled` removes element from tab order; `aria-disabled` keeps it discoverable | Roselli: Don't Disable Form Controls - [SC 2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
71
+ | `no-disabled-and-aria-disabled` | Element has both `disabled` and `aria-disabled` - causes conflicting states in assistive tech | [ARIA 1.2](https://www.w3.org/TR/wai-aria-1.2/) |
70
72
 
71
73
  ### Warnings - on by default
72
74
 
@@ -89,7 +91,6 @@ These rules flag real problems but generate enough noise in typical codebases th
89
91
  | `no-tab-without-controls` | `role="tab"` without `aria-controls` | [APG: Tabs Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) |
90
92
  | `no-href-hash` | `<a href="#">` used as a button | Sutton: Links vs Buttons - [SC 2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
91
93
  | `warn-role-alert` | `role="alert"` - prefer `role="status"` for non-urgent updates | [APG](https://www.w3.org/WAI/ARIA/apg/) / Roselli / Sutton - [SC 4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
92
- | `prefer-aria-disabled` | HTML `disabled` removes element from tab order; `aria-disabled` keeps it discoverable | Roselli: Don't Disable Form Controls - [SC 2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
93
94
  | `no-target-blank-without-label` | `target="_blank"` without communicating the new-tab behaviour | WebAIM - [SC 3.2.2](https://www.w3.org/WAI/WCAG21/Understanding/on-input) |
94
95
  | `no-dialog-without-close` | `role="dialog"` or `<dialog>` without a visible close button | [APG: Dialog Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) - [SC 2.1.2](https://www.w3.org/WAI/WCAG21/Understanding/no-keyboard-trap) |
95
96
 
package/RULES.md CHANGED
@@ -14,11 +14,11 @@ Neighbor ships rules across three separate domains. Each has its own reference p
14
14
 
15
15
  ESLint rules that flag bad ARIA patterns, missing accessible names, keyboard traps, and structural errors in JSX, Vue SFCs, and Angular templates. Full reference → [RULES-MARKUP.md](RULES-MARKUP.md)
16
16
 
17
- **Errors (definite breakage):** `no-aria-label-on-generic`, `no-assertive-live-overuse`, `no-unblocked-aria-disabled`, `no-roles-without-name`, `no-group-without-name`, `no-presentation-on-focusable`, `no-log-with-interactive-children`, `no-aria-hidden-in-link`, `no-redundant-aria-hidden-with-presentation`, `no-aria-owns-on-void`, `no-title-as-label`, `no-tabs-without-structure`, `no-positive-tabindex`, `no-autoplay-without-controls`, `no-heading-inside-interactive`, `no-placeholder-only`, `no-empty-button`, `no-image-role-without-name`, `no-spinbutton-without-range`, `no-slider-without-range`, `no-combobox-without-expanded`, `no-mouse-only-events`, `no-listbox-without-option`, `no-tree-without-treeitem`, `no-feed-without-article`, `no-aria-activedescendant-without-id`, `no-duplicate-id`, `no-summary-without-details`, `no-aria-required-on-non-form`, `no-input-type-invalid`, `no-labelledby-missing-target`, `no-dynamic-content-without-live`, `form-field-multiple-labels`, `no-empty-table-header`
17
+ **Errors (definite breakage):** `no-aria-label-on-generic`, `no-assertive-live-overuse`, `no-unblocked-aria-disabled`, `no-roles-without-name`, `no-group-without-name`, `no-presentation-on-focusable`, `no-log-with-interactive-children`, `no-aria-hidden-in-link`, `no-redundant-aria-hidden-with-presentation`, `no-aria-owns-on-void`, `no-title-as-label`, `no-tabs-without-structure`, `no-positive-tabindex`, `no-autoplay-without-controls`, `no-heading-inside-interactive`, `no-placeholder-only`, `no-empty-button`, `no-image-role-without-name`, `no-spinbutton-without-range`, `no-slider-without-range`, `no-combobox-without-expanded`, `no-mouse-only-events`, `no-listbox-without-option`, `no-tree-without-treeitem`, `no-feed-without-article`, `no-aria-activedescendant-without-id`, `no-duplicate-id`, `no-summary-without-details`, `no-aria-required-on-non-form`, `no-input-type-invalid`, `no-labelledby-missing-target`, `no-dynamic-content-without-live`, `form-field-multiple-labels`, `no-empty-table-header`, `no-disabled-and-aria-disabled`, `prefer-aria-disabled`
18
18
 
19
19
  **Warnings (on by default):** `no-tooltip-role-misuse`, `no-menu-role-on-nav`, `no-button-type-missing`
20
20
 
21
- **Off by default (opt in):** `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`
21
+ **Off by default (opt in):** `no-application-role`, `no-grid-role`, `no-aria-roledescription`, `no-aria-readonly`, `no-tab-without-controls`, `no-href-hash`, `warn-role-alert`, `no-target-blank-without-label`, `no-dialog-without-close`
22
22
 
23
23
  **Vue / Angular only:** `no-anchor-ambiguous-text`, `no-anchor-no-content`, `no-aria-activedescendant-no-tabindex`, `no-invalid-aria-prop-value`, `no-autocomplete-invalid`, `no-heading-no-content`, `no-iframe-no-title`, `no-img-redundant-alt`, `no-access-key`, `no-noninteractive-to-interactive-role`, `no-noninteractive-tabindex`, `prefer-semantic-element`, `no-role-supports-aria-props`, `no-scope-on-td`
24
24
 
package/lib/rules.js CHANGED
@@ -579,18 +579,15 @@ export function makeWarnRoleAlert(h) {
579
579
 
580
580
  // ─── prefer-aria-disabled ────────────────────────────────────────────────────
581
581
 
582
- // Native form controls that support HTML disabled per spec - aria-disabled is
583
- // not the right substitute for these; native disabled is correct and expected.
584
- const NATIVE_DISABLED_ELEMENTS = new Set(['input', 'select', 'textarea', 'option', 'optgroup', 'fieldset'])
585
-
582
+ // Prefer aria-disabled over HTML disabled for all interactive elements
586
583
  export function makePreferAriaDisabled(h) {
587
584
  return {
588
585
  meta: {
589
586
  type: 'suggestion',
590
- docs: { description: 'Suggest aria-disabled over the HTML disabled attribute for better AT discoverability' },
587
+ docs: { description: 'Prefer aria-disabled over HTML disabled attribute for consistent AT handling' },
591
588
  messages: {
592
589
  disabled:
593
- '`disabled` removes the element from the tab order - keyboard and AT users cannot discover it or learn why it\'s unavailable. Consider aria-disabled="true" instead, which keeps the element reachable and lets you explain the reason. Guard the onClick handler when using aria-disabled. (Roselli: Don\'t Disable Form Controls)',
590
+ '`disabled` removes the element from the tab order - keyboard and AT users cannot discover it or learn why it\'s unavailable. Use aria-disabled="true" instead, which keeps the element reachable and lets you explain the reason. For form controls, use aria-disabled on the control itself, not the native disabled attribute. (Roselli: Don\'t Disable Form Controls)',
594
591
  },
595
592
  schema: [],
596
593
  },
@@ -598,9 +595,6 @@ export function makePreferAriaDisabled(h) {
598
595
  return {
599
596
  [h.elementVisitor](node) {
600
597
  if (!h.isInteractiveElement(node)) return
601
- // Native form controls: HTML disabled is correct per spec, not aria-disabled
602
- const elName = h.getElementName(node)
603
- if (elName && NATIVE_DISABLED_ELEMENTS.has(elName)) return
604
598
  const attr = h.getAttr(node, 'disabled')
605
599
  if (!attr) return
606
600
  // Only flag boolean disabled (not disabled={false})
@@ -2398,7 +2392,7 @@ export function buildRecommendedRules(ns) {
2398
2392
  [`${ns}/no-tab-without-controls`]: 'off',
2399
2393
  [`${ns}/no-href-hash`]: 'off',
2400
2394
  [`${ns}/warn-role-alert`]: 'off',
2401
- [`${ns}/prefer-aria-disabled`]: 'off',
2395
+ [`${ns}/prefer-aria-disabled`]: 'warn',
2402
2396
  [`${ns}/no-target-blank-without-label`]: 'off',
2403
2397
  [`${ns}/no-dialog-without-close`]: 'off',
2404
2398
  }
package/lib/ulam-rules.js CHANGED
@@ -254,20 +254,154 @@ export function makeNoUsePageTitleInRemix() {
254
254
  }
255
255
  }
256
256
 
257
+ // ─── no-disabled-and-aria-disabled ───────────────────────────────────────────
258
+ //
259
+ // HTML5 disabled attribute and aria-disabled should not be used together on the
260
+ // same element or nested within each other (e.g., element with disabled containing
261
+ // a child with aria-disabled or vice versa). This creates conflicting semantics:
262
+ // - Native disabled (on form controls) is a concrete state
263
+ // - aria-disabled (on any element) is an accessibility annotation
264
+ // Using both leads to inconsistent screen reader announcements and styling conflicts.
265
+ //
266
+ // Solution: Use only aria-disabled on all custom controls; use only disabled on
267
+ // native form controls (button, input, select, textarea).
268
+
269
+ function hasDisabledInAncestors(node) {
270
+ let current = node
271
+ while (current) {
272
+ if (current.type === 'JSXOpeningElement' || current.type === 'JSXSelfClosingElement') {
273
+ const attrs = current.attributes || []
274
+ const hasDisabled = attrs.some(attr =>
275
+ (attr.type === 'JSXAttribute' && attr.name?.name === 'disabled') ||
276
+ (attr.type === 'JSXSpreadAttribute')
277
+ )
278
+ if (hasDisabled) return true
279
+ }
280
+ current = current.parent
281
+ }
282
+ return false
283
+ }
284
+
285
+ function hasAriaDisabledInAncestors(node) {
286
+ let current = node
287
+ while (current) {
288
+ if (current.type === 'JSXOpeningElement' || current.type === 'JSXSelfClosingElement') {
289
+ const attrs = current.attributes || []
290
+ const hasAriaDis = attrs.some(attr =>
291
+ attr.type === 'JSXAttribute' &&
292
+ attr.name?.name === 'aria-disabled'
293
+ )
294
+ if (hasAriaDis) return true
295
+ }
296
+ current = current.parent
297
+ }
298
+ return false
299
+ }
300
+
301
+ function hasDisabledInChildren(node) {
302
+ if (!node.children) return false
303
+ for (const child of node.children) {
304
+ if (child.type === 'JSXElement') {
305
+ const attrs = child.openingElement?.attributes || []
306
+ if (attrs.some(attr => attr.type === 'JSXAttribute' && attr.name?.name === 'disabled')) {
307
+ return true
308
+ }
309
+ if (hasDisabledInChildren(child)) return true
310
+ } else if (child.type === 'JSXFragment') {
311
+ if (hasDisabledInChildren(child)) return true
312
+ }
313
+ }
314
+ return false
315
+ }
316
+
317
+ function hasAriaDisabledInChildren(node) {
318
+ if (!node.children) return false
319
+ for (const child of node.children) {
320
+ if (child.type === 'JSXElement') {
321
+ const attrs = child.openingElement?.attributes || []
322
+ if (attrs.some(attr => attr.type === 'JSXAttribute' && attr.name?.name === 'aria-disabled')) {
323
+ return true
324
+ }
325
+ if (hasAriaDisabledInChildren(child)) return true
326
+ } else if (child.type === 'JSXFragment') {
327
+ if (hasAriaDisabledInChildren(child)) return true
328
+ }
329
+ }
330
+ return false
331
+ }
332
+
333
+ export function makeNoDisabledAndAriaDisabled() {
334
+ return {
335
+ meta: {
336
+ type: 'problem',
337
+ docs: {
338
+ description: 'Disallow disabled and aria-disabled used together on same element or nested',
339
+ url: 'https://www.w3.org/WAI/ARIA/apg/'
340
+ },
341
+ messages: {
342
+ sameElement:
343
+ 'Element has both `disabled` and `aria-disabled="true"`. Use only `aria-disabled` for custom controls ' +
344
+ 'or only `disabled` for native form controls (button, input, select, textarea), not both.',
345
+ nestedDisabledInAriaDis:
346
+ 'Element with `aria-disabled="true"` contains a descendant with native `disabled` attribute. ' +
347
+ 'Use either `aria-disabled` consistently across the tree or `disabled` on native controls only, ' +
348
+ 'not nested combinations.',
349
+ nestedAriaDisInDisabled:
350
+ 'Element with `disabled` contains a descendant with `aria-disabled="true"`. ' +
351
+ 'Use either `disabled` on native form controls or `aria-disabled` consistently, not both nested.',
352
+ },
353
+ schema: [],
354
+ },
355
+ create(context) {
356
+ return {
357
+ 'JSXOpeningElement, JSXSelfClosingElement'(node) {
358
+ const attrs = node.attributes || []
359
+ const hasDisabled = attrs.some(attr =>
360
+ attr.type === 'JSXAttribute' && attr.name?.name === 'disabled'
361
+ )
362
+ const hasAriaDis = attrs.some(attr =>
363
+ attr.type === 'JSXAttribute' && attr.name?.name === 'aria-disabled'
364
+ )
365
+
366
+ // Check: both on same element
367
+ if (hasDisabled && hasAriaDis) {
368
+ context.report({ node, messageId: 'sameElement' })
369
+ return
370
+ }
371
+
372
+ // Check: aria-disabled parent with disabled child
373
+ if (hasAriaDis && hasDisabledInChildren(node.parent)) {
374
+ context.report({ node, messageId: 'nestedDisabledInAriaDis' })
375
+ return
376
+ }
377
+
378
+ // Check: disabled parent with aria-disabled child
379
+ if (hasDisabled && hasAriaDisabledInChildren(node.parent)) {
380
+ context.report({ node, messageId: 'nestedAriaDisInDisabled' })
381
+ return
382
+ }
383
+ },
384
+ }
385
+ },
386
+ }
387
+ }
388
+
257
389
  // ─── All ulam rule factories ──────────────────────────────────────────────────
258
390
 
259
391
  export const ULAM_RULE_FACTORIES = {
260
- 'no-announce-in-render': makeNoAnnounceInRender,
261
- 'no-hash-router-in-remix': makeNoHashRouterInRemix,
262
- 'no-use-page-title-in-remix': makeNoUsePageTitleInRemix,
392
+ 'no-announce-in-render': makeNoAnnounceInRender,
393
+ 'no-hash-router-in-remix': makeNoHashRouterInRemix,
394
+ 'no-use-page-title-in-remix': makeNoUsePageTitleInRemix,
395
+ 'no-disabled-and-aria-disabled': makeNoDisabledAndAriaDisabled,
263
396
  }
264
397
 
265
- /** React plugin: all three ulam rules. */
398
+ /** React plugin: all ulam rules. */
266
399
  export function buildUlamRules() {
267
400
  return {
268
- 'no-announce-in-render': makeNoAnnounceInRender({ framework: 'react' }),
269
- 'no-hash-router-in-remix': makeNoHashRouterInRemix(),
270
- 'no-use-page-title-in-remix': makeNoUsePageTitleInRemix(),
401
+ 'no-announce-in-render': makeNoAnnounceInRender({ framework: 'react' }),
402
+ 'no-hash-router-in-remix': makeNoHashRouterInRemix(),
403
+ 'no-use-page-title-in-remix': makeNoUsePageTitleInRemix(),
404
+ 'no-disabled-and-aria-disabled': makeNoDisabledAndAriaDisabled(),
271
405
  }
272
406
  }
273
407
 
@@ -287,9 +421,10 @@ export function buildUlamRulesAngular() {
287
421
 
288
422
  export function buildUlamRecommendedRules(ns) {
289
423
  return {
290
- [`${ns}/no-announce-in-render`]: 'error',
291
- [`${ns}/no-hash-router-in-remix`]: 'warn',
292
- [`${ns}/no-use-page-title-in-remix`]: 'warn',
424
+ [`${ns}/no-announce-in-render`]: 'error',
425
+ [`${ns}/no-hash-router-in-remix`]: 'warn',
426
+ [`${ns}/no-use-page-title-in-remix`]: 'warn',
427
+ [`${ns}/no-disabled-and-aria-disabled`]: 'error',
293
428
  }
294
429
  }
295
430
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a11yfred/neighbor",
3
- "version": "1.0.4",
3
+ "version": "1.1.2",
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",
@@ -31,7 +31,8 @@
31
31
  "./eslint": "./neighbor-eslint.mjs",
32
32
  "./eslint-vue": "./neighbor-eslint-vue.mjs",
33
33
  "./eslint-angular": "./neighbor-eslint-angular.mjs",
34
- "./content": "./neighbor-content.mjs"
34
+ "./content": "./neighbor-content.mjs",
35
+ "./rules": "./lib/content-rules.js"
35
36
  },
36
37
  "keywords": [
37
38
  "stylelint",