eslint-plugin-aria-state-validator 1.0.1 → 1.1.1
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 +70 -41
- package/README.md +57 -30
- package/index.js +49 -1
- package/lib/rules/state-dependent-aria-validator.js +339 -57
- package/lib/rules/static-aria-validator.js +151 -3
- package/package.json +8 -4
package/README.ko.md
CHANGED
|
@@ -41,18 +41,16 @@ npm install --save-dev eslint-plugin-aria-state-validator
|
|
|
41
41
|
import ariaStateValidator from "eslint-plugin-aria-state-validator";
|
|
42
42
|
|
|
43
43
|
export default [
|
|
44
|
-
|
|
45
|
-
plugins: {
|
|
46
|
-
"aria-state-validator": ariaStateValidator,
|
|
47
|
-
},
|
|
48
|
-
rules: {
|
|
49
|
-
"aria-state-validator/state-dependent-aria-validator": "warn",
|
|
50
|
-
"aria-state-validator/static-aria-validator": "warn",
|
|
51
|
-
},
|
|
52
|
-
},
|
|
44
|
+
...ariaStateValidator.configs["flat/recommended"],
|
|
53
45
|
];
|
|
54
46
|
```
|
|
55
47
|
|
|
48
|
+
사용 가능한 Flat preset:
|
|
49
|
+
- `flat/recommended`
|
|
50
|
+
- `flat/strict`
|
|
51
|
+
- `flat/dynamic-only`
|
|
52
|
+
- `flat/static-only`
|
|
53
|
+
|
|
56
54
|
### Legacy ESLintRC Config
|
|
57
55
|
|
|
58
56
|
```json
|
|
@@ -68,17 +66,17 @@ export default [
|
|
|
68
66
|
### 설정 프리셋
|
|
69
67
|
|
|
70
68
|
```javascript
|
|
71
|
-
//
|
|
72
|
-
...ariaStateValidator.configs
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
...ariaStateValidator.configs
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
69
|
+
// Flat Config용 preset
|
|
70
|
+
...ariaStateValidator.configs["flat/recommended"]
|
|
71
|
+
...ariaStateValidator.configs["flat/strict"]
|
|
72
|
+
...ariaStateValidator.configs["flat/dynamic-only"]
|
|
73
|
+
...ariaStateValidator.configs["flat/static-only"]
|
|
74
|
+
|
|
75
|
+
// Legacy eslintrc용 preset
|
|
76
|
+
extends: ["plugin:aria-state-validator/recommended"]
|
|
77
|
+
extends: ["plugin:aria-state-validator/strict"]
|
|
78
|
+
extends: ["plugin:aria-state-validator/dynamic-only"]
|
|
79
|
+
extends: ["plugin:aria-state-validator/static-only"]
|
|
82
80
|
```
|
|
83
81
|
|
|
84
82
|
## 두 가지 규칙으로 포괄적인 ARIA 검증
|
|
@@ -89,14 +87,38 @@ export default [
|
|
|
89
87
|
|
|
90
88
|
ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동적으로 업데이트되는지 검증합니다.
|
|
91
89
|
|
|
90
|
+
규칙 옵션:
|
|
91
|
+
- `interactiveHandlers` (`string[]`): 인터랙티브 핸들러로 간주할 이벤트 prop 목록 재정의 (기본값: `onClick`, `onKeyDown`, `onKeyPress`, `onChange`, `onToggle`)
|
|
92
|
+
- `strictBooleanInference` (`boolean`): `true`(기본값)면 불리언으로 추론되는 식별자만 경고, `false`면 식별자 바인딩 전체를 불리언 위험으로 간주
|
|
93
|
+
- `ignorePatterns` (`string[]`): 속성 이름 또는 값 소스 텍스트와 매칭되는 항목을 검사에서 제외할 정규식 패턴
|
|
94
|
+
|
|
95
|
+
예시:
|
|
96
|
+
|
|
97
|
+
```javascript
|
|
98
|
+
{
|
|
99
|
+
rules: {
|
|
100
|
+
"aria-state-validator/state-dependent-aria-validator": [
|
|
101
|
+
"warn",
|
|
102
|
+
{
|
|
103
|
+
interactiveHandlers: ["onClick", "onPointerDown"],
|
|
104
|
+
strictBooleanInference: true,
|
|
105
|
+
ignorePatterns: ["^aria-current$", "^\\{isOpen\\}$"],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
92
112
|
### 규칙 2: `static-aria-validator` (정적 ARIA 검증)
|
|
93
113
|
|
|
94
114
|
사용된 ARIA 속성의 **정확성**을 검증합니다:
|
|
95
115
|
|
|
96
|
-
- 유효한 ARIA 속성 이름 (오타 감지)
|
|
97
|
-
- Boolean 및 Enum ARIA 속성의 올바른 값
|
|
98
|
-
- 충돌하는 ARIA 속성 감지
|
|
99
|
-
- 네이티브 HTML 요소의 중복 role 감지
|
|
116
|
+
- 유효한 ARIA 속성 이름 (오타 감지 및 **자동 수정**)
|
|
117
|
+
- Boolean 및 Enum ARIA 속성의 올바른 값 (**자동 수정** 지원)
|
|
118
|
+
- 충돌하는 ARIA 속성 감지 (**자동 수정** 지원)
|
|
119
|
+
- 네이티브 HTML 요소의 중복 role 감지 (**자동 수정** 지원)
|
|
120
|
+
- **역할별 필수 ARIA 속성** 검증 (예: `switch` 역할의 `aria-checked` 필수 사용)
|
|
121
|
+
- **역할-속성 호환성** 검증 (예: `img` 역할에 `aria-expanded` 사용 방지)
|
|
100
122
|
|
|
101
123
|
**참고:** 이 플러그인은 ARIA 속성을 **어떻게** 사용하는지를 검증하며, **사용 여부**를 강제하지는 않습니다. 접근성 기능의 존재를 강제하려면 [eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y)를 사용하세요.
|
|
102
124
|
|
|
@@ -116,7 +138,7 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
|
|
|
116
138
|
<button aria-expanded={isOpen ? 'true' : 'false'}>토글</button>
|
|
117
139
|
```
|
|
118
140
|
|
|
119
|
-
|
|
141
|
+
**제안 제공:** 규칙이 문자열 형식(`'true'/'false'`)으로 바꾸는 안전한 제안을 제공합니다.
|
|
120
142
|
|
|
121
143
|
### 2. 정적 값 + 인터랙티브 핸들러 감지
|
|
122
144
|
|
|
@@ -134,30 +156,39 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
|
|
|
134
156
|
</button>
|
|
135
157
|
```
|
|
136
158
|
|
|
137
|
-
**스마트
|
|
159
|
+
**스마트 제안:** 핸들러에 상태 변수 참조가 있으면 (예: `() => setExpanded(!expanded)`), 플러그인이 변수 이름을 추출해 ARIA 속성 바인딩 제안을 제공합니다:
|
|
138
160
|
|
|
139
161
|
```jsx
|
|
140
|
-
// ❌
|
|
162
|
+
// ❌ 제안 전
|
|
141
163
|
<div onClick={() => setExpanded(!expanded)} aria-expanded="false">
|
|
142
164
|
클릭하여 확장
|
|
143
165
|
</div>
|
|
144
166
|
|
|
145
|
-
// ✅
|
|
167
|
+
// ✅ 제안 적용 후 (자동으로 'expanded' 변수 감지)
|
|
146
168
|
<div onClick={() => setExpanded(!expanded)} aria-expanded={expanded ? 'true' : 'false'}>
|
|
147
169
|
클릭하여 확장
|
|
148
170
|
</div>
|
|
149
171
|
```
|
|
150
172
|
|
|
173
|
+
**제안 지원 패턴:**
|
|
174
|
+
- `() => setX(!x)`
|
|
175
|
+
- `() => setX(true)`
|
|
176
|
+
- `() => setX(prev => !prev)`
|
|
177
|
+
|
|
151
178
|
### 3. 역할 기반 상태 연관성 검증
|
|
152
179
|
|
|
153
|
-
**감지 내용:**
|
|
180
|
+
**감지 내용:** 인터랙티브 역할(Role)에 정적인 ARIA 상태를 사용하거나 필수 속성이 누락된 경우.
|
|
154
181
|
|
|
155
182
|
```jsx
|
|
156
183
|
// ❌ 오류: role="button"인데 aria-pressed가 정적
|
|
157
184
|
<div role="button" aria-pressed="false">버튼</div>
|
|
158
185
|
|
|
186
|
+
// ❌ 오류: 인터랙티브 switch role에서 aria-checked 누락
|
|
187
|
+
<div role="switch" onClick={toggle}>토글</div>
|
|
188
|
+
|
|
159
189
|
// ✅ 올바름: 동적 상태 바인딩
|
|
160
190
|
<div role="button" aria-pressed={isPressed ? 'true' : 'false'}>버튼</div>
|
|
191
|
+
<div role="switch" aria-checked={isChecked ? 'true' : 'false'}>토글</div>
|
|
161
192
|
```
|
|
162
193
|
|
|
163
194
|
---
|
|
@@ -193,7 +224,7 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
|
|
|
193
224
|
**감지 내용:** 잘못된 boolean 형식 값
|
|
194
225
|
|
|
195
226
|
```jsx
|
|
196
|
-
// ❌ 오류: 유효하지 않은 boolean 값
|
|
227
|
+
// ❌ 오류: 유효하지 않은 boolean 값 ("yes"/"no"는 자동 수정 지원)
|
|
197
228
|
<div aria-hidden="yes">내용</div>
|
|
198
229
|
<div aria-disabled="1">내용</div>
|
|
199
230
|
|
|
@@ -252,12 +283,15 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
|
|
|
252
283
|
|
|
253
284
|
- `aria-expanded` - 토글 버튼, 확장 가능한 요소
|
|
254
285
|
- `aria-selected` - 탭, 선택 가능한 옵션
|
|
255
|
-
- `aria-checked` - 체크박스, 라디오
|
|
286
|
+
- `aria-checked` - 체크박스, 라디오 버튼, 스위치
|
|
256
287
|
- `aria-pressed` - 토글 버튼
|
|
257
288
|
- `aria-hidden` - 동적으로 표시/숨김되는 요소
|
|
258
289
|
- `aria-disabled` - 동적으로 활성화/비활성화되는 요소
|
|
259
290
|
- `aria-modal` - 다이얼로그, 모달
|
|
260
291
|
- `aria-current` - 현재 활성 항목 표시
|
|
292
|
+
- `aria-valuenow`, `aria-valuemin`, `aria-valuemax` - 수치 입력 위젯 (슬라이더, 프로그레스 바 등)
|
|
293
|
+
- `aria-level` - 계층 구조 레벨
|
|
294
|
+
- `aria-posinset`, `aria-setsize` - 세트 내 위치 및 전체 크기
|
|
261
295
|
|
|
262
296
|
## 지원하는 인터랙티브 역할
|
|
263
297
|
|
|
@@ -265,7 +299,7 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
|
|
|
265
299
|
|
|
266
300
|
- `button`, `tab`, `checkbox`, `radio`, `switch`
|
|
267
301
|
- `menuitem`, `menuitemcheckbox`, `menuitemradio`
|
|
268
|
-
- `option`, `treeitem`
|
|
302
|
+
- `option`, `treeitem`, `slider`, `progressbar`
|
|
269
303
|
- `dialog`, `alertdialog`
|
|
270
304
|
|
|
271
305
|
## 예제
|
|
@@ -315,13 +349,14 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
|
|
|
315
349
|
|
|
316
350
|
- **기반:** ESLint 플러그인 (Node.js 환경)
|
|
317
351
|
- **파서:** JSX/TSX AST 파싱을 위한 `@babel/eslint-parser` 또는 TypeScript 파서
|
|
352
|
+
- **ARIA 데이터 소스:** 역할-속성 호환성 검증을 위한 `aria-query` 정의 데이터
|
|
318
353
|
- **분석:** JSX 요소에 대한 Visitor 패턴
|
|
319
354
|
|
|
320
355
|
### 분석 로직
|
|
321
356
|
|
|
322
357
|
1. **JSXElement 순회:** `role` 및 `aria-*` 속성 식별
|
|
323
|
-
2.
|
|
324
|
-
3. **상태
|
|
358
|
+
2. **패턴 분석:** JSX 표현식에서 불리언 유사 바인딩과 정적 값 패턴 감지
|
|
359
|
+
3. **상태 추론 휴리스틱:** 핸들러/세터 패턴(예: `setX(!x)`)에서 상태 변수 추론
|
|
325
360
|
4. **패턴 검증:** 올바른 문자열 변환 패턴 확인
|
|
326
361
|
|
|
327
362
|
## 기대 효과
|
|
@@ -332,7 +367,7 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
|
|
|
332
367
|
|
|
333
368
|
### 2. 개발자 생산성
|
|
334
369
|
|
|
335
|
-
|
|
370
|
+
반복적인 ARIA 상태 바인딩 오류에 대해 타겟 제안 제공
|
|
336
371
|
|
|
337
372
|
### 3. 표준화
|
|
338
373
|
|
|
@@ -346,12 +381,6 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
|
|
|
346
381
|
npm test
|
|
347
382
|
```
|
|
348
383
|
|
|
349
|
-
### 빌드
|
|
350
|
-
|
|
351
|
-
```bash
|
|
352
|
-
npm run build # 해당되는 경우
|
|
353
|
-
```
|
|
354
|
-
|
|
355
384
|
## 기여
|
|
356
385
|
|
|
357
386
|
기여는 언제나 환영합니다! 이슈나 풀 리퀘스트를 자유롭게 제출해주세요.
|
package/README.md
CHANGED
|
@@ -40,17 +40,16 @@ npm install --save-dev eslint-plugin-aria-state-validator
|
|
|
40
40
|
import ariaStateValidator from 'eslint-plugin-aria-state-validator';
|
|
41
41
|
|
|
42
42
|
export default [
|
|
43
|
-
|
|
44
|
-
plugins: {
|
|
45
|
-
'aria-state-validator': ariaStateValidator
|
|
46
|
-
},
|
|
47
|
-
rules: {
|
|
48
|
-
'aria-state-validator/state-dependent-aria-validator': 'warn'
|
|
49
|
-
}
|
|
50
|
-
}
|
|
43
|
+
...ariaStateValidator.configs['flat/recommended']
|
|
51
44
|
];
|
|
52
45
|
```
|
|
53
46
|
|
|
47
|
+
Available flat presets:
|
|
48
|
+
- `flat/recommended`
|
|
49
|
+
- `flat/strict`
|
|
50
|
+
- `flat/dynamic-only`
|
|
51
|
+
- `flat/static-only`
|
|
52
|
+
|
|
54
53
|
### Legacy ESLintRC Config
|
|
55
54
|
|
|
56
55
|
```json
|
|
@@ -70,13 +69,34 @@ This plugin provides two complementary rules:
|
|
|
70
69
|
|
|
71
70
|
Validates that ARIA **state** attributes are properly bound to component state and update dynamically.
|
|
72
71
|
|
|
72
|
+
Rule options:
|
|
73
|
+
- `interactiveHandlers` (`string[]`): Override which event props are treated as interactive (default: `onClick`, `onKeyDown`, `onKeyPress`, `onChange`, `onToggle`)
|
|
74
|
+
- `strictBooleanInference` (`boolean`): When `true` (default), only flags identifiers inferred as boolean-like; when `false`, treats any identifier binding as boolean-risk
|
|
75
|
+
- `ignorePatterns` (`string[]`): Regex patterns to skip checks for matching attribute names or value source text
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
|
|
79
|
+
```javascript
|
|
80
|
+
{
|
|
81
|
+
rules: {
|
|
82
|
+
'aria-state-validator/state-dependent-aria-validator': ['warn', {
|
|
83
|
+
interactiveHandlers: ['onClick', 'onPointerDown'],
|
|
84
|
+
strictBooleanInference: true,
|
|
85
|
+
ignorePatterns: ['^aria-current$', '^\\{isOpen\\}$']
|
|
86
|
+
}]
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
73
91
|
### Rule 2: `static-aria-validator` (Static ARIA Validation)
|
|
74
92
|
|
|
75
93
|
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
|
|
94
|
+
- Valid ARIA property names (catches typos with **auto-fix**)
|
|
95
|
+
- Correct values for boolean and enum ARIA attributes (**auto-fix** for "yes"/"no")
|
|
96
|
+
- No conflicting ARIA attributes (**auto-fix** available)
|
|
97
|
+
- No redundant roles on native HTML elements (**auto-fix** available)
|
|
98
|
+
- **Required ARIA attributes** based on roles (e.g., `aria-checked` for `switch`)
|
|
99
|
+
- **Role-attribute compatibility** (e.g., prevents `aria-expanded` on `img`)
|
|
80
100
|
|
|
81
101
|
**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
102
|
|
|
@@ -94,7 +114,7 @@ Validates the **correctness** of ARIA attributes when they are used:
|
|
|
94
114
|
<button aria-expanded={isOpen ? 'true' : 'false'}>Toggle</button>
|
|
95
115
|
```
|
|
96
116
|
|
|
97
|
-
**
|
|
117
|
+
**Suggestion Available:** The rule provides a safe suggestion to convert bindings to string format (`'true'/'false'`).
|
|
98
118
|
|
|
99
119
|
### 2. Static Value + Interactive Handler Detection
|
|
100
120
|
|
|
@@ -112,30 +132,39 @@ Validates the **correctness** of ARIA attributes when they are used:
|
|
|
112
132
|
</button>
|
|
113
133
|
```
|
|
114
134
|
|
|
115
|
-
**Smart
|
|
135
|
+
**Smart Suggestion:** When the handler contains state variable references (e.g., `() => setExpanded(!expanded)`), the plugin can suggest binding the inferred variable to the ARIA attribute:
|
|
116
136
|
|
|
117
137
|
```jsx
|
|
118
|
-
// ❌ Before
|
|
138
|
+
// ❌ Before suggestion
|
|
119
139
|
<div onClick={() => setExpanded(!expanded)} aria-expanded="false">
|
|
120
140
|
Click to expand
|
|
121
141
|
</div>
|
|
122
142
|
|
|
123
|
-
// ✅
|
|
143
|
+
// ✅ Suggested fix (automatically detects 'expanded' variable)
|
|
124
144
|
<div onClick={() => setExpanded(!expanded)} aria-expanded={expanded ? 'true' : 'false'}>
|
|
125
145
|
Click to expand
|
|
126
146
|
</div>
|
|
127
147
|
```
|
|
128
148
|
|
|
149
|
+
**Supported Patterns for Suggestions:**
|
|
150
|
+
- `() => setX(!x)`
|
|
151
|
+
- `() => setX(true)`
|
|
152
|
+
- `() => setX(prev => !prev)`
|
|
153
|
+
|
|
129
154
|
### 3. Role-Based State Association Validation
|
|
130
155
|
|
|
131
|
-
**Detects:** Interactive roles with static ARIA states
|
|
156
|
+
**Detects:** Interactive roles with static ARIA states or missing required states.
|
|
132
157
|
|
|
133
158
|
```jsx
|
|
134
159
|
// ❌ Error: role="button" with static aria-pressed
|
|
135
160
|
<div role="button" aria-pressed="false">Button</div>
|
|
136
161
|
|
|
162
|
+
// ❌ Error: interactive switch role missing aria-checked
|
|
163
|
+
<div role="switch" onClick={toggle}>Toggle</div>
|
|
164
|
+
|
|
137
165
|
// ✅ Correct: Dynamic state binding
|
|
138
166
|
<div role="button" aria-pressed={isPressed ? 'true' : 'false'}>Button</div>
|
|
167
|
+
<div role="switch" aria-checked={isChecked ? 'true' : 'false'}>Toggle</div>
|
|
139
168
|
```
|
|
140
169
|
|
|
141
170
|
---
|
|
@@ -150,7 +179,7 @@ Validates the **correctness** of ARIA attributes when they are used:
|
|
|
150
179
|
// ❌ Error: Typo in aria-label
|
|
151
180
|
<button aria-labell="Close">X</button>
|
|
152
181
|
|
|
153
|
-
// ✅ Correct: Proper spelling
|
|
182
|
+
// ✅ Correct (Auto-fixed): Proper spelling
|
|
154
183
|
<button aria-label="Close">X</button>
|
|
155
184
|
```
|
|
156
185
|
|
|
@@ -171,7 +200,7 @@ Validates the **correctness** of ARIA attributes when they are used:
|
|
|
171
200
|
**Detects:** Incorrect boolean-like values
|
|
172
201
|
|
|
173
202
|
```jsx
|
|
174
|
-
// ❌ Error: Invalid boolean value
|
|
203
|
+
// ❌ Error: Invalid boolean value (Auto-fix available for "yes"/"no")
|
|
175
204
|
<div aria-hidden="yes">Content</div>
|
|
176
205
|
<div aria-disabled="1">Content</div>
|
|
177
206
|
|
|
@@ -230,12 +259,15 @@ The plugin validates these dynamic ARIA state attributes:
|
|
|
230
259
|
|
|
231
260
|
- `aria-expanded` - Toggle buttons, expandable elements
|
|
232
261
|
- `aria-selected` - Tabs, selectable options
|
|
233
|
-
- `aria-checked` - Checkboxes, radio buttons
|
|
262
|
+
- `aria-checked` - Checkboxes, radio buttons, switches
|
|
234
263
|
- `aria-pressed` - Toggle buttons
|
|
235
264
|
- `aria-hidden` - Dynamically shown/hidden elements
|
|
236
265
|
- `aria-disabled` - Dynamically enabled/disabled elements
|
|
237
266
|
- `aria-modal` - Dialogs, modals
|
|
238
267
|
- `aria-current` - Current active item indicators
|
|
268
|
+
- `aria-valuenow`, `aria-valuemin`, `aria-valuemax` - Range widgets (sliders, progress bars)
|
|
269
|
+
- `aria-level` - Hierarchical level
|
|
270
|
+
- `aria-posinset`, `aria-setsize` - Set position and size
|
|
239
271
|
|
|
240
272
|
## Supported Interactive Roles
|
|
241
273
|
|
|
@@ -243,7 +275,7 @@ Elements with these roles are expected to have dynamic ARIA states:
|
|
|
243
275
|
|
|
244
276
|
- `button`, `tab`, `checkbox`, `radio`, `switch`
|
|
245
277
|
- `menuitem`, `menuitemcheckbox`, `menuitemradio`
|
|
246
|
-
- `option`, `treeitem`
|
|
278
|
+
- `option`, `treeitem`, `slider`, `progressbar`
|
|
247
279
|
- `dialog`, `alertdialog`
|
|
248
280
|
|
|
249
281
|
## Examples
|
|
@@ -293,13 +325,14 @@ Elements with these roles are expected to have dynamic ARIA states:
|
|
|
293
325
|
|
|
294
326
|
- **Base:** ESLint plugin (Node.js environment)
|
|
295
327
|
- **Parser:** `@babel/eslint-parser` or TypeScript parser for JSX/TSX AST parsing
|
|
328
|
+
- **ARIA Data Source:** `aria-query` role/property definitions for role-attribute compatibility
|
|
296
329
|
- **Analysis:** Visitor pattern on JSX elements
|
|
297
330
|
|
|
298
331
|
### Analysis Logic
|
|
299
332
|
|
|
300
333
|
1. **JSXElement Traversal:** Identifies `role` and `aria-*` attributes
|
|
301
|
-
2. **
|
|
302
|
-
3. **State Inference:**
|
|
334
|
+
2. **Pattern Analysis:** Detects boolean-like bindings and static-value patterns in JSX expressions
|
|
335
|
+
3. **State Inference Heuristics:** Infers likely state variables from handler/setter patterns (e.g., `setX(!x)`)
|
|
303
336
|
4. **Pattern Validation:** Checks for proper string conversion patterns
|
|
304
337
|
|
|
305
338
|
## Expected Benefits
|
|
@@ -310,7 +343,7 @@ Catch dynamic ARIA errors at build/CI phase instead of runtime
|
|
|
310
343
|
|
|
311
344
|
### 2. Developer Productivity
|
|
312
345
|
|
|
313
|
-
|
|
346
|
+
Apply targeted suggestions for repetitive ARIA state binding errors
|
|
314
347
|
|
|
315
348
|
### 3. Standardization
|
|
316
349
|
|
|
@@ -324,12 +357,6 @@ Enforce consistent dynamic ARIA usage patterns across projects
|
|
|
324
357
|
npm test
|
|
325
358
|
```
|
|
326
359
|
|
|
327
|
-
### Building
|
|
328
|
-
|
|
329
|
-
```bash
|
|
330
|
-
npm run build # If applicable
|
|
331
|
-
```
|
|
332
|
-
|
|
333
360
|
## Contributing
|
|
334
361
|
|
|
335
362
|
Contributions welcome! Please feel free to submit issues or pull requests.
|
package/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const stateDependentAriaValidator = require('./lib/rules/state-dependent-aria-validator');
|
|
2
2
|
const staticAriaValidator = require('./lib/rules/static-aria-validator');
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
const plugin = {
|
|
5
5
|
rules: {
|
|
6
6
|
'state-dependent-aria-validator': stateDependentAriaValidator,
|
|
7
7
|
'static-aria-validator': staticAriaValidator
|
|
@@ -35,3 +35,51 @@ module.exports = {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
};
|
|
38
|
+
|
|
39
|
+
plugin.configs['flat/recommended'] = [
|
|
40
|
+
{
|
|
41
|
+
plugins: {
|
|
42
|
+
'aria-state-validator': plugin
|
|
43
|
+
},
|
|
44
|
+
rules: {
|
|
45
|
+
'aria-state-validator/state-dependent-aria-validator': 'warn',
|
|
46
|
+
'aria-state-validator/static-aria-validator': 'warn'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
plugin.configs['flat/strict'] = [
|
|
52
|
+
{
|
|
53
|
+
plugins: {
|
|
54
|
+
'aria-state-validator': plugin
|
|
55
|
+
},
|
|
56
|
+
rules: {
|
|
57
|
+
'aria-state-validator/state-dependent-aria-validator': 'error',
|
|
58
|
+
'aria-state-validator/static-aria-validator': 'error'
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
plugin.configs['flat/dynamic-only'] = [
|
|
64
|
+
{
|
|
65
|
+
plugins: {
|
|
66
|
+
'aria-state-validator': plugin
|
|
67
|
+
},
|
|
68
|
+
rules: {
|
|
69
|
+
'aria-state-validator/state-dependent-aria-validator': 'warn'
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
plugin.configs['flat/static-only'] = [
|
|
75
|
+
{
|
|
76
|
+
plugins: {
|
|
77
|
+
'aria-state-validator': plugin
|
|
78
|
+
},
|
|
79
|
+
rules: {
|
|
80
|
+
'aria-state-validator/static-aria-validator': 'warn'
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
module.exports = plugin;
|
|
@@ -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,36 @@ 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",
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
const ROLE_REQUIRED_DYNAMIC_ARIA = {
|
|
98
|
+
switch: "aria-checked",
|
|
99
|
+
checkbox: "aria-checked",
|
|
100
|
+
radio: "aria-checked",
|
|
101
|
+
menuitemcheckbox: "aria-checked",
|
|
102
|
+
menuitemradio: "aria-checked",
|
|
103
|
+
tab: "aria-selected",
|
|
104
|
+
option: "aria-selected",
|
|
105
|
+
treeitem: "aria-selected",
|
|
106
|
+
slider: "aria-valuenow",
|
|
107
|
+
progressbar: "aria-valuenow",
|
|
108
|
+
scrollbar: "aria-valuenow",
|
|
109
|
+
spinbutton: "aria-valuenow",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const DEFAULT_INTERACTIVE_HANDLERS = [
|
|
113
|
+
"onClick",
|
|
114
|
+
"onKeyDown",
|
|
115
|
+
"onKeyPress",
|
|
116
|
+
"onChange",
|
|
117
|
+
"onToggle",
|
|
64
118
|
];
|
|
65
119
|
|
|
66
120
|
module.exports = {
|
|
@@ -71,10 +125,28 @@ module.exports = {
|
|
|
71
125
|
"Validate that ARIA state attributes are properly bound to component state",
|
|
72
126
|
category: "Accessibility",
|
|
73
127
|
recommended: true,
|
|
74
|
-
url: "https://github.com/
|
|
128
|
+
url: "https://github.com/comento/eslint-plugin-aria-state-validator",
|
|
75
129
|
},
|
|
76
|
-
|
|
77
|
-
schema: [
|
|
130
|
+
hasSuggestions: true,
|
|
131
|
+
schema: [
|
|
132
|
+
{
|
|
133
|
+
type: "object",
|
|
134
|
+
properties: {
|
|
135
|
+
interactiveHandlers: {
|
|
136
|
+
type: "array",
|
|
137
|
+
items: { type: "string" },
|
|
138
|
+
},
|
|
139
|
+
strictBooleanInference: {
|
|
140
|
+
type: "boolean",
|
|
141
|
+
},
|
|
142
|
+
ignorePatterns: {
|
|
143
|
+
type: "array",
|
|
144
|
+
items: { type: "string" },
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
additionalProperties: false,
|
|
148
|
+
},
|
|
149
|
+
],
|
|
78
150
|
messages: {
|
|
79
151
|
booleanBinding:
|
|
80
152
|
'ARIA attribute "{{ariaAttr}}" should not directly bind Boolean values. Convert to string.',
|
|
@@ -84,10 +156,152 @@ module.exports = {
|
|
|
84
156
|
'Element with role="{{role}}" should have "{{ariaAttr}}" attribute bound to dynamic state.',
|
|
85
157
|
invalidStaticValue:
|
|
86
158
|
'Static value "{{value}}" for "{{ariaAttr}}" should be managed with dynamic state.',
|
|
159
|
+
suggestStringConversion:
|
|
160
|
+
'Convert "{{ariaAttr}}" binding to explicit string ("true"/"false") conversion.',
|
|
161
|
+
suggestStateBinding:
|
|
162
|
+
'Replace static "{{ariaAttr}}" value with inferred state binding.',
|
|
87
163
|
},
|
|
88
164
|
},
|
|
89
165
|
|
|
90
166
|
create(context) {
|
|
167
|
+
const sourceCode = context.getSourceCode();
|
|
168
|
+
const options = context.options[0] || {};
|
|
169
|
+
const strictBooleanInference = options.strictBooleanInference !== false;
|
|
170
|
+
const interactiveHandlers =
|
|
171
|
+
Array.isArray(options.interactiveHandlers) &&
|
|
172
|
+
options.interactiveHandlers.length > 0
|
|
173
|
+
? options.interactiveHandlers
|
|
174
|
+
: DEFAULT_INTERACTIVE_HANDLERS;
|
|
175
|
+
const ignoreRegexes = Array.isArray(options.ignorePatterns)
|
|
176
|
+
? options.ignorePatterns
|
|
177
|
+
.map((pattern) => {
|
|
178
|
+
try {
|
|
179
|
+
return new RegExp(pattern);
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
.filter(Boolean)
|
|
185
|
+
: [];
|
|
186
|
+
|
|
187
|
+
function matchesIgnorePattern(text) {
|
|
188
|
+
if (!text || ignoreRegexes.length === 0) return false;
|
|
189
|
+
return ignoreRegexes.some((regex) => regex.test(text));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function getVariableFromScope(identifierNode) {
|
|
193
|
+
if (!identifierNode || identifierNode.type !== "Identifier") return null;
|
|
194
|
+
if (!sourceCode.getScope) return null;
|
|
195
|
+
|
|
196
|
+
const scope = sourceCode.getScope(identifierNode);
|
|
197
|
+
let currentScope = scope;
|
|
198
|
+
|
|
199
|
+
while (currentScope) {
|
|
200
|
+
if (currentScope.set && currentScope.set.has(identifierNode.name)) {
|
|
201
|
+
return currentScope.set.get(identifierNode.name);
|
|
202
|
+
}
|
|
203
|
+
currentScope = currentScope.upper;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isBooleanishExpression(expr) {
|
|
210
|
+
if (!expr) return false;
|
|
211
|
+
|
|
212
|
+
if (expr.type === "Literal" && typeof expr.value === "boolean") {
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (
|
|
217
|
+
expr.type === "UnaryExpression" &&
|
|
218
|
+
(expr.operator === "!" || expr.operator === "!!")
|
|
219
|
+
) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (
|
|
224
|
+
expr.type === "BinaryExpression" &&
|
|
225
|
+
["==", "===", "!=", "!==", "<", "<=", ">", ">="].includes(expr.operator)
|
|
226
|
+
) {
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (expr.type === "LogicalExpression") {
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (
|
|
235
|
+
expr.type === "CallExpression" &&
|
|
236
|
+
expr.callee.type === "Identifier" &&
|
|
237
|
+
expr.callee.name === "Boolean"
|
|
238
|
+
) {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (expr.type === "ConditionalExpression") {
|
|
243
|
+
return (
|
|
244
|
+
isBooleanishExpression(expr.consequent) &&
|
|
245
|
+
isBooleanishExpression(expr.alternate)
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isIdentifierFromBooleanState(variable, identifierName) {
|
|
253
|
+
if (!variable || !Array.isArray(variable.defs)) return false;
|
|
254
|
+
|
|
255
|
+
return variable.defs.some((def) => {
|
|
256
|
+
const declarator = def.node;
|
|
257
|
+
if (!declarator || declarator.type !== "VariableDeclarator") return false;
|
|
258
|
+
|
|
259
|
+
if (
|
|
260
|
+
declarator.id &&
|
|
261
|
+
declarator.id.type === "ArrayPattern" &&
|
|
262
|
+
declarator.init &&
|
|
263
|
+
declarator.init.type === "CallExpression" &&
|
|
264
|
+
declarator.init.callee &&
|
|
265
|
+
declarator.init.callee.type === "Identifier" &&
|
|
266
|
+
declarator.init.callee.name === "useState"
|
|
267
|
+
) {
|
|
268
|
+
const hasIdentifier = declarator.id.elements.some(
|
|
269
|
+
(element) =>
|
|
270
|
+
element &&
|
|
271
|
+
element.type === "Identifier" &&
|
|
272
|
+
element.name === identifierName
|
|
273
|
+
);
|
|
274
|
+
if (!hasIdentifier) return false;
|
|
275
|
+
|
|
276
|
+
const firstArg = declarator.init.arguments[0];
|
|
277
|
+
return Boolean(
|
|
278
|
+
firstArg &&
|
|
279
|
+
((firstArg.type === "Literal" &&
|
|
280
|
+
typeof firstArg.value === "boolean") ||
|
|
281
|
+
isBooleanishExpression(firstArg))
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return Boolean(
|
|
286
|
+
declarator.id &&
|
|
287
|
+
declarator.id.type === "Identifier" &&
|
|
288
|
+
declarator.id.name === identifierName &&
|
|
289
|
+
isBooleanishExpression(declarator.init)
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function hasBooleanLikeName(name) {
|
|
295
|
+
if (!name) return false;
|
|
296
|
+
|
|
297
|
+
const strictBooleanPatterns = [
|
|
298
|
+
/^(is|has|can|should|did|will|was|were)[A-Z_]/,
|
|
299
|
+
/^(open|opened|expanded|selected|checked|pressed|hidden|disabled|active|visible|loading|mounted)$/i,
|
|
300
|
+
];
|
|
301
|
+
|
|
302
|
+
return strictBooleanPatterns.some((pattern) => pattern.test(name));
|
|
303
|
+
}
|
|
304
|
+
|
|
91
305
|
function isStaticValue(node) {
|
|
92
306
|
if (!node) return false;
|
|
93
307
|
|
|
@@ -123,7 +337,17 @@ module.exports = {
|
|
|
123
337
|
const expression = node.expression;
|
|
124
338
|
|
|
125
339
|
if (expression.type === "Identifier") {
|
|
126
|
-
|
|
340
|
+
if (matchesIgnorePattern(expression.name)) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
if (!strictBooleanInference) {
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
const variable = getVariableFromScope(expression);
|
|
347
|
+
return (
|
|
348
|
+
hasBooleanLikeName(expression.name) ||
|
|
349
|
+
isIdentifierFromBooleanState(variable, expression.name)
|
|
350
|
+
);
|
|
127
351
|
}
|
|
128
352
|
|
|
129
353
|
if (expression.type === "LogicalExpression") {
|
|
@@ -181,14 +405,6 @@ module.exports = {
|
|
|
181
405
|
}
|
|
182
406
|
|
|
183
407
|
function hasInteractiveHandler(attributes) {
|
|
184
|
-
const interactiveHandlers = [
|
|
185
|
-
"onClick",
|
|
186
|
-
"onKeyDown",
|
|
187
|
-
"onKeyPress",
|
|
188
|
-
"onChange",
|
|
189
|
-
"onToggle",
|
|
190
|
-
];
|
|
191
|
-
|
|
192
408
|
return attributes.some((attr) => {
|
|
193
409
|
if (attr.type !== "JSXAttribute") return false;
|
|
194
410
|
const name = attr.name.name;
|
|
@@ -224,55 +440,76 @@ module.exports = {
|
|
|
224
440
|
|
|
225
441
|
const expression = handlerAttr.value.expression;
|
|
226
442
|
|
|
227
|
-
//
|
|
443
|
+
// Direct call or arrow function/function expression
|
|
444
|
+
let functionBody = null;
|
|
228
445
|
if (
|
|
229
446
|
expression.type === "ArrowFunctionExpression" ||
|
|
230
447
|
expression.type === "FunctionExpression"
|
|
231
448
|
) {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
449
|
+
functionBody = expression.body;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (!functionBody) return null;
|
|
453
|
+
|
|
454
|
+
// Find setState calls in the body
|
|
455
|
+
let stateVariable = null;
|
|
456
|
+
|
|
457
|
+
const checkCallExpression = (callExpr) => {
|
|
458
|
+
if (callExpr.type !== "CallExpression") return null;
|
|
459
|
+
|
|
460
|
+
// Simple case: setX(!x), setX(true), setX(false)
|
|
461
|
+
if (callExpr.arguments.length > 0) {
|
|
462
|
+
const arg = callExpr.arguments[0];
|
|
463
|
+
|
|
464
|
+
// Pattern: !x
|
|
465
|
+
if (arg.type === "UnaryExpression" && arg.operator === "!") {
|
|
466
|
+
if (arg.argument.type === "Identifier") {
|
|
467
|
+
return arg.argument.name;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Pattern: boolean literal (true/false)
|
|
472
|
+
if (arg.type === "Literal" && typeof arg.value === "boolean") {
|
|
473
|
+
// We can't easily know which state it's setting from just 'true',
|
|
474
|
+
// but we can look at the setter name if it follows 'setX' pattern
|
|
475
|
+
const callee = callExpr.callee;
|
|
476
|
+
if (callee.type === "Identifier" && callee.name.startsWith("set")) {
|
|
477
|
+
const varName = callee.name.slice(3);
|
|
478
|
+
return varName.charAt(0).toLowerCase() + varName.slice(1);
|
|
246
479
|
}
|
|
247
480
|
}
|
|
248
|
-
}
|
|
249
481
|
|
|
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
|
-
}
|
|
482
|
+
// Pattern: prev => !prev (functional update)
|
|
483
|
+
if (arg.type === "ArrowFunctionExpression" && arg.body.type === "UnaryExpression" && arg.body.operator === "!") {
|
|
484
|
+
const callee = callExpr.callee;
|
|
485
|
+
if (callee.type === "Identifier" && callee.name.startsWith("set")) {
|
|
486
|
+
const varName = callee.name.slice(3);
|
|
487
|
+
return varName.charAt(0).toLowerCase() + varName.slice(1);
|
|
264
488
|
}
|
|
265
489
|
}
|
|
266
490
|
}
|
|
491
|
+
return null;
|
|
492
|
+
};
|
|
267
493
|
|
|
268
|
-
|
|
494
|
+
// Simple case: () => setX(!x)
|
|
495
|
+
if (functionBody.type === "CallExpression") {
|
|
496
|
+
stateVariable = checkCallExpression(functionBody);
|
|
269
497
|
}
|
|
270
498
|
|
|
271
|
-
|
|
499
|
+
// Block statement case: () => { setX(!x); }
|
|
500
|
+
if (functionBody.type === "BlockStatement" && functionBody.body.length > 0) {
|
|
501
|
+
for (const stmt of functionBody.body) {
|
|
502
|
+
if (stmt.type === "ExpressionStatement") {
|
|
503
|
+
stateVariable = checkCallExpression(stmt.expression);
|
|
504
|
+
if (stateVariable) break;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return stateVariable;
|
|
272
510
|
}
|
|
273
511
|
|
|
274
512
|
function createStringConversionFix(fixer, node) {
|
|
275
|
-
const sourceCode = context.getSourceCode();
|
|
276
513
|
const expression = node.expression;
|
|
277
514
|
|
|
278
515
|
let identifierName;
|
|
@@ -301,6 +538,31 @@ module.exports = {
|
|
|
301
538
|
const attributes = node.attributes || [];
|
|
302
539
|
const hasHandler = hasInteractiveHandler(attributes);
|
|
303
540
|
const role = getRoleValue(attributes);
|
|
541
|
+
const presentAriaAttrs = new Set(
|
|
542
|
+
attributes
|
|
543
|
+
.filter(
|
|
544
|
+
(attr) =>
|
|
545
|
+
attr.type === "JSXAttribute" &&
|
|
546
|
+
attr.name &&
|
|
547
|
+
typeof attr.name.name === "string" &&
|
|
548
|
+
attr.name.name.startsWith("aria-")
|
|
549
|
+
)
|
|
550
|
+
.map((attr) => attr.name.name)
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
if (role && hasHandler && ROLE_REQUIRED_DYNAMIC_ARIA[role]) {
|
|
554
|
+
const requiredAria = ROLE_REQUIRED_DYNAMIC_ARIA[role];
|
|
555
|
+
if (!presentAriaAttrs.has(requiredAria)) {
|
|
556
|
+
context.report({
|
|
557
|
+
node: node.name,
|
|
558
|
+
messageId: "missingStateBinding",
|
|
559
|
+
data: {
|
|
560
|
+
role,
|
|
561
|
+
ariaAttr: requiredAria,
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
304
566
|
|
|
305
567
|
attributes.forEach((attr) => {
|
|
306
568
|
if (attr.type !== "JSXAttribute") return;
|
|
@@ -308,12 +570,17 @@ module.exports = {
|
|
|
308
570
|
const attrName = attr.name.name;
|
|
309
571
|
|
|
310
572
|
if (!DYNAMIC_ARIA_ATTRIBUTES[attrName]) return;
|
|
573
|
+
if (matchesIgnorePattern(attrName)) return;
|
|
311
574
|
|
|
312
575
|
const attrValue = attr.value;
|
|
576
|
+
if (!attrValue) return;
|
|
577
|
+
if (matchesIgnorePattern(sourceCode.getText(attrValue))) return;
|
|
313
578
|
|
|
314
579
|
if (
|
|
315
580
|
isBooleanIdentifier(attrValue) &&
|
|
316
|
-
!hasProperStringConversion(attrValue)
|
|
581
|
+
!hasProperStringConversion(attrValue) &&
|
|
582
|
+
DYNAMIC_ARIA_ATTRIBUTES[attrName].validValues &&
|
|
583
|
+
(DYNAMIC_ARIA_ATTRIBUTES[attrName].validValues.includes("true") || DYNAMIC_ARIA_ATTRIBUTES[attrName].validValues.includes("false"))
|
|
317
584
|
) {
|
|
318
585
|
context.report({
|
|
319
586
|
node: attr,
|
|
@@ -321,9 +588,15 @@ module.exports = {
|
|
|
321
588
|
data: {
|
|
322
589
|
ariaAttr: attrName,
|
|
323
590
|
},
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
591
|
+
suggest: [
|
|
592
|
+
{
|
|
593
|
+
messageId: "suggestStringConversion",
|
|
594
|
+
data: { ariaAttr: attrName },
|
|
595
|
+
fix(fixer) {
|
|
596
|
+
return createStringConversionFix(fixer, attrValue);
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
],
|
|
327
600
|
});
|
|
328
601
|
return;
|
|
329
602
|
}
|
|
@@ -332,7 +605,7 @@ module.exports = {
|
|
|
332
605
|
const handlers = attributes.filter(
|
|
333
606
|
(a) =>
|
|
334
607
|
a.type === "JSXAttribute" &&
|
|
335
|
-
|
|
608
|
+
interactiveHandlers.includes(a.name.name)
|
|
336
609
|
);
|
|
337
610
|
|
|
338
611
|
const handlerNames = handlers.map((a) => a.name.name);
|
|
@@ -343,6 +616,9 @@ module.exports = {
|
|
|
343
616
|
stateVariable = extractStateVariableFromHandler(handler);
|
|
344
617
|
if (stateVariable) break;
|
|
345
618
|
}
|
|
619
|
+
if (matchesIgnorePattern(stateVariable)) {
|
|
620
|
+
stateVariable = null;
|
|
621
|
+
}
|
|
346
622
|
|
|
347
623
|
context.report({
|
|
348
624
|
node: attr,
|
|
@@ -351,13 +627,19 @@ module.exports = {
|
|
|
351
627
|
ariaAttr: attrName,
|
|
352
628
|
handler: handlerNames.join(", "),
|
|
353
629
|
},
|
|
354
|
-
|
|
355
|
-
?
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
630
|
+
suggest: stateVariable
|
|
631
|
+
? [
|
|
632
|
+
{
|
|
633
|
+
messageId: "suggestStateBinding",
|
|
634
|
+
data: { ariaAttr: attrName },
|
|
635
|
+
fix(fixer) {
|
|
636
|
+
return fixer.replaceText(
|
|
637
|
+
attrValue,
|
|
638
|
+
`{${stateVariable} ? 'true' : 'false'}`
|
|
639
|
+
);
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
]
|
|
361
643
|
: undefined,
|
|
362
644
|
});
|
|
363
645
|
return;
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
"use strict";
|
|
7
7
|
|
|
8
|
+
const { roles: ARIA_ROLES } = require("aria-query");
|
|
9
|
+
|
|
8
10
|
// Valid ARIA properties according to ARIA 1.2 spec
|
|
9
11
|
const VALID_ARIA_PROPS = new Set([
|
|
10
12
|
// Widget attributes
|
|
@@ -76,6 +78,56 @@ const REQUIRES_VALUE = new Set([
|
|
|
76
78
|
"aria-flowto",
|
|
77
79
|
]);
|
|
78
80
|
|
|
81
|
+
// Common ARIA typos and their correct versions
|
|
82
|
+
const ARIA_TYPOS = {
|
|
83
|
+
"aria-labell": "aria-label",
|
|
84
|
+
"aria-hidde": "aria-hidden",
|
|
85
|
+
"aria-expect": "aria-expanded",
|
|
86
|
+
"aria-describeb": "aria-describedby",
|
|
87
|
+
"aria-control": "aria-controls",
|
|
88
|
+
"aria-check": "aria-checked",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Fallback role definitions when aria-query data is unavailable for a role.
|
|
92
|
+
const ROLE_DEFINITIONS = {
|
|
93
|
+
checkbox: {
|
|
94
|
+
required: ["aria-checked"],
|
|
95
|
+
allowed: ["aria-readonly", "aria-disabled"],
|
|
96
|
+
},
|
|
97
|
+
radio: {
|
|
98
|
+
required: ["aria-checked"],
|
|
99
|
+
allowed: ["aria-disabled"],
|
|
100
|
+
},
|
|
101
|
+
combobox: {
|
|
102
|
+
required: ["aria-controls", "aria-expanded"],
|
|
103
|
+
allowed: ["aria-haspopup", "aria-autocomplete", "aria-required"],
|
|
104
|
+
},
|
|
105
|
+
tab: {
|
|
106
|
+
required: ["aria-selected"],
|
|
107
|
+
allowed: ["aria-disabled"],
|
|
108
|
+
},
|
|
109
|
+
slider: {
|
|
110
|
+
required: ["aria-valuenow"],
|
|
111
|
+
allowed: ["aria-valuemin", "aria-valuemax", "aria-orientation", "aria-readonly"],
|
|
112
|
+
},
|
|
113
|
+
switch: {
|
|
114
|
+
required: ["aria-checked"],
|
|
115
|
+
allowed: ["aria-readonly", "aria-disabled"],
|
|
116
|
+
},
|
|
117
|
+
scrollbar: {
|
|
118
|
+
required: ["aria-valuenow", "aria-valuemin", "aria-valuemax", "aria-orientation"],
|
|
119
|
+
allowed: ["aria-controls"],
|
|
120
|
+
},
|
|
121
|
+
heading: {
|
|
122
|
+
required: ["aria-level"],
|
|
123
|
+
allowed: [],
|
|
124
|
+
},
|
|
125
|
+
img: {
|
|
126
|
+
required: [],
|
|
127
|
+
allowed: ["aria-label", "aria-labelledby"],
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
79
131
|
// Valid values for boolean-like ARIA attributes
|
|
80
132
|
const BOOLEAN_ARIA_VALUES = new Set(["true", "false"]);
|
|
81
133
|
|
|
@@ -128,9 +180,9 @@ module.exports = {
|
|
|
128
180
|
description: "Validate static ARIA attributes for correctness",
|
|
129
181
|
category: "Accessibility",
|
|
130
182
|
recommended: true,
|
|
131
|
-
url: "https://github.com/
|
|
183
|
+
url: "https://github.com/comento/eslint-plugin-aria-state-validator",
|
|
132
184
|
},
|
|
133
|
-
fixable:
|
|
185
|
+
fixable: "code",
|
|
134
186
|
schema: [],
|
|
135
187
|
messages: {
|
|
136
188
|
invalidAriaProp:
|
|
@@ -145,10 +197,17 @@ module.exports = {
|
|
|
145
197
|
"Do not use both aria-label and aria-labelledby. aria-labelledby takes precedence.",
|
|
146
198
|
redundantRole:
|
|
147
199
|
'Redundant role "{{role}}" on <{{element}}>. Native HTML semantics already convey this role.',
|
|
200
|
+
missingRequiredAria:
|
|
201
|
+
'Role "{{role}}" requires the following ARIA attributes: {{missing}}.',
|
|
202
|
+
disallowedAriaForRole:
|
|
203
|
+
'ARIA attribute "{{prop}}" is not recommended for role "{{role}}".',
|
|
204
|
+
ariaTypo:
|
|
205
|
+
'Invalid ARIA attribute "{{prop}}". Did you mean "{{correct}}"?',
|
|
148
206
|
},
|
|
149
207
|
},
|
|
150
208
|
|
|
151
209
|
create(context) {
|
|
210
|
+
const sourceCode = context.getSourceCode();
|
|
152
211
|
// Elements with implicit roles
|
|
153
212
|
const IMPLICIT_ROLES = {
|
|
154
213
|
a: "link", // when href present
|
|
@@ -170,7 +229,7 @@ module.exports = {
|
|
|
170
229
|
};
|
|
171
230
|
|
|
172
231
|
function getAttributeValue(attr) {
|
|
173
|
-
if (!attr.value) return null;
|
|
232
|
+
if (!attr || !attr.value) return null;
|
|
174
233
|
|
|
175
234
|
if (attr.value.type === "Literal") {
|
|
176
235
|
return attr.value.value;
|
|
@@ -235,17 +294,72 @@ module.exports = {
|
|
|
235
294
|
role: roleValue,
|
|
236
295
|
element: elementName,
|
|
237
296
|
},
|
|
297
|
+
fix(fixer) {
|
|
298
|
+
const [start, end] = roleAttr.range;
|
|
299
|
+
const removeStart =
|
|
300
|
+
start > 0 && sourceCode.text[start - 1] === " " ? start - 1 : start;
|
|
301
|
+
return fixer.removeRange([removeStart, end]);
|
|
302
|
+
},
|
|
238
303
|
});
|
|
239
304
|
}
|
|
240
305
|
}
|
|
241
306
|
|
|
307
|
+
function getRoleSpec(roleValue) {
|
|
308
|
+
if (!roleValue) return null;
|
|
309
|
+
|
|
310
|
+
const ariaRole = ARIA_ROLES.get(roleValue);
|
|
311
|
+
if (ariaRole) {
|
|
312
|
+
return {
|
|
313
|
+
required: Object.keys(ariaRole.requiredProps || {}),
|
|
314
|
+
allowedSet: new Set(Object.keys(ariaRole.props || {})),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (ROLE_DEFINITIONS[roleValue]) {
|
|
319
|
+
const fallback = ROLE_DEFINITIONS[roleValue];
|
|
320
|
+
return {
|
|
321
|
+
required: fallback.required,
|
|
322
|
+
allowedSet: new Set([...fallback.required, ...fallback.allowed]),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
242
329
|
return {
|
|
243
330
|
JSXOpeningElement(node) {
|
|
244
331
|
const attributes = node.attributes || [];
|
|
332
|
+
const elementName = node.name.name;
|
|
245
333
|
|
|
246
334
|
// Check for redundant roles
|
|
247
335
|
checkRedundantRole(node);
|
|
248
336
|
|
|
337
|
+
const roleAttr = attributes.find(
|
|
338
|
+
(attr) => attr.type === "JSXAttribute" && attr.name.name === "role"
|
|
339
|
+
);
|
|
340
|
+
const roleValue = getAttributeValue(roleAttr);
|
|
341
|
+
const roleSpec = getRoleSpec(roleValue);
|
|
342
|
+
|
|
343
|
+
// Required ARIA attributes for role
|
|
344
|
+
if (roleValue && roleSpec) {
|
|
345
|
+
const required = roleSpec.required;
|
|
346
|
+
const present = attributes
|
|
347
|
+
.filter((attr) => attr.type === "JSXAttribute" && attr.name.name.startsWith("aria-"))
|
|
348
|
+
.map((attr) => attr.name.name);
|
|
349
|
+
|
|
350
|
+
const missing = required.filter((req) => !present.includes(req));
|
|
351
|
+
if (missing.length > 0) {
|
|
352
|
+
context.report({
|
|
353
|
+
node: roleAttr || node.name,
|
|
354
|
+
messageId: "missingRequiredAria",
|
|
355
|
+
data: {
|
|
356
|
+
role: roleValue,
|
|
357
|
+
missing: missing.join(", "),
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
249
363
|
// Check for conflicting aria-label and aria-labelledby
|
|
250
364
|
const hasAriaLabel = attributes.some(
|
|
251
365
|
(attr) =>
|
|
@@ -264,6 +378,9 @@ module.exports = {
|
|
|
264
378
|
context.report({
|
|
265
379
|
node: ariaLabelAttr,
|
|
266
380
|
messageId: "ariaLabelWithLabelledBy",
|
|
381
|
+
fix(fixer) {
|
|
382
|
+
return fixer.remove(ariaLabelAttr);
|
|
383
|
+
}
|
|
267
384
|
});
|
|
268
385
|
}
|
|
269
386
|
|
|
@@ -273,6 +390,22 @@ module.exports = {
|
|
|
273
390
|
|
|
274
391
|
const attrName = attr.name.name;
|
|
275
392
|
|
|
393
|
+
// Check for typos in aria- attributes
|
|
394
|
+
if (attrName.startsWith("aria-") && !VALID_ARIA_PROPS.has(attrName)) {
|
|
395
|
+
const correct = ARIA_TYPOS[attrName];
|
|
396
|
+
if (correct) {
|
|
397
|
+
context.report({
|
|
398
|
+
node: attr.name,
|
|
399
|
+
messageId: "ariaTypo",
|
|
400
|
+
data: { prop: attrName, correct: correct },
|
|
401
|
+
fix(fixer) {
|
|
402
|
+
return fixer.replaceText(attr.name, correct);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
276
409
|
// Only check aria-* attributes
|
|
277
410
|
if (!attrName.startsWith("aria-")) return;
|
|
278
411
|
|
|
@@ -286,6 +419,16 @@ module.exports = {
|
|
|
286
419
|
return;
|
|
287
420
|
}
|
|
288
421
|
|
|
422
|
+
// Check role-attribute compatibility from aria-query role props.
|
|
423
|
+
if (roleValue && roleSpec && roleSpec.allowedSet.size > 0 && !roleSpec.allowedSet.has(attrName)) {
|
|
424
|
+
context.report({
|
|
425
|
+
node: attr,
|
|
426
|
+
messageId: "disallowedAriaForRole",
|
|
427
|
+
data: { prop: attrName, role: roleValue },
|
|
428
|
+
});
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
289
432
|
const value = getAttributeValue(attr);
|
|
290
433
|
|
|
291
434
|
// Can't validate dynamic values
|
|
@@ -331,6 +474,11 @@ module.exports = {
|
|
|
331
474
|
node: attr,
|
|
332
475
|
messageId: "invalidBooleanValue",
|
|
333
476
|
data: { prop: attrName, value: String(value) },
|
|
477
|
+
fix(fixer) {
|
|
478
|
+
if (value === "yes" || value === "1") return fixer.replaceText(attr.value, '"true"');
|
|
479
|
+
if (value === "no" || value === "0") return fixer.replaceText(attr.value, '"false"');
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
334
482
|
});
|
|
335
483
|
return;
|
|
336
484
|
}
|
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.1",
|
|
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": [
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
"license": "MIT",
|
|
36
36
|
"repository": {
|
|
37
37
|
"type": "git",
|
|
38
|
-
"url": "https://github.com/
|
|
38
|
+
"url": "https://github.com/comento/eslint-plugin-aria-state-validator.git"
|
|
39
39
|
},
|
|
40
40
|
"bugs": {
|
|
41
|
-
"url": "https://github.com/
|
|
41
|
+
"url": "https://github.com/comento/eslint-plugin-aria-state-validator/issues"
|
|
42
42
|
},
|
|
43
|
-
"homepage": "https://github.com/
|
|
43
|
+
"homepage": "https://github.com/comento/eslint-plugin-aria-state-validator#readme",
|
|
44
44
|
"engines": {
|
|
45
45
|
"node": ">=14.0.0"
|
|
46
46
|
},
|
|
@@ -50,8 +50,12 @@
|
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@babel/eslint-parser": "^7.28.5",
|
|
52
52
|
"@babel/preset-react": "^7.28.5",
|
|
53
|
+
"@typescript-eslint/parser": "^8.57.2",
|
|
53
54
|
"eslint": "^9.39.1",
|
|
54
55
|
"eslint-plugin-react": "^7.37.5",
|
|
55
56
|
"jest": "^30.2.0"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"aria-query": "^5.3.2"
|
|
56
60
|
}
|
|
57
61
|
}
|