eslint-plugin-aria-state-validator 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +22 -8
- package/README.md +23 -9
- package/lib/rules/state-dependent-aria-validator.js +88 -33
- package/lib/rules/static-aria-validator.js +127 -2
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -93,10 +93,12 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
|
|
|
93
93
|
|
|
94
94
|
사용된 ARIA 속성의 **정확성**을 검증합니다:
|
|
95
95
|
|
|
96
|
-
- 유효한 ARIA 속성 이름 (오타 감지)
|
|
97
|
-
- Boolean 및 Enum ARIA 속성의 올바른 값
|
|
98
|
-
- 충돌하는 ARIA 속성 감지
|
|
99
|
-
- 네이티브 HTML 요소의 중복 role 감지
|
|
96
|
+
- 유효한 ARIA 속성 이름 (오타 감지 및 **자동 수정**)
|
|
97
|
+
- Boolean 및 Enum ARIA 속성의 올바른 값 (**자동 수정** 지원)
|
|
98
|
+
- 충돌하는 ARIA 속성 감지 (**자동 수정** 지원)
|
|
99
|
+
- 네이티브 HTML 요소의 중복 role 감지 (**자동 수정** 지원)
|
|
100
|
+
- **역할별 필수 ARIA 속성** 검증 (예: `switch` 역할의 `aria-checked` 필수 사용)
|
|
101
|
+
- **역할-속성 호환성** 검증 (예: `img` 역할에 `aria-expanded` 사용 방지)
|
|
100
102
|
|
|
101
103
|
**참고:** 이 플러그인은 ARIA 속성을 **어떻게** 사용하는지를 검증하며, **사용 여부**를 강제하지는 않습니다. 접근성 기능의 존재를 강제하려면 [eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y)를 사용하세요.
|
|
102
104
|
|
|
@@ -148,16 +150,25 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
|
|
|
148
150
|
</div>
|
|
149
151
|
```
|
|
150
152
|
|
|
153
|
+
**자동 수정 지원 패턴:**
|
|
154
|
+
- `() => setX(!x)`
|
|
155
|
+
- `() => setX(true)`
|
|
156
|
+
- `() => setX(prev => !prev)`
|
|
157
|
+
|
|
151
158
|
### 3. 역할 기반 상태 연관성 검증
|
|
152
159
|
|
|
153
|
-
**감지 내용:**
|
|
160
|
+
**감지 내용:** 인터랙티브 역할(Role)에 정적인 ARIA 상태를 사용하거나 필수 속성이 누락된 경우.
|
|
154
161
|
|
|
155
162
|
```jsx
|
|
156
163
|
// ❌ 오류: role="button"인데 aria-pressed가 정적
|
|
157
164
|
<div role="button" aria-pressed="false">버튼</div>
|
|
158
165
|
|
|
166
|
+
// ❌ 오류: role="switch"에 필수 속성 aria-checked 누락
|
|
167
|
+
<div role="switch">토글</div>
|
|
168
|
+
|
|
159
169
|
// ✅ 올바름: 동적 상태 바인딩
|
|
160
170
|
<div role="button" aria-pressed={isPressed ? 'true' : 'false'}>버튼</div>
|
|
171
|
+
<div role="switch" aria-checked={isChecked ? 'true' : 'false'}>토글</div>
|
|
161
172
|
```
|
|
162
173
|
|
|
163
174
|
---
|
|
@@ -193,7 +204,7 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
|
|
|
193
204
|
**감지 내용:** 잘못된 boolean 형식 값
|
|
194
205
|
|
|
195
206
|
```jsx
|
|
196
|
-
// ❌ 오류: 유효하지 않은 boolean 값
|
|
207
|
+
// ❌ 오류: 유효하지 않은 boolean 값 ("yes"/"no"는 자동 수정 지원)
|
|
197
208
|
<div aria-hidden="yes">내용</div>
|
|
198
209
|
<div aria-disabled="1">내용</div>
|
|
199
210
|
|
|
@@ -252,12 +263,15 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
|
|
|
252
263
|
|
|
253
264
|
- `aria-expanded` - 토글 버튼, 확장 가능한 요소
|
|
254
265
|
- `aria-selected` - 탭, 선택 가능한 옵션
|
|
255
|
-
- `aria-checked` - 체크박스, 라디오
|
|
266
|
+
- `aria-checked` - 체크박스, 라디오 버튼, 스위치
|
|
256
267
|
- `aria-pressed` - 토글 버튼
|
|
257
268
|
- `aria-hidden` - 동적으로 표시/숨김되는 요소
|
|
258
269
|
- `aria-disabled` - 동적으로 활성화/비활성화되는 요소
|
|
259
270
|
- `aria-modal` - 다이얼로그, 모달
|
|
260
271
|
- `aria-current` - 현재 활성 항목 표시
|
|
272
|
+
- `aria-valuenow`, `aria-valuemin`, `aria-valuemax` - 수치 입력 위젯 (슬라이더, 프로그레스 바 등)
|
|
273
|
+
- `aria-level` - 계층 구조 레벨
|
|
274
|
+
- `aria-posinset`, `aria-setsize` - 세트 내 위치 및 전체 크기
|
|
261
275
|
|
|
262
276
|
## 지원하는 인터랙티브 역할
|
|
263
277
|
|
|
@@ -265,7 +279,7 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
|
|
|
265
279
|
|
|
266
280
|
- `button`, `tab`, `checkbox`, `radio`, `switch`
|
|
267
281
|
- `menuitem`, `menuitemcheckbox`, `menuitemradio`
|
|
268
|
-
- `option`, `treeitem`
|
|
282
|
+
- `option`, `treeitem`, `slider`, `progressbar`
|
|
269
283
|
- `dialog`, `alertdialog`
|
|
270
284
|
|
|
271
285
|
## 예제
|
package/README.md
CHANGED
|
@@ -73,10 +73,12 @@ Validates that ARIA **state** attributes are properly bound to component state a
|
|
|
73
73
|
### Rule 2: `static-aria-validator` (Static ARIA Validation)
|
|
74
74
|
|
|
75
75
|
Validates the **correctness** of ARIA attributes when they are used:
|
|
76
|
-
- Valid ARIA property names (catches typos)
|
|
77
|
-
- Correct values for boolean and enum ARIA attributes
|
|
78
|
-
- No conflicting ARIA attributes
|
|
79
|
-
- No redundant roles on native HTML elements
|
|
76
|
+
- Valid ARIA property names (catches typos with **auto-fix**)
|
|
77
|
+
- Correct values for boolean and enum ARIA attributes (**auto-fix** for "yes"/"no")
|
|
78
|
+
- No conflicting ARIA attributes (**auto-fix** available)
|
|
79
|
+
- No redundant roles on native HTML elements (**auto-fix** available)
|
|
80
|
+
- **Required ARIA attributes** based on roles (e.g., `aria-checked` for `switch`)
|
|
81
|
+
- **Role-attribute compatibility** (e.g., prevents `aria-expanded` on `img`)
|
|
80
82
|
|
|
81
83
|
**Note:** This plugin validates **how** ARIA attributes are used, not **whether** they should be used. For enforcing the presence of accessibility features, use [eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y).
|
|
82
84
|
|
|
@@ -126,16 +128,25 @@ Validates the **correctness** of ARIA attributes when they are used:
|
|
|
126
128
|
</div>
|
|
127
129
|
```
|
|
128
130
|
|
|
131
|
+
**Supported Patterns for Auto-fix:**
|
|
132
|
+
- `() => setX(!x)`
|
|
133
|
+
- `() => setX(true)`
|
|
134
|
+
- `() => setX(prev => !prev)`
|
|
135
|
+
|
|
129
136
|
### 3. Role-Based State Association Validation
|
|
130
137
|
|
|
131
|
-
**Detects:** Interactive roles with static ARIA states
|
|
138
|
+
**Detects:** Interactive roles with static ARIA states or missing required states.
|
|
132
139
|
|
|
133
140
|
```jsx
|
|
134
141
|
// ❌ Error: role="button" with static aria-pressed
|
|
135
142
|
<div role="button" aria-pressed="false">Button</div>
|
|
136
143
|
|
|
144
|
+
// ❌ Error: role="switch" missing aria-checked
|
|
145
|
+
<div role="switch">Toggle</div>
|
|
146
|
+
|
|
137
147
|
// ✅ Correct: Dynamic state binding
|
|
138
148
|
<div role="button" aria-pressed={isPressed ? 'true' : 'false'}>Button</div>
|
|
149
|
+
<div role="switch" aria-checked={isChecked ? 'true' : 'false'}>Toggle</div>
|
|
139
150
|
```
|
|
140
151
|
|
|
141
152
|
---
|
|
@@ -150,7 +161,7 @@ Validates the **correctness** of ARIA attributes when they are used:
|
|
|
150
161
|
// ❌ Error: Typo in aria-label
|
|
151
162
|
<button aria-labell="Close">X</button>
|
|
152
163
|
|
|
153
|
-
// ✅ Correct: Proper spelling
|
|
164
|
+
// ✅ Correct (Auto-fixed): Proper spelling
|
|
154
165
|
<button aria-label="Close">X</button>
|
|
155
166
|
```
|
|
156
167
|
|
|
@@ -171,7 +182,7 @@ Validates the **correctness** of ARIA attributes when they are used:
|
|
|
171
182
|
**Detects:** Incorrect boolean-like values
|
|
172
183
|
|
|
173
184
|
```jsx
|
|
174
|
-
// ❌ Error: Invalid boolean value
|
|
185
|
+
// ❌ Error: Invalid boolean value (Auto-fix available for "yes"/"no")
|
|
175
186
|
<div aria-hidden="yes">Content</div>
|
|
176
187
|
<div aria-disabled="1">Content</div>
|
|
177
188
|
|
|
@@ -230,12 +241,15 @@ The plugin validates these dynamic ARIA state attributes:
|
|
|
230
241
|
|
|
231
242
|
- `aria-expanded` - Toggle buttons, expandable elements
|
|
232
243
|
- `aria-selected` - Tabs, selectable options
|
|
233
|
-
- `aria-checked` - Checkboxes, radio buttons
|
|
244
|
+
- `aria-checked` - Checkboxes, radio buttons, switches
|
|
234
245
|
- `aria-pressed` - Toggle buttons
|
|
235
246
|
- `aria-hidden` - Dynamically shown/hidden elements
|
|
236
247
|
- `aria-disabled` - Dynamically enabled/disabled elements
|
|
237
248
|
- `aria-modal` - Dialogs, modals
|
|
238
249
|
- `aria-current` - Current active item indicators
|
|
250
|
+
- `aria-valuenow`, `aria-valuemin`, `aria-valuemax` - Range widgets (sliders, progress bars)
|
|
251
|
+
- `aria-level` - Hierarchical level
|
|
252
|
+
- `aria-posinset`, `aria-setsize` - Set position and size
|
|
239
253
|
|
|
240
254
|
## Supported Interactive Roles
|
|
241
255
|
|
|
@@ -243,7 +257,7 @@ Elements with these roles are expected to have dynamic ARIA states:
|
|
|
243
257
|
|
|
244
258
|
- `button`, `tab`, `checkbox`, `radio`, `switch`
|
|
245
259
|
- `menuitem`, `menuitemcheckbox`, `menuitemradio`
|
|
246
|
-
- `option`, `treeitem`
|
|
260
|
+
- `option`, `treeitem`, `slider`, `progressbar`
|
|
247
261
|
- `dialog`, `alertdialog`
|
|
248
262
|
|
|
249
263
|
## Examples
|
|
@@ -46,6 +46,30 @@ const DYNAMIC_ARIA_ATTRIBUTES = {
|
|
|
46
46
|
validValues: ["page", "step", "location", "date", "time", "true", "false"],
|
|
47
47
|
description: "Current active item indicator",
|
|
48
48
|
},
|
|
49
|
+
"aria-valuenow": {
|
|
50
|
+
requiresState: true,
|
|
51
|
+
description: "Current value for range widgets",
|
|
52
|
+
},
|
|
53
|
+
"aria-valuemin": {
|
|
54
|
+
requiresState: true,
|
|
55
|
+
description: "Minimum value for range widgets",
|
|
56
|
+
},
|
|
57
|
+
"aria-valuemax": {
|
|
58
|
+
requiresState: true,
|
|
59
|
+
description: "Maximum value for range widgets",
|
|
60
|
+
},
|
|
61
|
+
"aria-level": {
|
|
62
|
+
requiresState: true,
|
|
63
|
+
description: "Hierarchical level of an element",
|
|
64
|
+
},
|
|
65
|
+
"aria-posinset": {
|
|
66
|
+
requiresState: true,
|
|
67
|
+
description: "Position of an element in a set",
|
|
68
|
+
},
|
|
69
|
+
"aria-setsize": {
|
|
70
|
+
requiresState: true,
|
|
71
|
+
description: "Total size of a set",
|
|
72
|
+
},
|
|
49
73
|
};
|
|
50
74
|
|
|
51
75
|
const INTERACTIVE_ROLES = [
|
|
@@ -61,6 +85,13 @@ const INTERACTIVE_ROLES = [
|
|
|
61
85
|
"treeitem",
|
|
62
86
|
"dialog",
|
|
63
87
|
"alertdialog",
|
|
88
|
+
"slider",
|
|
89
|
+
"progressbar",
|
|
90
|
+
"scrollbar",
|
|
91
|
+
"spinbutton",
|
|
92
|
+
"tree",
|
|
93
|
+
"grid",
|
|
94
|
+
"listbox",
|
|
64
95
|
];
|
|
65
96
|
|
|
66
97
|
module.exports = {
|
|
@@ -224,51 +255,73 @@ module.exports = {
|
|
|
224
255
|
|
|
225
256
|
const expression = handlerAttr.value.expression;
|
|
226
257
|
|
|
227
|
-
//
|
|
258
|
+
// Direct call or arrow function/function expression
|
|
259
|
+
let functionBody = null;
|
|
228
260
|
if (
|
|
229
261
|
expression.type === "ArrowFunctionExpression" ||
|
|
230
262
|
expression.type === "FunctionExpression"
|
|
231
263
|
) {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
264
|
+
functionBody = expression.body;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!functionBody) return null;
|
|
268
|
+
|
|
269
|
+
// Find setState calls in the body
|
|
270
|
+
let stateVariable = null;
|
|
271
|
+
|
|
272
|
+
const checkCallExpression = (callExpr) => {
|
|
273
|
+
if (callExpr.type !== "CallExpression") return null;
|
|
274
|
+
|
|
275
|
+
// Simple case: setX(!x), setX(true), setX(false)
|
|
276
|
+
if (callExpr.arguments.length > 0) {
|
|
277
|
+
const arg = callExpr.arguments[0];
|
|
278
|
+
|
|
279
|
+
// Pattern: !x
|
|
280
|
+
if (arg.type === "UnaryExpression" && arg.operator === "!") {
|
|
281
|
+
if (arg.argument.type === "Identifier") {
|
|
282
|
+
return arg.argument.name;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Pattern: boolean literal (true/false)
|
|
287
|
+
if (arg.type === "Literal" && typeof arg.value === "boolean") {
|
|
288
|
+
// We can't easily know which state it's setting from just 'true',
|
|
289
|
+
// but we can look at the setter name if it follows 'setX' pattern
|
|
290
|
+
const callee = callExpr.callee;
|
|
291
|
+
if (callee.type === "Identifier" && callee.name.startsWith("set")) {
|
|
292
|
+
const varName = callee.name.slice(3);
|
|
293
|
+
return varName.charAt(0).toLowerCase() + varName.slice(1);
|
|
246
294
|
}
|
|
247
295
|
}
|
|
248
|
-
}
|
|
249
296
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
const arg = expr.arguments[0];
|
|
257
|
-
if (arg.type === "UnaryExpression" && arg.operator === "!") {
|
|
258
|
-
if (arg.argument.type === "Identifier") {
|
|
259
|
-
stateVariable = arg.argument.name;
|
|
260
|
-
break;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
297
|
+
// Pattern: prev => !prev (functional update)
|
|
298
|
+
if (arg.type === "ArrowFunctionExpression" && arg.body.type === "UnaryExpression" && arg.body.operator === "!") {
|
|
299
|
+
const callee = callExpr.callee;
|
|
300
|
+
if (callee.type === "Identifier" && callee.name.startsWith("set")) {
|
|
301
|
+
const varName = callee.name.slice(3);
|
|
302
|
+
return varName.charAt(0).toLowerCase() + varName.slice(1);
|
|
264
303
|
}
|
|
265
304
|
}
|
|
266
305
|
}
|
|
306
|
+
return null;
|
|
307
|
+
};
|
|
267
308
|
|
|
268
|
-
|
|
309
|
+
// Simple case: () => setX(!x)
|
|
310
|
+
if (functionBody.type === "CallExpression") {
|
|
311
|
+
stateVariable = checkCallExpression(functionBody);
|
|
269
312
|
}
|
|
270
313
|
|
|
271
|
-
|
|
314
|
+
// Block statement case: () => { setX(!x); }
|
|
315
|
+
if (functionBody.type === "BlockStatement" && functionBody.body.length > 0) {
|
|
316
|
+
for (const stmt of functionBody.body) {
|
|
317
|
+
if (stmt.type === "ExpressionStatement") {
|
|
318
|
+
stateVariable = checkCallExpression(stmt.expression);
|
|
319
|
+
if (stateVariable) break;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return stateVariable;
|
|
272
325
|
}
|
|
273
326
|
|
|
274
327
|
function createStringConversionFix(fixer, node) {
|
|
@@ -313,7 +366,9 @@ module.exports = {
|
|
|
313
366
|
|
|
314
367
|
if (
|
|
315
368
|
isBooleanIdentifier(attrValue) &&
|
|
316
|
-
!hasProperStringConversion(attrValue)
|
|
369
|
+
!hasProperStringConversion(attrValue) &&
|
|
370
|
+
DYNAMIC_ARIA_ATTRIBUTES[attrName].validValues &&
|
|
371
|
+
(DYNAMIC_ARIA_ATTRIBUTES[attrName].validValues.includes("true") || DYNAMIC_ARIA_ATTRIBUTES[attrName].validValues.includes("false"))
|
|
317
372
|
) {
|
|
318
373
|
context.report({
|
|
319
374
|
node: attr,
|
|
@@ -76,6 +76,56 @@ const REQUIRES_VALUE = new Set([
|
|
|
76
76
|
"aria-flowto",
|
|
77
77
|
]);
|
|
78
78
|
|
|
79
|
+
// Common ARIA typos and their correct versions
|
|
80
|
+
const ARIA_TYPOS = {
|
|
81
|
+
"aria-labell": "aria-label",
|
|
82
|
+
"aria-hidde": "aria-hidden",
|
|
83
|
+
"aria-expect": "aria-expanded",
|
|
84
|
+
"aria-describeb": "aria-describedby",
|
|
85
|
+
"aria-control": "aria-controls",
|
|
86
|
+
"aria-check": "aria-checked",
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Roles and their required/allowed attributes
|
|
90
|
+
const ROLE_DEFINITIONS = {
|
|
91
|
+
checkbox: {
|
|
92
|
+
required: ["aria-checked"],
|
|
93
|
+
allowed: ["aria-readonly", "aria-disabled"],
|
|
94
|
+
},
|
|
95
|
+
radio: {
|
|
96
|
+
required: ["aria-checked"],
|
|
97
|
+
allowed: ["aria-disabled"],
|
|
98
|
+
},
|
|
99
|
+
combobox: {
|
|
100
|
+
required: ["aria-controls", "aria-expanded"],
|
|
101
|
+
allowed: ["aria-haspopup", "aria-autocomplete", "aria-required"],
|
|
102
|
+
},
|
|
103
|
+
tab: {
|
|
104
|
+
required: ["aria-selected"],
|
|
105
|
+
allowed: ["aria-disabled"],
|
|
106
|
+
},
|
|
107
|
+
slider: {
|
|
108
|
+
required: ["aria-valuenow"],
|
|
109
|
+
allowed: ["aria-valuemin", "aria-valuemax", "aria-orientation", "aria-readonly"],
|
|
110
|
+
},
|
|
111
|
+
switch: {
|
|
112
|
+
required: ["aria-checked"],
|
|
113
|
+
allowed: ["aria-readonly", "aria-disabled"],
|
|
114
|
+
},
|
|
115
|
+
scrollbar: {
|
|
116
|
+
required: ["aria-valuenow", "aria-valuemin", "aria-valuemax", "aria-orientation"],
|
|
117
|
+
allowed: ["aria-controls"],
|
|
118
|
+
},
|
|
119
|
+
heading: {
|
|
120
|
+
required: ["aria-level"],
|
|
121
|
+
allowed: [],
|
|
122
|
+
},
|
|
123
|
+
img: {
|
|
124
|
+
required: [],
|
|
125
|
+
allowed: ["aria-label", "aria-labelledby"],
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
79
129
|
// Valid values for boolean-like ARIA attributes
|
|
80
130
|
const BOOLEAN_ARIA_VALUES = new Set(["true", "false"]);
|
|
81
131
|
|
|
@@ -130,7 +180,7 @@ module.exports = {
|
|
|
130
180
|
recommended: true,
|
|
131
181
|
url: "https://github.com/your-repo/eslint-plugin-aria-state-validator",
|
|
132
182
|
},
|
|
133
|
-
fixable:
|
|
183
|
+
fixable: "code",
|
|
134
184
|
schema: [],
|
|
135
185
|
messages: {
|
|
136
186
|
invalidAriaProp:
|
|
@@ -145,10 +195,17 @@ module.exports = {
|
|
|
145
195
|
"Do not use both aria-label and aria-labelledby. aria-labelledby takes precedence.",
|
|
146
196
|
redundantRole:
|
|
147
197
|
'Redundant role "{{role}}" on <{{element}}>. Native HTML semantics already convey this role.',
|
|
198
|
+
missingRequiredAria:
|
|
199
|
+
'Role "{{role}}" requires the following ARIA attributes: {{missing}}.',
|
|
200
|
+
disallowedAriaForRole:
|
|
201
|
+
'ARIA attribute "{{prop}}" is not recommended for role "{{role}}".',
|
|
202
|
+
ariaTypo:
|
|
203
|
+
'Invalid ARIA attribute "{{prop}}". Did you mean "{{correct}}"?',
|
|
148
204
|
},
|
|
149
205
|
},
|
|
150
206
|
|
|
151
207
|
create(context) {
|
|
208
|
+
const sourceCode = context.getSourceCode();
|
|
152
209
|
// Elements with implicit roles
|
|
153
210
|
const IMPLICIT_ROLES = {
|
|
154
211
|
a: "link", // when href present
|
|
@@ -170,7 +227,7 @@ module.exports = {
|
|
|
170
227
|
};
|
|
171
228
|
|
|
172
229
|
function getAttributeValue(attr) {
|
|
173
|
-
if (!attr.value) return null;
|
|
230
|
+
if (!attr || !attr.value) return null;
|
|
174
231
|
|
|
175
232
|
if (attr.value.type === "Literal") {
|
|
176
233
|
return attr.value.value;
|
|
@@ -242,10 +299,36 @@ module.exports = {
|
|
|
242
299
|
return {
|
|
243
300
|
JSXOpeningElement(node) {
|
|
244
301
|
const attributes = node.attributes || [];
|
|
302
|
+
const elementName = node.name.name;
|
|
245
303
|
|
|
246
304
|
// Check for redundant roles
|
|
247
305
|
checkRedundantRole(node);
|
|
248
306
|
|
|
307
|
+
const roleAttr = attributes.find(
|
|
308
|
+
(attr) => attr.type === "JSXAttribute" && attr.name.name === "role"
|
|
309
|
+
);
|
|
310
|
+
const roleValue = getAttributeValue(roleAttr);
|
|
311
|
+
|
|
312
|
+
// Required ARIA attributes for role
|
|
313
|
+
if (roleValue && ROLE_DEFINITIONS[roleValue]) {
|
|
314
|
+
const required = ROLE_DEFINITIONS[roleValue].required;
|
|
315
|
+
const present = attributes
|
|
316
|
+
.filter((attr) => attr.type === "JSXAttribute" && attr.name.name.startsWith("aria-"))
|
|
317
|
+
.map((attr) => attr.name.name);
|
|
318
|
+
|
|
319
|
+
const missing = required.filter((req) => !present.includes(req));
|
|
320
|
+
if (missing.length > 0) {
|
|
321
|
+
context.report({
|
|
322
|
+
node: roleAttr || node.name,
|
|
323
|
+
messageId: "missingRequiredAria",
|
|
324
|
+
data: {
|
|
325
|
+
role: roleValue,
|
|
326
|
+
missing: missing.join(", "),
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
249
332
|
// Check for conflicting aria-label and aria-labelledby
|
|
250
333
|
const hasAriaLabel = attributes.some(
|
|
251
334
|
(attr) =>
|
|
@@ -264,6 +347,9 @@ module.exports = {
|
|
|
264
347
|
context.report({
|
|
265
348
|
node: ariaLabelAttr,
|
|
266
349
|
messageId: "ariaLabelWithLabelledBy",
|
|
350
|
+
fix(fixer) {
|
|
351
|
+
return fixer.remove(ariaLabelAttr);
|
|
352
|
+
}
|
|
267
353
|
});
|
|
268
354
|
}
|
|
269
355
|
|
|
@@ -273,6 +359,22 @@ module.exports = {
|
|
|
273
359
|
|
|
274
360
|
const attrName = attr.name.name;
|
|
275
361
|
|
|
362
|
+
// Check for typos in aria- attributes
|
|
363
|
+
if (attrName.startsWith("aria-") && !VALID_ARIA_PROPS.has(attrName)) {
|
|
364
|
+
const correct = ARIA_TYPOS[attrName];
|
|
365
|
+
if (correct) {
|
|
366
|
+
context.report({
|
|
367
|
+
node: attr.name,
|
|
368
|
+
messageId: "ariaTypo",
|
|
369
|
+
data: { prop: attrName, correct: correct },
|
|
370
|
+
fix(fixer) {
|
|
371
|
+
return fixer.replaceText(attr.name, correct);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
276
378
|
// Only check aria-* attributes
|
|
277
379
|
if (!attrName.startsWith("aria-")) return;
|
|
278
380
|
|
|
@@ -286,6 +388,24 @@ module.exports = {
|
|
|
286
388
|
return;
|
|
287
389
|
}
|
|
288
390
|
|
|
391
|
+
// Check for role-attribute compatibility
|
|
392
|
+
if (roleValue && ROLE_DEFINITIONS[roleValue]) {
|
|
393
|
+
const definition = ROLE_DEFINITIONS[roleValue];
|
|
394
|
+
const allAllowed = [...definition.required, ...definition.allowed, "aria-label", "aria-labelledby", "aria-describedby", "aria-hidden", "aria-live", "aria-atomic", "aria-busy"];
|
|
395
|
+
|
|
396
|
+
if (!allAllowed.includes(attrName) && !attrName.startsWith("aria-own") && !attrName.startsWith("aria-control")) {
|
|
397
|
+
// This is a bit simplified, but good for catching obvious mismatches
|
|
398
|
+
// e.g., role="img" with aria-expanded
|
|
399
|
+
if (roleValue === "img" && attrName === "aria-expanded") {
|
|
400
|
+
context.report({
|
|
401
|
+
node: attr,
|
|
402
|
+
messageId: "disallowedAriaForRole",
|
|
403
|
+
data: { prop: attrName, role: roleValue },
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
289
409
|
const value = getAttributeValue(attr);
|
|
290
410
|
|
|
291
411
|
// Can't validate dynamic values
|
|
@@ -331,6 +451,11 @@ module.exports = {
|
|
|
331
451
|
node: attr,
|
|
332
452
|
messageId: "invalidBooleanValue",
|
|
333
453
|
data: { prop: attrName, value: String(value) },
|
|
454
|
+
fix(fixer) {
|
|
455
|
+
if (value === "yes" || value === "1") return fixer.replaceText(attr.value, '"true"');
|
|
456
|
+
if (value === "no" || value === "0") return fixer.replaceText(attr.value, '"false"');
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
334
459
|
});
|
|
335
460
|
return;
|
|
336
461
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-aria-state-validator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Comprehensive ESLint plugin for ARIA accessibility validation: dynamic state binding and static ARIA attributes correctness",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|