@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 +29 -3
- package/CONTRIBUTING.md +1 -1
- package/README.md +75 -69
- package/RULES-CONTENT.md +5 -5
- package/RULES-MARKUP.md +2 -1
- package/RULES.md +2 -2
- package/lib/rules.js +4 -10
- package/lib/ulam-rules.js +145 -10
- package/package.json +3 -2
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
|
|
8
|
-
- **`no-dialog-without-close
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`, `
|
|
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
|
-
//
|
|
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: '
|
|
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.
|
|
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`]: '
|
|
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':
|
|
261
|
-
'no-hash-router-in-remix':
|
|
262
|
-
'no-use-page-title-in-remix':
|
|
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
|
|
398
|
+
/** React plugin: all ulam rules. */
|
|
266
399
|
export function buildUlamRules() {
|
|
267
400
|
return {
|
|
268
|
-
'no-announce-in-render':
|
|
269
|
-
'no-hash-router-in-remix':
|
|
270
|
-
'no-use-page-title-in-remix':
|
|
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`]:
|
|
291
|
-
[`${ns}/no-hash-router-in-remix`]:
|
|
292
|
-
[`${ns}/no-use-page-title-in-remix`]:
|
|
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.
|
|
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",
|